Upgrade Claude from text search to code understanding.

While Constellation's MCP server provides raw code intelligence capabilities, this plugin enhances your Claude Code experience with:
| Feature | Benefit |
|---|---|
| Slash Commands | Quick access to common workflows |
| Contextual Skills | Claude automatically loads relevant knowledge — including proactive impact analysis before risky changes |
| Safety Hooks | Nudges Claude toward code_intel over text search at session start, in subagents, in the search tools' descriptions, and before symbol-like searches inside indexed projects, and adds a one-line code_intel hint to the result of a symbol search |
Execute powerful analysis with simple slash commands:
| Command | Description |
|---|---|
/constellation:status | Check API connectivity and project indexing status |
/constellation:diagnose | Quick health check for connectivity and authentication |
/constellation:impact <symbol> <file> | Analyze blast radius before changing a symbol |
/constellation:deps <file> [--reverse] | Map dependencies or find what depends on a file |
/constellation:unused | Discover orphaned exports and dead code |
/constellation:architecture | Get a high-level overview of your codebase structure |
Claude automatically activates specialized knowledge based on your questions:
| Skill | Triggers When You Ask About... |
|---|---|
| constellation-troubleshooting | Error codes, connectivity issues, debugging problems |
| impact-analysis | Renaming, refactoring, deleting, or moving symbols/files; "what would break if...", "is X dead code", "what depends on X" |
Example Trigger:
You: "Rename AuthService to AuthenticationService"
Claude: "Before renaming, let me analyze the potential impact..."
[impact-analysis skill activates, runs api.impactAnalysis, reports risk + dependents]
Event hooks enable intelligent, transparent assistance. They run in-process inside Claude Code, declared by the plugin's hooks module (hooks/register.ts):
| Hook | Event (matcher) | Behavior | |||
|---|---|---|---|---|---|
| Session Awareness | classic.SessionStart | Injects code_intel MCP tool awareness at session start, including after clear, resume, and compact | |||
| Subagent Awareness | classic.SubagentStart | Injects code_intel awareness into spawned subagents (built-ins like Explore/Plan don't inherit project AGENTS.md) | |||
| Search Tool Nudge | tool.call (`Grep\ | Glob`) | Reminds Claude to prefer code_intel when a Grep pattern looks like a symbol (AuthService, class UserService, getUser\(), or a Glob has a PascalCase or camelCase file stem (/UserService.ts), and the search is inside an indexed project. Quoted phrases, error text, TODO-style markers, regex with character classes or alternation, and extension globs such as /*.ts get no reminder | ||
| Bash Search Nudge | tool.call (Bash) | Parses the leading command, up to the first unquoted `\ | , ;, && or \ | \ | (or the command after a leading cd <dir> &&). When it is grep, egrep, rg, ag, ack or git grep (also after git -C <dir> or variable assignments such as LC_ALL=C) and the pattern looks like a symbol, adds the same reminder when the searched directory is inside an indexed project. Flags and their values (-t ts, -A 3, -g '*.ts') are skipped, and language keywords such as import are not symbols. A grep after a pipe, awk, and findstr` no longer trigger a reminder |
| Tool description guidance | tool.describe (`Grep\ | Glob\ | Bash`) | Appends a short rule to the description of the search tools: use code_intel for symbol definitions, references, dependents, call graphs and impact, and keep text search for literal text. Applied once per session; if the session starts outside an indexed project, it is added once the working directory moves into one, and then kept, so moving around never rewrites the prompt cache. Native macOS and Linux builds have no Grep or Glob tool, so there it is the Bash description that carries the rule | |
| Reminder budget | turn.start, tool.call (code_intel), classic.SessionStart (clear, resume, fork) | Caps the search reminders at nudgeLimit per session (default 3), counted separately for each subagent, and skips a reminder when code_intel was already called earlier in the same turn. See Reminder limit | |||
| Adoption counter | tool.call (code_intel), tool.call (`Grep\ | Glob\ | Bash), ui.render (Spinner, only with showAdoption`) | Counts Claude's code_intel calls and, with a key and inside an indexed project, its text searches (symbol-like or literal), for the session and per day, on this machine only. Shown on the Stats tab of /constellation and, with showAdoption on, beside the spinner. See Adoption counter | |
| Search result hint | tool.call (`Grep\ | Bash`) | After a symbol search with Grep, or with grep, egrep, rg, ag, ack or git grep in the shell, looks the symbol up with code_intel and adds one line to the search result, for example >_CONSTELLATION:// AuthService (class) is defined at src/auth/auth.service.ts:12, with 4 usages. Use code_intel for references, callers, and impact. See Search result hint |
All hooks are gated on CONSTELLATION_ACCESS_KEY being set and starting with ak: (no key means a silent no-op, so the plugin doesn't nag in environments where Constellation isn't configured), with one exception: the adoption counter counts a code_intel call with or without a key. The counter only counts and adds nothing to Claude's context. The tool description guidance, the search nudges, the search result hint and the counting of text searches also require a constellation.json in the searched directory (the call's path for Grep and Glob when set, else the working directory) or a parent, so they stay out of projects that are not indexed. Subagents such as Explore see the same rewritten descriptions. The reminders are added to what the call already returns, so a permission decision made by another hook is kept.
The search reminders are limited so they stay useful instead of repeating on every search:
nudgeLimit reminders (default 3). Only searches that would have drawn a reminder count against it. Each subagent has its own limit, because its context starts empty.code_intel was already called earlier in the same turn, a symbol search in that turn gets no reminder and uses none of the limit./clear and when a session is resumed or forked.Set nudgeLimit with /config (it appears under the Constellation plugin) or run claude plugin configure constellation. A value of 0 turns the search reminders off while keeping the awareness text and the description guidance.
When Claude searches for a symbol with Grep, or with grep, egrep, rg, ag, ack or git grep in the shell, the search runs first and its result is kept as it is. The plugin then looks the symbol up with code_intel (the most used exact-name match, exported symbols first, with its usage count from the graph) and adds one line after the result, so Claude learns the graph has the answer without being told off for searching. It applies to subagent searches too.
/clear, /resume or /branch. Two searches running at once show it once.code_intel was already called earlier in the same turn, for patterns that are not symbol-like (the same rules as the reminders), and outside indexed projects.nudgeLimit: the hint has its own once-per-symbol rule and never uses the reminder count.Turn it off by setting augmentGrep to false with /config or claude plugin configure constellation.
The plugin counts how often Claude uses code_intel beside how often it searches text, so you can see whether the reminders work. The counts stay on your machine: they are never sent to Constellation or anywhere else.
code_intel call Claude or a subagent makes (with the time the call reported), and each search Claude or a subagent runs with Grep, Glob, or grep, egrep, rg, ag, ack or git grep in the shell, as symbol-like or literal by the same rules as the reminders. A search counts only where code_intel could have answered it: with an access key set, and inside an indexed project (a constellation.json in the searched directory or a parent).code_intel lookups (the search result hint, the impact features, the /constellation pane), a search another plugin runs, a search with no access key or outside an indexed project, a call or search that was refused, and shell commands that are not searches.code_intel calls out of code_intel calls plus symbol-like searches. Literal searches are shown but left out of it, because code_intel could not have answered them./constellation (or /constellation stats) shows this session, today and the last 30 days. Set showAdoption to true with /config or claude plugin configure constellation to also show the session's counts beside the spinner. It is off by default./constellation stats reads them. The session's counts start over after /clear and when a session is resumed or forked; the daily totals stay.The hooks above come from a hooks module (hooks/register.ts) that Claude Code loads and runs in-process, in the same session, instead of starting a separate node process per event. The module makes no network requests of its own. It runs git only through Claude Code's process call, in the project root: git rev-parse, git symbolic-ref and git diff --name-only when Claude runs gh pr create, and git rev-parse and git rev-list --count for the index freshness indicator; the search result hint and the /constellation command query through the plugin's own MCP server. It uses only the Claude Code calls listed under Data Handling.
/constellation command: a pane with Status, Diagnose, Deps, Unused, Explore and Stats tabs (keys 1 to 6, r to refresh, Esc to close), run as /constellation [status|diagnose|deps <file>|unused [kind]|explore [query]|stats]. The Stats tab shows the adoption counter for this session, today and the last 30 days, all three read at the same moment (Refresh reads them again); it sends no query and opens without a key or an indexed project. It starts no model turn and works while Claude is mid-turn. Where the session cannot draw a pane (for example VS Code), it answers with a few lines of text instead. The Markdown /constellation:* commands remain.p (switch project) in the pane's header forgets it and shows the list again.colors option, set with /config or claude plugin configure constellation. brand (the default) uses the Constellation palette and switches to Claude Code's own theme colors on light, ANSI and color-blind themes; theme always uses Claude Code's theme colors; none draws no color. Every status also carries a word and a symbol, so no setting loses information.impactGate option, set with /config or claude plugin configure constellation, is off by default. dialog asks you before an edit Claude Code would run without asking, to a file whose impact is at or above impactThreshold, offering Proceed, Proceed and don't ask again for this file, or Cancel; an edit already headed to the permission prompt gets a one-line impact summary beside it instead. In auto mode, where such an edit goes to the auto-mode classifier rather than to you, both dialog and native ask you first, and the classifier still decides after you choose Proceed. native sends that edit to Claude Code's own permission prompt, with the summary as a toast. require-analysis refuses Claude's first edit to such a file once, with the impact report as the reason, unless Claude already ran a code_intel impact query (impactAnalysis, traceSymbolUsage or getDependents) naming the file or a symbol its dependents import; the retry goes through. It is the mode for CI and headless runs, where no one can answer a dialog or a prompt. impactThreshold is high (the default) or critical. Dependents come from the last indexed commit, so a branch with unindexed changes can show fewer or more than the working tree has. Counts can also fall short where imports go through tsconfig path aliases or export * barrels. Any failure to read the impact, or a lookup that takes more than 3 seconds, lets the edit through; in require-analysis that edit was the file's one check, so later edits to it go through as well.✦ index 4 commits behind · indexed 2h ago. A failed ping shows its error and the next step instead. Nothing is shown when the index matches HEAD. The check runs at session start, after Claude runs a git commit, pull, checkout, merge or rebase, when the working directory changes and every 5 minutes. A session that cannot draw gets one log line.code_intel calls and results in the transcript get compact rows, for example ✦ code_intel · impactAnalysis · constellation-core and ✗ HIGH · 23 dependents · 140 ms · as of abc1234. The result row summarizes what came back; an error shows its headline and the next step, for example ✗ AUTH_ERROR · Your access key wasn't accepted · constellation auth. An interrupted call, a result too large to read and a failure without an error code keep Claude Code's own row. Colors follow the colors option. With --verbose, Claude Code's own drawing also shows under the row.turnSummary option, on by default. After a turn that changed files, one line appears under Claude's answer, for example >_CONSTELLATION:// 3 files changed (1 new) · 12 downstream dependents (4 test files) · as of fba36d5. It counts edits made by subagents too. A file created this turn counts as new and is not queried. Only files in one project are counted and queried: the project of the first edited file inside one, so plan or scratch files and files in other projects are left out. A count of 100+ means a file has at least that many dependents. Files deleted with a shell rm are not tracked. Dependents come from the indexed commit (the 7-character commit is shown) and undercount imports through tsconfig path aliases and export * barrels.prImpact option, inform (the default), require or off. When Claude runs gh pr create or gh pr new (recognized anywhere in a && or ; chain or on a later line), the plugin lists the branch's changed files, their downstream dependents (those importing the most changed files first), the affected test files and the changed files' exported symbols that other files import. A PR for another branch (--head) or repository (--repo or GH_REPO), or after a cd the shell expands (~, $VAR, -), is left alone. Without --base, the base is the remote's default branch (origin/HEAD, else origin/main or origin/master when it exists); in a fork that is the fork's default branch, so pass --base there. A body file the same command writes is checked through the command text; a body file that cannot be read counts as having no heading. inform lets the command run and logs the section; require refuses the command once per branch per session, with the section as the reason, until the PR body has an ## Impact heading. It fails open: a git or code_intel error lets the command run. Dependents come from the indexed commit (shown as its 7-character commit) and undercount imports through tsconfig path aliases and export * barrels.showAdoption option, off by default. When on, the line that animates while a turn runs also shows the session's counts, for example Sauteing · ✦ 5 code_intel / 1 grep…: code_intel calls first, then symbol-like text searches, for the main conversation and subagents together. Nothing is added until one of the two is above zero, and the line is redrawn only when one of the two changes. Only the text after the spinner's word changes; the word, the timer and the token count stay Claude Code's own.hooks/hooks.json keeps an empty "hooks" block beside "modules", so older builds should still read it as a valid hooks file and simply run no hooks. This has not been tested on an older build./plugin, start Claude Code with --safe-mode, or set disableAllHooks in your settings.What the plugin runs, reads, and sends:
| Component | Runs / reads | Sends |
|---|---|---|
| MCP server | Started with npx -y @constellationdev/mcp@<pinned version>, which downloads the package from the npm registry. Reads constellation.json and the current git branch from your project, and reads lines from local source files to attach code snippets to query results | Queries (symbol names, file paths, project ID, branch) to the Constellation API at https://api.constellationdev.io, or the self-hosted URL you configure via CONSTELLATION_API_URL or constellation.json, authenticated with CONSTELLATION_ACCESS_KEY. Source code is never sent to the Constellation API. Code snippets are returned only to Claude in the local session |
| MCP server usage metrics | Runs after each code_intel call. On by default; set CONSTELLATION_USAGE_METRICS=false to turn it off | A usage event (project ID, branch, which API methods ran, estimated token counts, durations) to the same Constellation API at /intel/v1/usage, or to USAGE_ENDPOINT_URL if you set it, authenticated with CONSTELLATION_ACCESS_KEY. Contains no source code, snippets, or symbol contents |
| Hooks (hooks/register.ts) | Run in-process in Claude Code. Calls: $.clock.after (retries a failed search result hint or impact lookup after a minute, re-reads a file's impact after five minutes, runs the session start's connection check and freshness ping, and runs reload-plugins once the current handler has returned), $.clock.every (the 5-minute git-only freshness re-check), $.clock.now (tells when the code_intel rows last read the theme and verbose settings, when the onboarding band last read the theme, and which local day an adoption count belongs to), $.clock.sleep (the search result hint's 2.5 second deadline, the impact gate's 3 second one, the turn summary's 2 second one and the PR impact section's 8 second one, and the one second the Stats tab waits for adoption counts still being saved), $.command.register (adds the /constellation command), $.command.run (runs reload-plugins after sign-in, or once when the session start's ping with a stored key is refused, so every plugin reloads and the Constellation MCP server starts with the key), $.config.list (reads Claude Code's theme to choose the pane's, code_intel rows' and freshness band's colors, and the verbose setting to show Claude Code's own code_intel rows under the summaries; the rows read both at most once a second, and again when either changes in /config; the onboarding band reads the theme at most once a second), $.env.get (reads CONSTELLATION_ACCESS_KEY and checks it starts with ak:), $.env.set (sets CONSTELLATION_ACCESS_KEY to the stored key), $.fs.exists (checks for constellation.json in the searched directory and its parents, the project root walk for the freshness indicator, whether a file about to be edited already exists, and, for the onboarding, the .git and constellation.json walk-up from the working directory and whether /bin/sh exists), $.fs.read (reads the --body-file of gh pr create to check for an Impact heading, and, before the session start's ping with a stored key, the project's constellation.json, and, for the freshness indicator, the same file when the working directory moves into another project root: the ping is skipped when its apiUrl is not https://api.constellationdev.io or the file cannot be read), $.mcp.call and $.mcp.connect (run the onboarding's ping, which is also the freshness indicator's ping at session start and, when a key is configured and constellation.json uses the default API, on a move into another project root, the search result hint's, the /constellation command's, the impact gate's, the turn summary's and the PR impact section's code_intel queries through the plugin's MCP server), $.process.run (the onboarding's login-shell printenv CONSTELLATION_ACCESS_KEY key read-back at the start of an interactive session, or C:\Windows\System32\reg.exe query where there is no /bin/sh (Windows), and, when you press Sign in or Index this project, command -v constellation in a plain and then a login /bin/sh, each printing that shell's PATH; the read-back's output passes through any plugin that hooks process.run. It also runs git rev-parse, git symbolic-ref and git diff --name-only, with the repository's fsmonitor, hooks, external diff and network fetches turned off, in the project root when Claude runs gh pr create inside the session's working directory. For the freshness indicator it runs git rev-parse and git rev-list --count the same way, at session start, after a Bash call that runs a git commit, pull, checkout, merge or rebase, on a working-directory change and ev
hooks/register.ts 31 lines1import type { Register } from 'claude-code';
2import { registerAdoption } from './adoption';
3import { registerAugment } from './augment';
4import { registerBudget } from './budget';
5import { registerCommand } from './command';
6import { registerDescribe } from './describe';
7import { registerFreshness } from './freshness';
8import { registerImpactGate } from './impact';
9import { registerNudges } from './nudge';
10import { registerOnboarding } from './onboarding';
11import { registerPrImpact } from './primpact';
12import { registerSession } from './session';
13import { registerToolRows } from './toolrows';
14import { registerTurnSummary } from './turnsummary';
15
16export const register: Register = (on, options) => {
17 registerBudget(on, options);
18 registerNudges(on);
19 registerAdoption(on, options);
20 registerDescribe(on);
21 registerAugment(on, options);
22 registerSession(on);
23 registerCommand(on, options);
24 registerImpactGate(on, options);
25 registerTurnSummary(on, options);
26 registerPrImpact(on, options);
27 registerToolRows(on, options);
28 registerOnboarding(on, options);
29 registerFreshness(on, options);
30};
31hooks/adoption.ts 409 lines1import type { On, PluginOptions } from 'claude-code';
2import { isRecord, num } from './lib';
3
4/** How often code_intel was called, how long those calls took, and how many text searches ran beside them. */
5export type Counts = {
6 /** code_intel calls the agent made. */
7 codeIntel: number;
8 /** The time those calls reported, summed, in milliseconds. */
9 codeIntelMs: number;
10 /** Text searches for something that looks like a symbol. */
11 symbol: number;
12 /** Text searches for literal text. */
13 literal: number;
14};
15
16/** Counts for the main conversation and for every subagent together. */
17export type Buckets = { main: Counts; subagents: Counts };
18
19/** One counted call: whose it was and what it adds. */
20export type Note = { isMain: boolean; counts: Counts };
21
22/** Totals for the local day of the clock value given, and for the 30 days ending with it. */
23export type Stats = { today: Counts; last30: Counts };
24
25const FIELDS = ['codeIntel', 'codeIntelMs', 'symbol', 'literal'] as const;
26
27/** Every stored key starts with this, then the local date and the session id. */
28const PREFIX = 'adoption:';
29const DATED_KEY = /^adoption:(\d{4}-\d{2}-\d{2}):/;
30
31/** How many days, today included, the stored counts are kept and summed. */
32const KEPT_DAYS = 30;
33
34/** The brand mark that leads the spinner's counts. */
35const MARK = '✦';
36
37function zero(): Counts {
38 return { codeIntel: 0, codeIntelMs: 0, symbol: 0, literal: 0 };
39}
40
41function empty(): Buckets {
42 return { main: zero(), subagents: zero() };
43}
44
45function add(into: Counts, counts: Counts): void {
46 for (const field of FIELDS) into[field] += counts[field];
47}
48
49/** This session's totals. Module state only: a reload of the hooks module starts them over. */
50let session = empty();
51
52/** Whether the spinner shows the session's counts: the `showAdoption` option, read when the hooks register. */
53let shown = false;
54
55/**
56 * This session's stored entry per day key, as a promise so that calls arriving
57 * together share one read of the store. Kept across `resetAdoption()`: the
58 * stored day totals belong to the day, not the conversation.
59 */
60const days = new Map<string, Promise<Buckets>>();
61
62/**
63 * The last save queued per day key. Each save waits for the one before it, so
64 * two saves of one entry never land out of order and the last one carries
65 * every count.
66 */
67const writes = new Map<string, Promise<void>>();
68
69/** The background work still running: what `settled()` waits for. */
70const running = new Set<Promise<void>>();
71
72/** Whether this load already deleted the entries past their kept days. */
73let pruned = false;
74
75function note(isMain: boolean, counts: Counts): Note {
76 add(isMain ? session.main : session.subagents, counts);
77 return { isMain, counts };
78}
79
80/** Counts one code_intel call in the session totals, with its time when the answer carried one. */
81export function noteCodeIntelCall(isMain: boolean, timeMs?: number): Note {
82 return note(isMain, { ...zero(), codeIntel: 1, codeIntelMs: typeof timeMs === 'number' ? timeMs : 0 });
83}
84
85/** Counts one text search in the session totals, as symbol-like or literal. */
86export function noteSearch(isMain: boolean, symbolLike: boolean): Note {
87 return note(isMain, { ...zero(), symbol: symbolLike ? 1 : 0, literal: symbolLike ? 0 : 1 });
88}
89
90/** This session's totals, as copies. */
91export function sessionCounts(): Buckets {
92 return { main: { ...session.main }, subagents: { ...session.subagents } };
93}
94
95/** The main conversation's counts and the subagents' together. */
96export function total(buckets: Buckets): Counts {
97 const sum = zero();
98 add(sum, buckets.main);
99 add(sum, buckets.subagents);
100 return sum;
101}
102
103/**
104 * The share of symbol lookups that went to code_intel: its calls over its calls
105 * plus the symbol-like text searches. Literal searches are not lookups
106 * code_intel could have answered, so they never enter it. Undefined with nothing to divide.
107 */
108export function ratio(counts: Counts): number | undefined {
109 const lookups = counts.codeIntel + counts.symbol;
110 return lookups === 0 ? undefined : counts.codeIntel / lookups;
111}
112
113/**
114 * What the spinner adds before its own suffix: the session's code_intel calls
115 * and symbol-like text searches, main conversation and subagents together.
116 * Empty while both are 0.
117 */
118export function suffixText(): string {
119 const { codeIntel, symbol } = total(session);
120 return codeIntel === 0 && symbol === 0 ? '' : ` · ${MARK} ${codeIntel} code_intel / ${symbol} grep`;
121}
122
123/** True when the spinner shows the counts, so a new count asks for a redraw. */
124export function showsAdoption(): boolean {
125 return shown;
126}
127
128/** Thousands separators for a count, as `3,079`. */
129function grouped(n: number): string {
130 return n.toLocaleString('en-US');
131}
132
133function calls(n: number): string {
134 return `${grouped(n)} code_intel call${n === 1 ? '' : 's'}`;
135}
136
137function searches(n: number, what: string): string {
138 return `${grouped(n)} ${what} search${n === 1 ? '' : 'es'}`;
139}
140
141/** Milliseconds under a second, else seconds to one decimal, as `1.2 s`. */
142function duration(ms: number): string {
143 return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
144}
145
146/** What the percentage in a row of figures is. */
147export const SHARE_LABEL = 'structural lookups';
148export const SHARE_NOTE = 'Structural lookups: code_intel calls out of code_intel calls plus symbol-like text searches. Literal searches are left out.';
149/** Said in place of the stored rows when the store cannot be read. */
150export const UNREAD_NOTE = 'The stored history could not be read.';
151
152/** One row's counts in words: the calls with their summed time, both kinds of search, and the ratio as a percentage. */
153export type Figures = { calls: string; symbol: string; literal: string; share: string };
154
155function percent(counts: Counts): string {
156 const share = ratio(counts);
157 return share === undefined ? 'n/a' : `${Math.round(share * 100)}%`;
158}
159
160export function figures(counts: Counts): Figures {
161 return {
162 calls: `${calls(counts.codeIntel)} (${duration(counts.codeIntelMs)})`,
163 symbol: searches(counts.symbol, 'symbol-like'),
164 literal: searches(counts.literal, 'literal'),
165 share: percent(counts),
166 };
167}
168
169/** One row of the Stats tab: its name, its counts and, for the session, the main and subagent split. */
170export type StatRow = { label: string; counts: Counts; split?: string };
171
172/** The session row, then today and the last 30 days when the stored counts were read. */
173export function statRows(buckets: Buckets, stored: Stats | undefined): StatRow[] {
174 const part = (name: string, counts: Counts) => `${name}: ${calls(counts.codeIntel)}, ${searches(counts.symbol, 'symbol-like')}`;
175 return [
176 { label: 'This session', counts: total(buckets), split: `${part('main', buckets.main)} · ${part('subagents', buckets.subagents)}` },
177 ...(stored === undefined
178 ? []
179 : [
180 { label: 'Today', counts: stored.today },
181 { label: 'Last 30 days', counts: stored.last30 },
182 ]),
183 ];
184}
185
186/**
187 * The same rows as bare figures, the words left to a table's head, for a
188 * surface without a monospace grid (the Desktop app). The session's main and
189 * subagent split are rows of their own under it.
190 */
191export function statsCells(buckets: Buckets, stored: Stats | undefined): { label: string; figures: Figures }[] {
192 const row = (label: string, c: Counts) => ({
193 label,
194 figures: { calls: `${grouped(c.codeIntel)} (${duration(c.codeIntelMs)})`, symbol: grouped(c.symbol), literal: grouped(c.literal), share: percent(c) },
195 });
196 return statRows(buckets, stored).flatMap((r) =>
197 r.split === undefined ? [row(r.label, r.counts)] : [row(r.label, r.counts), row('↳ main', buckets.main), row('↳ subagents', buckets.subagents)],
198 );
199}
200
201/** The same rows as plain lines, for the command's text reply. */
202export function statsLines(buckets: Buckets, stored: Stats | undefined): string[] {
203 const lines = statRows(buckets, stored).map((row) => {
204 const f = figures(row.counts);
205 const split = row.split === undefined ? '' : ` (${row.split})`;
206 return `${row.label}: ${f.calls} · ${f.symbol} · ${f.literal} · ${f.share} ${SHARE_LABEL}${split}`;
207 });
208 return stored === undefined ? [...lines, UNREAD_NOTE] : lines;
209}
210
211/** `YYYY-MM-DD` for a local year, month (from 0) and day; a day outside the month rolls over. */
212function localDate(year: number, month: number, day: number): string {
213 const date = new Date(year, month, day, 12);
214 const pad = (n: number) => String(n).padStart(2, '0');
215 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
216}
217
218/** The first and last local dates the stored counts are summed over at `nowMs`. */
219function keptDates(nowMs: number): { oldest: string; today: string } {
220 const now = new Date(nowMs);
221 const [year, month, day] = [now.getFullYear(), now.getMonth(), now.getDate()];
222 return { oldest: localDate(year, month, day - (KEPT_DAYS - 1)), today: localDate(year, month, day) };
223}
224
225/**
226 * The store key of one session's counts for the local day of `nowMs`. One key
227 * per session: the store has no atomic update, so two sessions writing one key
228 * would lose each other's counts.
229 */
230export function dayKey(nowMs: number, sessionId: string): string {
231 return `${PREFIX}${keptDates(nowMs).today}:${sessionId}`;
232}
233
234function countsOf(value: unknown): Counts | undefined {
235 if (!isRecord(value)) return undefined;
236 const counts = zero();
237 for (const field of FIELDS) {
238 const n = num(value[field]);
239 if (n === undefined) return undefined;
240 counts[field] = n;
241 }
242 return counts;
243}
244
245/** A stored entry, or undefined when the value is not one. */
246function bucketsOf(value: unknown): Buckets | undefined {
247 if (!isRecord(value)) return undefined;
248 const main = countsOf(value.main);
249 const subagents = countsOf(value.subagents);
250 return main === undefined || subagents === undefined ? undefined : { main, subagents };
251}
252
253/** The local date in a stored key, or undefined when the key is not this module's. */
254function dateOf(key: string): string | undefined {
255 return DATED_KEY.exec(key)?.[1];
256}
257
258/** Deletes the entries dated before `oldest`. A delete that fails is left for the next time. */
259async function prune(oldest: string, keys: readonly string[], del: (key: string) => Promise<void>): Promise<void> {
260 await Promise.allSettled(
261 keys
262 .filter((key) => {
263 const date = dateOf(key);
264 return date !== undefined && date < oldest;
265 })
266 .map(async (key) => del(key)),
267 );
268}
269
270/**
271 * Adds `counted` to this session's entry under `key` and saves it. The first
272 * call for a key reads what the store already holds (`(k) => $.store.get(k)`),
273 * so a reloaded module or a resumed session adds to its earlier counts; a read
274 * that fails is tried again by the next call. `save` is `(k, v) => $.store.set(k, v)`.
275 * Saves of one key run one after another, in the order the counts arrived.
276 */
277export async function persist(
278 counted: Note,
279 key: string,
280 load: (key: string) => Promise<unknown>,
281 save: (key: string, entry: Buckets) => Promise<void>,
282): Promise<void> {
283 let seeded = days.get(key);
284 if (seeded === undefined) {
285 seeded = load(key).then((stored) => bucketsOf(stored) ?? empty());
286 days.set(key, seeded);
287 seeded.catch(() => {
288 if (days.get(key) === seeded) days.delete(key);
289 });
290 }
291 const entry = await seeded;
292 add(counted.isMain ? entry.main : entry.subagents, counted.counts);
293 const write = (writes.get(key) ?? Promise.resolve()).catch(() => undefined).then(() => save(key, entry));
294 writes.set(key, write);
295 await write;
296}
297
298/** The store as the counting hooks hand it over: each member a closure that spells its own `$` call. */
299export type StorePort = {
300 /** `() => $.clock.now()` */
301 now: () => Promise<number>;
302 /** `() => $.session.id()` */
303 sessionId: () => Promise<string>;
304 /** `(k) => $.store.get(k)` */
305 get: (key: string) => Promise<unknown>;
306 /** `(k, entry) => $.store.set(k, entry)` */
307 set: (key: string, entry: Buckets) => Promise<void>;
308 /** `() => $.store.keys()` */
309 keys: () => Promise<readonly string[]>;
310 /** `(k) => $.store.delete(k)` */
311 del: (key: string) => Promise<void>;
312};
313
314/**
315 * Saves `counted` under this session's key for the local day and, on the first
316 * call of a load, deletes the entries past their kept days. Never rejects: a
317 * store that fails leaves the session totals as they were.
318 */
319export async function save(counted: Note, store: StorePort): Promise<void> {
320 const isFirst = !pruned;
321 pruned = true;
322 try {
323 const now = await store.now();
324 await Promise.allSettled([
325 (async () => persist(counted, dayKey(now, await store.sessionId()), store.get, store.set))(),
326 isFirst ? (async () => prune(keptDates(now).oldest, await store.keys(), store.del))() : undefined,
327 ]);
328 } catch {
329 // No clock: nothing is stored for this count.
330 }
331}
332
333/**
334 * Starts `work` and returns at once, so a tool's answer never waits on the
335 * store. A failure is dropped. `settled()` waits for it.
336 */
337export function background(work: () => Promise<void>): void {
338 const task = (async () => work())().catch(() => undefined);
339 running.add(task);
340 void task.finally(() => running.delete(task));
341}
342
343/** Resolves once no background work is running, including work started while it waited. */
344export async function settled(): Promise<void> {
345 while (running.size > 0) await Promise.all(running);
346}
347
348/**
349 * Sums every session's stored counts for today and for the last 30 days, today
350 * included, and deletes the entries older than that. `keys`, `get` and `del`
351 * are `await $.store.keys()`, `(k) => $.store.get(k)` and `(k) => $.store.delete(k)`.
352 * A key or value that is not this module's is left alone and not counted, and
353 * so is an entry whose read fails.
354 */
355export async function loadStats(
356 nowMs: number,
357 keys: readonly string[],
358 get: (key: string) => Promise<unknown>,
359 del: (key: string) => Promise<void>,
360): Promise<Stats> {
361 const { oldest, today } = keptDates(nowMs);
362 const stats: Stats = { today: zero(), last30: zero() };
363 const kept = keys.flatMap((key) => {
364 const date = dateOf(key);
365 return date === undefined || date < oldest ? [] : [{ key, date }];
366 });
367 const [entries] = await Promise.all([Promise.allSettled(kept.map(async ({ key }) => bucketsOf(await get(key)))), prune(oldest, keys, del)]);
368 entries.forEach((read, i) => {
369 if (read.status !== 'fulfilled' || read.value === undefined) return;
370 const counts = total(read.value);
371 add(stats.last30, counts);
372 if (kept[i]?.date === today) add(stats.today, counts);
373 });
374 return stats;
375}
376
377/** Starts the session totals over, for a new conversation (`/clear`, `/resume`, `/branch`). The stored day totals stay. */
378export function resetAdoption(): void {
379 session = empty();
380}
381
382/**
383 * Drops the day entries held in memory and the saves still queued, as a reload
384 * of the hooks module does; the next count reads the store again and prunes it.
385 */
386export function forgetDays(): void {
387 days.clear();
388 writes.clear();
389 running.clear();
390 pruned = false;
391}
392
393/**
394 * Starts the counts over for this load and, with `showAdoption` on, puts the
395 * session's counts on the spinner, before the engine's own suffix. Unset reads
396 * as off. Counting never depends on the option.
397 */
398export function registerAdoption(on: On, options: PluginOptions): void {
399 resetAdoption();
400 forgetDays();
401 shown = options.showAdoption === true;
402 if (!shown) return;
403
404 on('ui.render', { component: 'Spinner' }, async (_$, e, next) => {
405 const text = suffixText();
406 return text === '' ? next(e) : next({ ...e, props: { ...e.props, suffix: text + e.props.suffix } });
407 });
408}
409hooks/augment.ts 165 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code';
2import { agentKey, usedCodeIntelThisTurn } from './budget';
3import { searchTarget } from './classify';
4import { codeIntel, isConfigured, projectRoot, withinDeadline } from './lib';
5import { PROMPT } from './theme';
6
7/**
8 * How long a finished search waits for its code_intel lookup before it returns
9 * the search result alone. The lookup starts with the search, so it has the
10 * search's own time as well. Allows for a cold start of the MCP server.
11 */
12export const SOFT_DEADLINE_MS = 2500;
13
14/** How long a lookup that failed (server down, sign-in, timeout) is remembered before it is tried again. */
15export const FAILURE_BACKOFF_MS = 60_000;
16
17/** What the lookup reports for an exact-name symbol match. */
18export type Found = {
19 name: string;
20 kind: string;
21 filePath: string;
22 line: number;
23 /** The graph's usage count of the symbol. */
24 usages: number;
25 /** How many symbols carry the name; the one reported is the most used. */
26 definitions: number;
27};
28
29/**
30 * Lookups by project root and identifier: in flight, or settled to a match,
31 * null (no exact match) or undefined (failed). A failure is dropped after
32 * `FAILURE_BACKOFF_MS`, so a down server costs one lookup a minute, not one per search.
33 */
34const lookups = new Map<string, Promise<Found | null | undefined>>();
35
36/**
37 * Lines already shown, by agent, project root and identifier. Claimed before the
38 * lookup is awaited, so parallel searches show one line, and released when no
39 * line is shown.
40 */
41const shown = new Set<string>();
42
43/** The part of code_intel's `api` the lookup uses. */
44export type LookupApi = {
45 searchSymbols: (params: {
46 query: string;
47 limit: number;
48 includeUsageCount: boolean;
49 isExported?: boolean;
50 }) => Promise<{ symbols: ReadonlyArray<{ name: string; kind: string; filePath: string; line: number; usageCount?: number }> }>;
51};
52
53/**
54 * The most used exact-name match for `name`, searched among exported symbols
55 * first, then among used symbols that are not exported (an unused one there is
56 * usually a fixture, not what the search means), or null when nothing matches.
57 * `searchSymbols` matches substrings, so the exact name can sit far down a
58 * page; a full page is read.
59 *
60 * Runs inside code_intel: `lookupCode` sends its source, so it may use only
61 * its arguments.
62 */
63export async function lookupSymbol(api: LookupApi, name: string): Promise<Found | null> {
64 const exact = async (isExported?: boolean) =>
65 (await api.searchSymbols({ query: name, limit: 100, includeUsageCount: true, ...(isExported ? { isExported } : {}) })).symbols.filter(
66 (s) => s.name === name,
67 );
68 let hits = await exact(true);
69 if (hits.length === 0) hits = (await exact()).filter((s) => (s.usageCount ?? 0) > 0);
70 const hit = hits.reduce<(typeof hits)[number] | null>((a, b) => (a === null || (b.usageCount ?? 0) > (a.usageCount ?? 0) ? b : a), null);
71 if (hit === null) return null;
72 return { name: hit.name, kind: hit.kind, filePath: hit.filePath, line: hit.line, usages: hit.usageCount ?? 0, definitions: hits.length };
73}
74
75/** The code_intel program that runs `lookupSymbol` for `name`. */
76export function lookupCode(name: string): string {
77 return `return await (${lookupSymbol.toString()})(api, ${JSON.stringify(name)});`;
78}
79
80/** The lookup's result read into `Found`, or null when it is not one. */
81function foundOf(value: unknown): Found | null {
82 if (typeof value !== 'object' || value === null) return null;
83 const name: unknown = Reflect.get(value, 'name');
84 const kind: unknown = Reflect.get(value, 'kind');
85 const filePath: unknown = Reflect.get(value, 'filePath');
86 const line: unknown = Reflect.get(value, 'line');
87 const usages: unknown = Reflect.get(value, 'usages');
88 const definitions: unknown = Reflect.get(value, 'definitions');
89 if (typeof name !== 'string' || typeof kind !== 'string' || typeof filePath !== 'string') return null;
90 if (typeof line !== 'number' || typeof usages !== 'number' || typeof definitions !== 'number') return null;
91 return { name, kind, filePath, line, usages, definitions };
92}
93
94/** The one line a search result gains. */
95export function augmentLine({ name, kind, filePath, line, usages, definitions }: Found): string {
96 const others = definitions > 1 ? `, the most used of ${definitions} symbols with that name` : '';
97 const count = `${usages} ${usages === 1 ? 'usage' : 'usages'}`;
98 return `${PROMPT} ${name} (${kind}) is defined at ${filePath}:${line}${others}, with ${count}. Use code_intel for references, callers, and impact.`;
99}
100
101/** The lookup for `name` in the project at `root`, shared with any already in flight or settled. */
102function lookup($: EngineInterface, root: string, name: string): Promise<Found | null | undefined> {
103 const key = `${root}\0${name}`;
104 let pending = lookups.get(key);
105 if (pending === undefined) {
106 const started: Promise<Found | null | undefined> = codeIntel(
107 { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
108 lookupCode(name),
109 { cwd: root },
110 ).then((envelope) => {
111 if (envelope.success) return foundOf(envelope.result);
112 // Only this failure is dropped: after a reset a newer lookup may hold the key.
113 $.clock.after(FAILURE_BACKOFF_MS, () => {
114 if (lookups.get(key) === started) lookups.delete(key);
115 });
116 return undefined;
117 });
118 lookups.set(key, started);
119 pending = started;
120 }
121 return pending;
122}
123
124export function registerAugment(on: On, options: PluginOptions): void {
125 lookups.clear();
126 shown.clear();
127 if (options.augmentGrep === false) return;
128
129 on('tool.call', { tool: /^(Grep|Bash)$/ }, async ($, e, next) => {
130 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return next(e);
131 const { symbol, path } = searchTarget(e);
132 if (symbol === null || usedCodeIntelThisTurn(e)) return next(e);
133 const root = await projectRoot(await $.session.cwd(), (p) => $.fs.exists(p), path);
134 if (root === null) return next(e);
135 const seen = `${agentKey(e)}\0${root}\0${symbol}`;
136 if (shown.has(seen)) return next(e);
137
138 shown.add(seen);
139 const pending = lookup($, root, symbol);
140 const r = await next(e);
141 if (r.deny !== undefined || r.isError) {
142 shown.delete(seen);
143 return r;
144 }
145 const found = await withinDeadline((ms, o) => $.clock.sleep(ms, o), pending, SOFT_DEADLINE_MS, next.signal);
146 if (found === null || found === undefined) {
147 shown.delete(seen);
148 return r;
149 }
150 return { ...r, context: [...(r.context ?? []), augmentLine(found)] };
151 });
152}
153
154/** Forgets every lookup and shown line, for a new conversation (`/clear`, `/resume`, `/branch`). */
155export function resetAugment(): void {
156 lookups.clear();
157 shown.clear();
158}
159
160/** Forgets the lines shown to the subagent `agentId` when its run ends; lookups stay shared. */
161export function forgetAgentLines(agentId: string): void {
162 const prefix = `${agentId}\0`;
163 for (const key of shown) if (key.startsWith(prefix)) shown.delete(key);
164}
165hooks/budget.ts 160 lines1import type { On, PluginOptions } from 'claude-code';
2import { background, noteCodeIntelCall, save, showsAdoption } from './adoption';
3import { observeEnvelope } from './freshness';
4import { absolute, canDraw, isConfigured, parseToolText, projectRoot, stringArg } from './lib';
5import { observeCodeIntel } from './onboarding';
6import { noteCodeIntel } from './risk';
7
8/** How many nudges one agent gets when the option is unset. */
9const DEFAULT_NUDGE_LIMIT = 3;
10
11/** The agent key of the main conversation. */
12export const MAIN = 'main';
13
14type AgentBudget = {
15 /** Nudges spent so far. */
16 nudges: number;
17 /** The turn in which the agent last called code_intel. */
18 codeIntelTurn?: string;
19};
20
21/**
22 * Per agent, what its budget has spent. Module state only: a reload of the
23 * hooks module starts every budget over, which is acceptable.
24 */
25const budgets = new Map<string, AgentBudget>();
26
27/**
28 * The main conversation's current turn, from `turn.start`. A subagent's run is
29 * one turn (its loop's `turn.complete` closes the run, and `forgetAgentBudget`
30 * then drops its state), so its turn is its `agentId`.
31 */
32let mainTurn: string | undefined;
33
34let nudgeLimit = DEFAULT_NUDGE_LIMIT;
35
36/** The agent a tool call belongs to: `agentId` in a subagent, else the main conversation. */
37export function agentKey(e: object): string {
38 return stringArg(e, 'agentId') ?? MAIN;
39}
40
41function budgetOf(key: string): AgentBudget {
42 let budget = budgets.get(key);
43 if (budget === undefined) {
44 budget = { nudges: 0 };
45 budgets.set(key, budget);
46 }
47 return budget;
48}
49
50/** The turn the agent `key` is in, or undefined before the main conversation's first turn. */
51function turnOf(key: string): string | undefined {
52 return key === MAIN ? mainTurn : key;
53}
54
55/** True when the agent that made the tool call `e` already called code_intel in its current turn. */
56export function usedCodeIntelThisTurn(e: object): boolean {
57 const key = agentKey(e);
58 const turn = turnOf(key);
59 return turn !== undefined && budgets.get(key)?.codeIntelTurn === turn;
60}
61
62/** True when the agent that made `e` has a nudge left. Spends nothing. */
63export function hasNudgeLeft(e: object): boolean {
64 return (budgets.get(agentKey(e))?.nudges ?? 0) < nudgeLimit;
65}
66
67/** Spends one nudge from the budget of the agent that made `e`; false, spending nothing, once it is used up. */
68export function spendNudge(e: object): boolean {
69 const budget = budgetOf(agentKey(e));
70 if (budget.nudges >= nudgeLimit) return false;
71 budget.nudges += 1;
72 return true;
73}
74
75export function registerBudget(on: On, options: PluginOptions): void {
76 const limit = options.nudgeLimit;
77 nudgeLimit = typeof limit === 'number' ? limit : DEFAULT_NUDGE_LIMIT;
78 budgets.clear();
79 mainTurn = undefined;
80
81 on('turn.start', async (_$, e, next) => {
82 mainTurn = e.turnId;
83 return next(e);
84 });
85
86 on('tool.call', { tool: /code_intel$/ }, async ($, e, next) => {
87 const key = agentKey(e);
88 const turn = turnOf(key);
89 if (turn !== undefined) budgetOf(key).codeIntelTurn = turn;
90 const r = await next(e);
91 // A plugin's own `$.mcp.call` of code_intel (this one's risk lookups among them) is not the agent's analysis,
92 // and the onboarding and the freshness indicator act on its own pings themselves.
93 if (next.origin.plugin === 'engine') {
94 noteCodeIntel(key, e, r);
95 try {
96 observeCodeIntel(
97 r,
98 isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY')),
99 () => $.ui.invalidate('ui.render'),
100 async (text) => {
101 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
102 },
103 );
104 } catch {
105 // The band stays as it was; the agent's answer goes back untouched.
106 }
107 // A refused call never ran; an errored one is still the agent reaching for code_intel.
108 if (r.deny === undefined) {
109 const counted = noteCodeIntelCall(key === MAIN, parseToolText(r.text, r.isError === true).time);
110 if (showsAdoption()) $.ui.invalidate('ui.render');
111 // Saved in the background: the agent's answer never waits on the store.
112 background(() =>
113 save(counted, {
114 now: () => $.clock.now(),
115 sessionId: () => $.session.id(),
116 get: (k) => $.store.get(k),
117 set: (k, entry) => $.store.set(k, entry),
118 keys: () => $.store.keys(),
119 del: (k) => $.store.delete(k),
120 }),
121 );
122 try {
123 // The index the answer came from, for the project the call ran in. The answer does not wait for git.
124 const session = await $.session.cwd();
125 const cwd = stringArg(e, 'cwd');
126 const root = await projectRoot(cwd === undefined ? session : absolute(cwd, session), (p) => $.fs.exists(p));
127 if (root !== null) {
128 void observeEnvelope(
129 parseToolText(r.text, r.isError === true),
130 root,
131 (argv, init) => $.process.run(argv, init),
132 () => $.ui.invalidate('ui.render'),
133 async (text) => {
134 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
135 },
136 ).catch(() => undefined);
137 }
138 } catch {
139 // The indicator stays as it was.
140 }
141 }
142 }
143 return r;
144 });
145}
146
147/** Starts every budget over, for a new conversation (`/clear`, `/resume`, `/branch`). */
148export function resetBudgets(): void {
149 budgets.clear();
150}
151
152/**
153 * Drops the budget of the subagent `agentId` when its run ends. A subagent
154 * continued later (SendMessage) keeps its id but starts a new run, so it gets a
155 * fresh budget and its earlier code_intel call no longer counts as this turn's.
156 */
157export function forgetAgentBudget(agentId: string): void {
158 budgets.delete(agentId);
159}
160hooks/command.ts 1409 lines1import type { ButtonProps, ElementTable, EngineInterface, On, PluginOptions, RenderElement } from 'claude-code';
2import { SHARE_LABEL, SHARE_NOTE, UNREAD_NOTE, figures, loadStats, sessionCounts, settled, statRows, statsCells, statsLines } from './adoption';
3import type { Buckets, Stats } from './adoption';
4import { canDraw, codeIntel, isConfigured, isRecord, projectName, projectRoot, withinDeadline } from './lib';
5import type { CodeIntelEnvelope, CodeIntelError } from './lib';
6import { explain, explainLines } from './explain';
7import { askText, callTree, detailLines, detailRows, drillCode, hasCallGraph, hits, impactView, rankExact, searchCode, usageLines, usageRows, usageTotal, where } from './explore';
8import type { Hit } from './explore';
9import { byFile, orphanCode, orphanPage, removalPrompt } from './unused';
10import type { OrphanRow } from './unused';
11import type { Explanation } from './explain';
12import { observeEnvelope, recheck, startTicks, track } from './freshness';
13import { checkConnection, pingProject, readStoredKeyAtStart, rememberRepo } from './onboarding';
14import type { FoundKey, OnboardingPorts } from './onboarding';
15import { BANNER_WIDTH, PROMPT, badge, buttonRow, forTheme, header, kind, paint, palette, risk, scheme, status } from './theme';
16import type { Scheme } from './theme';
17
18/**
19 * The command's name. Mod commands allow letters, digits, `_` and `-`, so the
20 * bare name sits beside the Markdown `/constellation:*` commands, and a name
21 * a built-in owns is refused.
22 */
23export const COMMAND = 'constellation';
24
25/** The id of the pane the command opens; the `requestId` its tree is drawn under. */
26const PANE = 'constellation';
27
28const TABS = ['status', 'diagnose', 'deps', 'unused', 'explore', 'stats'] as const;
29export type Tab = (typeof TABS)[number];
30/** A tab that shows a code_intel query's answer. Stats shows counts kept on this machine and never queries. */
31type QueryTab = Exclude<Tab, 'stats'>;
32type Direction = 'dependencies' | 'dependents';
33
34const TAB_LABEL: Readonly<Record<Tab, string>> = {
35 status: 'Status',
36 diagnose: 'Diagnose',
37 deps: 'Deps',
38 unused: 'Unused',
39 explore: 'Explore',
40 stats: 'Stats',
41};
42
43/** One line under the tab row saying what the tab shows. */
44const TAB_HINT: Readonly<Record<Tab, string>> = {
45 status: 'Whether Constellation is reachable and your access key is accepted.',
46 diagnose: 'What the Constellation index holds for this project.',
47 deps: 'What a file imports, or what imports it.',
48 unused: 'Exports nothing imports. Verify each one before deleting it.',
49 explore: 'Search the graph and drill into a symbol without a Claude turn.',
50 stats: 'How often Claude called code_intel, beside its text searches. Counted on this machine only.',
51};
52
53/** The column a labeled row's value starts in. */
54const LABEL_WIDTH = 13;
55
56/** Rows a tab draws before it ends with a "+N more" line, and lines the text fallback keeps. */
57const MAX_ROWS = 15;
58const MAX_TEXT_LINES = 6;
59/** Hits the explorer lists in the pane, and the fewer the text fallback keeps. */
60const EXPLORE_ROWS = 20;
61const EXPLORE_TEXT_HITS = 5;
62
63/** One thing a tab shows: a line of text, optionally led by a status or kind badge, or a labeled value. */
64export type Item = {
65 /** The name of a fact (`Connection`, `Symbols`): drawn dim in a fixed column, with the value after it. */
66 label?: string;
67 badge?: { kind: 'status' | 'kind'; value: string };
68 text: string;
69 /** A file the person can pick: drawn as a pressable row. */
70 path?: string;
71 /** A project root the person can pick when the working directory is not one: a pressable row. */
72 project?: string;
73 /** Secondary text: drawn with `dimColor`. */
74 dim?: boolean;
75 /** A group title, such as a file in the unused list. */
76 heading?: boolean;
77};
78
79export type Summary = {
80 /** The same facts as plain lines, for the text fallback. */
81 lines: string[];
82 items: Item[];
83 error?: CodeIntelError;
84 /** The error laid out for a person, which the pane and the text reply draw in place of `items`. */
85 explanation?: Explanation;
86};
87
88export type SummaryOptions = {
89 direction?: Direction;
90 path?: string;
91 project?: string;
92 /** The symbol the explorer searched for, which ranks its exact matches first. */
93 query?: string;
94};
95
96/**
97 * The tab, file path and kind a command's arguments select: the first word
98 * names the tab (default status), for deps the rest is the file path, and for
99 * unused the next word (or `--kind <k>`) is the kind to list, and for explore the
100 * rest is the symbol to search for.
101 */
102export function parseArgs(args: string): { tab: Tab; path?: string; kind?: string; query?: string } {
103 const trimmed = args.trim();
104 const split = trimmed.search(/\s/);
105 const word = (split === -1 ? trimmed : trimmed.slice(0, split)).toLowerCase();
106 const tab = TABS.find((t) => t === word);
107 if (tab === undefined) return { tab: 'status' };
108 const rest = split === -1 ? '' : trimmed.slice(split).trim();
109 if (tab === 'unused') {
110 // `unused function` and `unused --kind function` both name a kind.
111 const kind = rest.replace(/^--kind(?:\s+|=)/, '').split(/\s+/)[0]?.toLowerCase();
112 return kind === undefined || kind === '' ? { tab } : { tab, kind };
113 }
114 if (tab === 'explore') return rest === '' ? { tab } : { tab, query: rest };
115 return tab === 'deps' && rest !== '' ? { tab, path: rest } : { tab };
116}
117
118function records(value: unknown): Record<string, unknown>[] {
119 return Array.isArray(value) ? value.filter(isRecord) : [];
120}
121
122function text(value: unknown): string | undefined {
123 return typeof value === 'string' && value !== '' ? value : undefined;
124}
125
126function count(value: unknown): number | undefined {
127 return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
128}
129
130/** Thousands separators for a count, as `3,079`. */
131function grouped(n: number): string {
132 return n.toLocaleString('en-US');
133}
134
135function lineOf(item: Item): string {
136 if (item.label !== undefined) {
137 const word = item.badge === undefined ? undefined : (item.badge.kind === 'status' ? status(item.badge.value) : kind(item.badge.value)).word;
138 const value = word !== undefined && item.text !== '' ? `${word} (${item.text})` : (word ?? item.text);
139 return `${item.label}: ${value}`;
140 }
141 if (item.badge?.kind === 'status') return `${item.text}: ${status(item.badge.value).word}`;
142 if (item.badge?.kind === 'kind') return ` ${item.text} (${kind(item.badge.value).word})`;
143 return item.text;
144}
145
146function fromItems(items: Item[]): Summary {
147 return { lines: items.map(lineOf), items };
148}
149
150function failure(error: CodeIntelError): Summary {
151 const explanation = explain(error);
152 const candidates = error.candidates ?? [];
153 // With project roots to offer (the working directory sits above several
154 // projects), the pane asks for one instead of drawing the error.
155 const items: Item[] =
156 candidates.length > 0
157 ? [
158 { text: 'Choose a project', heading: true },
159 { text: 'This folder holds several Constellation projects.', dim: true },
160 ...candidates.map((c): Item => ({ text: projectName(c) ?? c, project: c })),
161 ]
162 : [];
163 return { lines: explainLines(explanation), items, error, explanation };
164}
165
166function capped(items: Item[]): Item[] {
167 if (items.length <= MAX_ROWS) return items;
168 return [...items.slice(0, MAX_ROWS), { text: `+${items.length - MAX_ROWS} more`, dim: true }];
169}
170
171function connection(result: unknown): Item[] {
172 const pong = isRecord(result) && result['pong'] === true;
173 return [
174 { label: 'Connection', badge: { kind: 'status', value: pong ? 'healthy' : 'unknown' }, text: '' },
175 { label: 'Auth', badge: { kind: 'status', value: pong ? 'healthy' : 'unknown' }, text: '' },
176 ];
177}
178
179function statusItems(result: unknown, project: string | undefined): Item[] {
180 return [...connection(result), ...(project === undefined ? [] : [{ label: 'Project', text: project }])];
181}
182
183function diagnoseItems(result: unknown): Item[] {
184 const ping = isRecord(result) ? result['ping'] : undefined;
185 const caps = isRecord(result) && isRecord(result['caps']) ? result['caps'] : {};
186 const indexed = caps['isIndexed'];
187 const items = connection(ping);
188 items.push({
189 label: 'Index',
190 badge: { kind: 'status', value: indexed === true ? 'indexed' : indexed === false ? 'stale' : 'unknown' },
191 text: indexed === false ? 'not indexed' : '',
192 });
193 const languages = caps['languages'] ?? caps['supportedLanguages'];
194 if (Array.isArray(languages) && languages.length > 0) {
195 items.push({ label: 'Languages', text: languages.filter((l) => typeof l === 'string').join(', ') });
196 }
197 const symbols = count(caps['symbolCount']);
198 if (symbols !== undefined) items.push({ label: 'Symbols', text: grouped(symbols) });
199 const files = count(caps['fileCount']);
200 if (files !== undefined) items.push({ label: 'Files', text: grouped(files) });
201 const branch = text(caps['indexedBranch']);
202 if (branch !== undefined) items.push({ label: 'Branch', text: branch });
203 return items;
204}
205
206function depsItems(result: unknown, direction: Direction, path: string | undefined): Item[] {
207 if (!isRecord(result)) return [{ text: 'No data returned', dim: true }];
208 const entries = records(direction === 'dependencies' ? result['directDependencies'] : result['directDependents']);
209 const rows: Item[] = [];
210 for (const entry of entries) {
211 const file = text(entry['filePath']);
212 if (file !== undefined) rows.push({ text: file, path: file });
213 else {
214 const module = text(entry['moduleName']);
215 if (module !== undefined) rows.push({ text: module, dim: true });
216 }
217 }
218 const label = direction === 'dependencies' ? 'depends on' : 'is used by';
219 const header: Item = { text: `${path ?? text(result['file']) ?? 'file'} ${label} ${rows.length}`, heading: true };
220 return rows.length === 0
221 ? [header, { text: direction === 'dependencies' ? 'No dependencies found' : 'No dependents found', dim: true }]
222 : [header, ...capped(rows)];
223}
224
225function unusedItems(result: unknown): Item[] {
226 if (!isRecord(result)) return [{ text: 'No data returned', dim: true }];
227 const symbols = records(result['orphanedSymbols']);
228 if (symbols.length === 0) return [{ text: 'No unused symbols found', dim: true }];
229 const byFile = new Map<string, Record<string, unknown>[]>();
230 for (const symbol of symbols) {
231 const file = text(symbol['filePath']) ?? 'unknown file';
232 byFile.set(file, [...(byFile.get(file) ?? []), symbol]);
233 }
234 const summary = isRecord(result['summary']) ? result['summary'] : {};
235 const total = count(summary['totalOrphanedSymbols']) ?? symbols.length;
236 const items: Item[] = [{ text: `${grouped(total)} unused symbols`, heading: true }];
237 for (const [file, group] of byFile) {
238 items.push({ text: file, dim: true });
239 for (const symbol of group) {
240 items.push({ badge: { kind: 'kind', value: text(symbol['kind']) ?? 'unknown' }, text: text(symbol['name']) ?? 'unnamed' });
241 }
242 }
243 return capped(items);
244}
245
246/** The top matches of a symbol search as plain lines, exact name first. */
247function exploreItems(result: unknown, query: string | undefined): Item[] {
248 const found = rankExact(query ?? '', hits(result)).slice(0, EXPLORE_TEXT_HITS);
249 if (found.length === 0) return [{ text: 'No symbols match', dim: true }];
250 return found.map((h): Item => ({ text: `${kind(h.kind).word} ${h.name} ${where(h)}` }));
251}
252
253/**
254 * The facts of one tab from its code_intel envelope, shared by the pane and
255 * the text fallback: `items` carry the badges and rows the pane draws and
256 * `lines` the same facts as plain text. The envelope's `result` is untyped, so
257 * each field is narrowed against the executor schemas before it is read; on
258 * error the code, message and guidance are returned.
259 */
260export function summarize(tab: QueryTab, envelope: CodeIntelEnvelope, options: SummaryOptions = {}): Summary {
261 if (!envelope.success) return failure(envelope.error ?? { code: 'UNKNOWN', message: 'The request failed' });
262 const { result } = envelope;
263 switch (tab) {
264 case 'status':
265 return fromItems(statusItems(result, options.project));
266 case 'diagnose':
267 return fromItems(diagnoseItems(result));
268 case 'deps':
269 return fromItems(depsItems(result, options.direction ?? 'dependencies', options.path));
270 case 'unused':
271 return fromItems(unusedItems(result));
272 case 'explore':
273 return fromItems(exploreItems(result, options.query));
274 }
275}
276
277/** How long ago `iso` was, in the largest whole unit, from plain Date math. */
278function relative(iso: string): string {
279 const then = Date.parse(iso);
280 if (Number.isNaN(then)) return iso;
281 const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
282 if (seconds < 60) return 'just now';
283 const minutes = Math.floor(seconds / 60);
284 if (minutes < 60) return `${minutes}m ago`;
285 const hours = Math.floor(minutes / 60);
286 if (hours < 24) return `${hours}h ago`;
287 return `${Math.floor(hours / 24)}d ago`;
288}
289
290function metadata(envelope: CodeIntelEnvelope | undefined): string | undefined {
291 const parts: string[] = [];
292 if (envelope?.asOfCommit) parts.push(`as of ${envelope.asOfCommit.slice(0, 7)}`);
293 if (envelope?.lastIndexedAt) parts.push(`indexed ${relative(envelope.lastIndexedAt)}`);
294 return parts.length === 0 ? undefined : parts.join(' · ');
295}
296
297/**
298 * One line about a project for the picker, from its `getCapabilities` envelope:
299 * when it was indexed, its languages and its file count.
300 */
301export function projectDetail(envelope: CodeIntelEnvelope | undefined): string {
302 if (envelope === undefined) return '…';
303 if (!envelope.success) return envelope.error?.code === 'PROJECT_NOT_INDEXED' ? 'not indexed' : (envelope.error?.code ?? 'unavailable');
304 const caps = isRecord(envelope.result) ? envelope.result : {};
305 if (caps['isIndexed'] === false) return 'not indexed';
306 const parts: string[] = [];
307 const indexedAt = text(caps['lastIndexedAt']) ?? envelope.lastIndexedAt;
308 if (indexedAt !== undefined) parts.push(`indexed ${relative(indexedAt)}`);
309 const languages = caps['supportedLanguages'];
310 if (Array.isArray(languages)) {
311 const names = languages.filter((l): l is string => typeof l === 'string');
312 if (names.length > 0) parts.push(names.join(', '));
313 }
314 const files = count(caps['fileCount']);
315 if (files !== undefined) parts.push(`${files} files`);
316 return parts.length === 0 ? 'indexed' : parts.join(' · ');
317}
318
319/** The Unused tab's kind choices: the kinds an export usually has, of the categories `findOrphanedCode` filters by. */
320const UNUSED_KINDS: readonly string[] = ['function', 'class', 'interface', 'type', 'type_alias', 'variable', 'constant', 'enum', 'struct', 'trait', 'module', 'namespace'];
321/** The Unused kind choice that sends no filter. */
322const ALL_KINDS = 'all';
323
324/** The `findOrphanedCode` filter for the kind the command named, if any. */
325function unusedFilter(): { filterByKind?: string[] } {
326 return unusedKind === undefined ? {} : { filterByKind: [unusedKind] };
327}
328
329function codeFor(tab: QueryTab, direction: Direction, path: string): string {
330 switch (tab) {
331 case 'status':
332 return 'return await api.ping()';
333 case 'diagnose':
334 return 'const [ping, caps] = await Promise.all([api.ping(), api.getCapabilities()]); return { ping, caps }';
335 case 'deps':
336 return `return await api.${direction === 'dependencies' ? 'getDependencies' : 'getDependents'}({ filePath: ${JSON.stringify(path)} })`;
337 case 'unused':
338 return orphanCode(unusedFilter());
339 case 'explore':
340 return searchCode(exploreQuery);
341 }
342}
343
344// The pane's state: module variables, lost on a hot reload, so every read has a default.
345let selected: Tab = 'status';
346let tint: Scheme = 'brand';
347let depsPath = '';
348let depsDirection: Direction = 'dependencies';
349let sessionCwd: string | undefined;
350let launchCwd: string | undefined;
351/**
352 * The project picked when the session's working directory is not a project
353 * (a workspace root above several). Kept across pane opens, unlike the rest,
354 * and used only while the session is still in `from`.
355 */
356let chosen: { from: string; root: string } | undefined;
357const cache = new Map<QueryTab, CodeIntelEnvelope>();
358const pending = new Set<QueryTab>();
359const generations = new Map<QueryTab, number>();
360/** The picker's per-project `getCapabilities` envelopes, by project root. */
361const details = new Map<string, CodeIntelEnvelope>();
362const detailsPending = new Set<string>();
363/** The kind `/constellation unused <kind>` asked for. Cleared by `reset()` only. */
364let unusedKind: string | undefined;
365// The unused picker's state, cleared with the tab by `drop('unused')`.
366const picked = new Set<string>();
367let morePages: OrphanRow[] = [];
368let nextOffset: number | undefined;
369let loadingMore = false;
370let handoffNote: string | undefined;
371/** The symbol the explorer searches for. Cleared by `reset()` only, since the search field sets it and then drops the tab. */
372let exploreQuery = '';
373// The explorer's state, cleared with the tab by `drop('explore')`.
374let focusHit: Hit | undefined;
375type Section = 'details' | 'usages' | 'impact' | 'calls';
376let section: Section = 'details';
377const drill = new Map<string, CodeIntelEnvelope>();
378const drillPending = new Set<string>();
379let exploreNote: string | undefined;
380/** How long a read of the stored counts waits for counts still being saved. */
381const SETTLE_MS = 1000;
382/** The session's counts and the stored ones, read together so the rows agree; `stored` is unset when the store cannot be read. */
383type Snapshot = { session: Buckets; stored: Stats | undefined };
384/** The Stats tab's counts: unset until the tab shows, then being read, then read. Cleared by `drop('stats')`. */
385let history: Snapshot | 'reading' | undefined;
386let historyRead = 0;
387
388/** The `$.store` key that remembers the project picked in `from`, across sessions. */
389function pickKey(from: string): string {
390 return `project:${from}`;
391}
392
393function drop(tab: Tab): void {
394 if (tab === 'stats') {
395 history = undefined;
396 historyRead += 1;
397 return;
398 }
399 cache.delete(tab);
400 pending.delete(tab);
401 generations.set(tab, (generations.get(tab) ?? 0) + 1);
402 if (tab === 'unused') {
403 picked.clear();
404 morePages = [];
405 nextOffset = undefined;
406 loadingMore = false;
407 handoffNote = undefined;
408 }
409 if (tab === 'explore') {
410 focusHit = undefined;
411 section = 'details';
412 drill.clear();
413 drillPending.clear();
414 exploreNote = undefined;
415 }
416}
417
418function reset(): void {
419 for (const tab of TABS) drop(tab);
420 unusedKind = undefined;
421 exploreQuery = '';
422 selected = 'status';
423 depsPath = '';
424 depsDirection = 'dependencies';
425 sessionCwd = undefined;
426 launchCwd = undefined;
427 details.clear();
428 detailsPending.clear();
429}
430
431/** The directory queries run in: the picked project while the session stays where it was picked. */
432function target(cwd: string): string {
433 return chosen?.from === cwd ? chosen.root : cwd;
434}
435
436/**
437 * Runs the tab's query and caches the envelope. A result that lands after its
438 * tab was dropped (refresh, a new path, the pane closing) is discarded. The
439 * redraw is asked for once the envelope is cached.
440 */
441async function runQuery($: EngineInterface, tab: QueryTab): Promise<void> {
442 const generation = generations.get(tab) ?? 0;
443 pending.add(tab);
444 let envelope: CodeIntelEnvelope;
445 try {
446 const cwd = sessionCwd ?? target(await $.session.cwd());
447 envelope = await codeIntel(
448 { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
449 codeFor(tab, depsDirection, depsPath),
450 { cwd },
451 );
452 } catch (error) {
453 envelope = { success: false, error: { code: 'MCP_CALL_FAILED', message: error instanceof Error ? error.message : String(error) } };
454 }
455 if ((generations.get(tab) ?? 0) !== generation) return;
456 pending.delete(tab);
457 cache.set(tab, envelope);
458 if (tab === 'unused' && envelope.success) nextOffset = orphanPage(envelope.result).nextOffset;
459 $.ui.invalidate('ui.render');
460}
461
462/**
463 * The session's counts with the stored ones, deleting the entries past their 30
464 * days. Counts are saved in the background, so it first waits, for at most
465 * `SETTLE_MS`, for the saves still running: today's row is then never behind
466 * the session's. With no store only the session's counts come back.
467 */
468async function readStats($: EngineInterface): Promise<Snapshot> {
469 try {
470 await withinDeadline((ms, o) => $.clock.sleep(ms, o), settled(), SETTLE_MS, new AbortController().signal);
471 } catch {
472 // No timer: the stored counts are read as they are.
473 }
474 const session = sessionCounts();
475 try {
476 return { session, stored: await loadStats(await $.clock.now(), await $.store.keys(), (k) => $.store.get(k), (k) => $.store.delete(k)) };
477 } catch {
478 return { session, stored: undefined };
479 }
480}
481
482/** Reads the Stats tab's counts; a read that lands after the tab was dropped is discarded. */
483async function readHistory($: EngineInterface): Promise<void> {
484 const read = (historyRead += 1);
485 history = 'reading';
486 const snapshot = await readStats($);
487 if (historyRead !== read) return;
488 history = snapshot;
489 $.ui.invalidate('ui.render');
490}
491
492/** Reads one symbol's details, usages, impact and call graph; a result that lands after the explorer was dropped is discarded. */
493async function runDrill($: EngineInterface, id: string, symbolKind: string): Promise<void> {
494 drillPending.add(id);
495 let envelope: CodeIntelEnvelope;
496 try {
497 const cwd = sessionCwd ?? target(await $.session.cwd());
498 envelope = await codeIntel({ connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) }, drillCode(id, symbolKind), { cwd });
499 } catch (error) {
500 envelope = { success: false, error: { code: 'MCP_CALL_FAILED', message: error instanceof Error ? error.message : String(error) } };
501 }
502 if (!drillPending.delete(id)) return;
503 drill.set(id, envelope);
504 $.ui.invalidate('ui.render');
505}
506
507/** Reads the next page of unused exports; a page that lands after the tab was dropped is discarded. */
508async function loadMore($: EngineInterface): Promise<void> {
509 if (loadingMore || nextOffset === undefined) return;
510 const generation = generations.get('unused') ?? 0;
511 const code = orphanCode({ ...unusedFilter(), limit: 50, offset: nextOffset });
512 loadingMore = true;
513 $.ui.invalidate('ui.render');
514 let envelope: CodeIntelEnvelope;
515 try {
516 const cwd = sessionCwd ?? target(await $.session.cwd());
517 envelope = await codeIntel({ connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) }, code, { cwd });
518 } catch (error) {
519 envelope = { success: false, error: { code: 'MCP_CALL_FAILED', message: error instanceof Error ? error.message : String(error) } };
520 }
521 if ((generations.get('unused') ?? 0) !== generation) return;
522 loadingMore = false;
523 if (envelope.success) {
524 const page = orphanPage(envelope.result);
525 morePages = [...morePages, ...page.rows];
526 nextOffset = page.nextOffset;
527 } else {
528 handoffNote = 'Could not load more. Try again.';
529 }
530 $.ui.invalidate('ui.render');
531}
532
533/**
534 * An error in the pane: the error badge and the headline in bold, then,
535 * indented under it, the detail and notes dim, the numbered steps with each
536 * command bold, and the docs link and the code last.
537 */
538function errorView(el: ElementTable, ex: Explanation, tint: Scheme): RenderElement {
539 const row = (label: string, value: RenderElement) =>
540 el.Box({ flexDirection: 'row', children: [el.Text({ dimColor: true, children: label.padEnd(7) }), value] });
541 const under: RenderElement[] = [];
542 if (ex.detail !== undefined || ex.notes.length > 0) {
543 under.push(
544 el.Box({
545 flexDirection: 'column',
546 children: [ex.detail, ...ex.notes].filter((t): t is string => t !== undefined).map((t) => el.Text({ dimColor: true, children: t })),
547 }),
548 );
549 }
550 if (ex.steps.length > 0) {
551 under.push(
552 el.Box({
553 flexDirection: 'column',
554 children: [
555 el.Text({ bold: true, children: 'Next steps' }),
556 ...ex.steps.map((step, i) =>
557 el.Box({ flexDirection: 'row', columnGap: 1, children: [el.Text({ dimColor: true, children: `${i + 1}.` }), el.Text({ bold: true, children: step })] }),
558 ),
559 ],
560 }),
561 );
562 }
563 under.push(
564 el.Box({
565 flexDirection: 'column',
566 children: [
567 ...(ex.docs === undefined ? [] : [row('Docs', el.Link({ href: ex.docs, label: ex.docs }))]),
568 row('Code', el.Text({ dimColor: true, children: ex.code })),
569 ],
570 }),
571 );
572 return el.Box({
573 flexDirection: 'column',
574 gap: 1,
575 children: [
576 el.Box({ flexDirection: 'row', columnGap: 1, children: [badge(el, '', forTheme(status('error'), tint)), el.Text({ bold: true, children: ex.title })] }),
577 el.Box({ flexDirection: 'column', gap: 1, paddingLeft: 2, children: under }),
578 ],
579 });
580}
581
582/** Reads one project's capabilities for the picker; a result that lands after the pane closed is dropped. */
583async function runDetail($: EngineInterface, root: string): Promise<void> {
584 detailsPending.add(root);
585 const envelope = await codeIntel(
586 { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
587 'return await api.getCapabilities()',
588 { cwd: root },
589 );
590 if (!detailsPending.delete(root)) return;
591 details.set(root, envelope);
592 $.ui.invalidate('ui.render');
593}
594
595export function registerCommand(on: On, options: PluginOptions): void {
596 on('session.start', async ($, e, next) => {
597 const ports: OnboardingPorts = {
598 run: (argv, init) => $.process.run(argv, init),
599 exists: (p) => $.fs.exists(p),
600 read: (p) => $.fs.read(p),
601 envSet: (key) => $.env.set('CONSTELLATION_ACCESS_KEY', key),
602 after: (ms, fn) => {
603 $.clock.after(ms, fn);
604 },
605 mcp: { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
606 reload: () => $.command.run({ command: 'reload-plugins' }),
607 toast: (text) => $.ui.toast(text),
608 invalidate: () => $.ui.invalidate('ui.render'),
609 log: async (text) => {
610 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
611 },
612 };
613 // A key the CLI stored is set before the session goes on, so the MCP server can start with it.
614 // A `-p` or SDK run skips the read-back: no one is at the prompt, and a login shell can take seconds.
615 let found: FoundKey | undefined;
616 try {
617 if (e.isInteractive && !isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) {
618 found = await readStoredKeyAtStart(e.cwd, ports);
619 if (found !== undefined) await ports.envSet(found.key);
620 } else {
621 await rememberRepo(e.cwd, ports.exists);
622 }
623 } catch {
624 // The onboarding never fails the session or skips the registration.
625 found = undefined;
626 }
627 const r = await next(e);
628 // The project to ping and watch: the stored key's, else the session's when its key is set.
629 let root: string | null = null;
630 try {
631 if (found !== undefined) root = found.projectRoot;
632 else if (isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) root = await projectRoot(e.cwd, ports.exists);
633 } catch {
634 root = null;
635 }
636 if (root !== null) {
637 const at = root;
638 const again = () => recheck(ports.run, ports.invalidate, ports.log);
639 track(at);
640 try {
641 // Never await code_intel in session.start: the ping runs on a timer. Only a stored key's ping acts on
642 // AUTH_ERROR and the index button; either answer tells the freshness indicator what is indexed.
643 $.clock.after(0, () => {
644 const observe = (envelope: CodeIntelEnvelope) => void observeEnvelope(envelope, at, ports.run, ports.invalidate, ports.log).catch(() => undefined);
645 const pinged =
646 found !== undefined
647 ? checkConnection(at, ports, observe)
648 : pingProject(at, ports).then((envelope) => {
649 if (envelope !== undefined) observe(envelope);
650 });
651 void pinged.then(again).catch(() => undefined);
652 });
653 } catch {
654 // No timer: the agent's own calls still bring up the band and the indicator.
655 }
656 try {
657 // Every five minutes git alone compares the checkout again; code_intel is never called on the timer.
658 startTicks(() => $.clock.every(5 * 60_000, () => void again().catch(() => undefined)));
659 } catch {
660 // No timer: git commands the agent runs and code_intel answers still update the indicator.
661 }
662 }
663 try {
664 await $.command.register({
665 name: COMMAND,
666 description: 'Constellation status, diagnose, deps, unused code, symbol explorer and code_intel usage stats',
667 argumentHint: '[status|diagnose|deps <file>|unused [kind]|explore [query]|stats]',
668 immediate: true,
669 });
670 } catch {
671 // Registration refused: the Markdown commands still work.
672 }
673 return r;
674 });
675
676 on('command.run', { command: COMMAND }, async ($, e) => {
677 const { tab, path, kind, query } = parseArgs(e.args);
678 const draws = canDraw(await $.session.surfaces());
679 if (tab === 'stats' && !draws) {
680 const { session, stored } = await readStats($);
681 return { text: [`${PROMPT} ${tab}`, ...statsLines(session, stored).map((l) => `- ${l}`)].join('\n') };
682 }
683 const cwd = await $.session.cwd();
684 if (chosen?.from !== cwd) {
685 try {
686 const saved = await $.store.get(pickKey(cwd));
687 if (typeof saved === 'string') chosen = { from: cwd, root: saved };
688 } catch {
689 // Nothing saved, or the store is unavailable: the picker asks again.
690 }
691 }
692 const dir = target(cwd);
693 unusedKind = kind;
694 exploreQuery = query ?? '';
695 if (!draws && tab !== 'stats') {
696 if (tab === 'deps' && path === undefined) return { text: 'Usage: /constellation deps <file>' };
697 if (tab === 'explore' && query === undefined) return { text: 'Usage: /constellation explore <symbol>' };
698 const envelope = await codeIntel(
699 { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
700 codeFor(tab, 'dependencies', path ?? ''),
701 { cwd: dir },
702 );
703 const summary = summarize(tab, envelope, { path, project: projectName(dir), query });
704 const meta = metadata(envelope);
705 if (summary.explanation !== undefined) return { text: [`${PROMPT} ${tab}`, ...summary.lines].join('\n') };
706 const body = [...summary.lines.slice(0, MAX_TEXT_LINES), ...(meta === undefined ? [] : [meta])];
707 return { text: [`${PROMPT} ${tab}`, ...body.map((l) => `- ${l.trim()}`)].join('\n') };
708 }
709 reset();
710 unusedKind = kind;
711 exploreQuery = query ?? '';
712 selected = tab;
713 depsPath = path ?? '';
714 sessionCwd = dir;
715 launchCwd = cwd;
716 try {
717 tint = scheme(options.colors, (await $.config.list()).find((r) => r.key === 'theme')?.value);
718 } catch {
719 tint = scheme(options.colors, undefined);
720 }
721 await $.ui.open({ id: PANE, title: 'Constellation', focus: true, closeOnEscape: true });
722 $.ui.invalidate('ui.render');
723 return {};
724 });
725
726 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
727 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e);
728 const el = $.ui.resolve(e);
729 const envelope = selected === 'stats' ? undefined : cache.get(selected);
730 const needsPath = selected === 'deps' && depsPath === '';
731 const needsQuery = selected === 'explore' && exploreQuery === '';
732 if (selected === 'stats') {
733 if (history === undefined) void readHistory($);
734 } else if (envelope === undefined && !pending.has(selected) && !needsPath && !needsQuery) void runQuery($, selected);
735 const isPending = selected !== 'stats' && pending.has(selected);
736
737 const redraw = (): void => $.ui.invalidate('ui.render');
738 // In a row that may be wider than the pane, the name and kind keep their width and a long location
739 // shortens in the middle, instead of every item shrinking and wrapping onto a second line.
740 const fixed = (children: RenderElement[]): RenderElement => el.Box({ flexDirection: 'row', columnGap: 1, flexShrink: 0, children });
741 const place = (text: string): RenderElement => el.Box({ flexShrink: 1, children: [el.Text({ dimColor: true, wrap: 'truncate-middle', children: text })] });
742 // Desktop has no monospace grid, so rows padded into columns do not line up there: it gets markdown tables.
743 const grid = e.surface !== 'desktop';
744 // The selected one of a row of tab-like buttons: `▸` in the terminal, Desktop's own primary button there.
745 // A checkbox: `[x]` in the terminal; on Desktop ballot boxes, the check forced to text (U+FE0E), not an emoji.
746 const check = (isChecked: boolean): string => (grid ? (isChecked ? '[x]' : '[ ]') : isChecked ? '\u2611\uFE0E' : '\u2610');
747 const choice = (isSelected: boolean, label: string): Pick<ButtonProps, 'label' | 'plain' | 'variant'> =>
748 grid ? { label: isSelected ? `▸ ${label}` : label, plain: true } : isSelected ? { label, variant: 'primary' } : { label, plain: true };
749 // A table of Boxes, each column a share of the width, so cells keep their colors and still line up without a
750 // monospace grid. Never markdown: the cells hold graph text, which a markdown parser would read as links.
751 const boxTable = (widths: readonly string[], rows: readonly { key?: string; cells: readonly RenderElement[] }[], head?: readonly string[]): RenderElement => {
752 const line = (cells: readonly RenderElement[], key?: string): RenderElement =>
753 el.Box({ ...(key === undefined ? {} : { key }), flexDirection: 'row', children: cells.map((cell, i) => el.Box({ width: widths[i] ?? 'auto', children: [cell] })) });
754 return el.Box({
755 flexDirection: 'column',
756 children: [...(head === undefined ? [] : [line(head.map((h) => el.Text({ bold: true, children: h })))]), ...rows.map((r) => line(r.cells, r.key))],
757 });
758 };
759 // Label and value rows, the label dim: the Desktop form of rows padded into a label column.
760 const factTable = (rows: readonly (readonly [string, string])[]): RenderElement =>
761 boxTable(['25%', '75%'], rows.map(([label, value]) => ({ cells: [el.Text({ dimColor: true, children: label }), el.Text({ children: value })] })));
762 const showDeps = (path: string): void => {
763 depsPath = path;
764 drop('deps');
765 redraw();
766 };
767 const accent = paint(palette.nebula, tint);
768 const pick = async (project: string): Promise<void> => {
769 const from = launchCwd ?? (await $.session.cwd());
770 chosen = { from, root: project };
771 sessionCwd = project;
772 for (const tab of TABS) drop(tab);
773 redraw();
774 try {
775 await $.store.set(pickKey(from), project);
776 } catch {
777 // Not remembered across sessions; this session keeps the pick.
778 }
779 };
780 const switchProject = async (): Promise<void> => {
781 const from = launchCwd ?? (await $.session.cwd());
782 chosen = undefined;
783 sessionCwd = from;
784 for (const tab of TABS) drop(tab);
785 redraw();
786 try {
787 await $.store.delete(pickKey(from));
788 } catch {
789 // A pick left in the store is offered again and can be switched again.
790 }
791 };
792 const summary = selected === 'stats' || envelope === undefined ? undefined : summarize(selected, envelope, { direction: depsDirection, path: depsPath, project: projectName(sessionCwd), query: exploreQuery });
793
794 const picking = summary?.items.some((i) => i.project !== undefined) ?? false;
795
796 const body: RenderElement[] = [];
797 if (selected === 'deps') {
798 body.push(
799 el.Input({
800 key: 'deps-path',
801 label: 'File',
802 value: depsPath,
803 placeholder: 'path/to/file.ts',
804 submitLabel: 'show',
805 onSubmit: (value) => showDeps(value.trim()),
806 }),
807 el.Select({
808 key: 'deps-direction',
809 label: 'Show',
810 options: [
811 { value: 'dependencies', label: 'Dependencies (what this file imports)' },
812 { value: 'dependents', label: 'Dependents (what imports this file)' },
813 ],
814 value: depsDirection,
815 onSelect: (value) => {
816 if (value !== 'dependencies' && value !== 'dependents') return;
817 if (value === depsDirection) return;
818 depsDirection = value;
819 drop('deps');
820 redraw();
821 },
822 }),
823 );
824 }
825 if (selected === 'unused' && !picking) {
826 body.push(
827 el.Select({
828 key: 'unused-kind',
829 label: 'Kind',
830 options: [
831 { value: ALL_KINDS, label: 'All kinds' },
832 ...[...UNUSED_KINDS, ...(unusedKind === undefined || UNUSED_KINDS.includes(unusedKind) ? [] : [unusedKind])].map((k) => ({ value: k, label: k })),
833 ],
834 value: unusedKind ?? ALL_KINDS,
835 onSelect: (value) => {
836 const chosenKind = value === ALL_KINDS ? undefined : value;
837 if (chosenKind === unusedKind) return;
838 unusedKind = chosenKind;
839 drop('unused');
840 redraw();
841 },
842 }),
843 );
844 }
845 const hit = focusHit;
846 if (selected === 'explore' && hit === undefined) {
847 body.push(
848 el.Input({
849 key: 'explore-query',
850 label: 'Symbol',
851 value: exploreQuery,
852 placeholder: 'name',
853 submitLabel: 'search',
854 autoFocus: true,
855 onSubmit: (value) => {
856 exploreQuery = value.trim();
857 drop('explore');
858 redraw();
859 },
860 }),
861 );
862 }
863 const firstPage = selected === 'unused' && envelope?.success === true ? orphanPage(envelope.result) : undefined;
864 const loaded = firstPage === undefined ? [] : [...firstPage.rows, ...morePages];
865 const pickedRows = loaded.filter((r) => picked.has(r.symbolId));
866 const toggle = (ids: readonly string[], select: boolean): void => {
867 for (const id of ids) {
868 if (select) picked.add(id);
869 else picked.delete(id);
870 }
871 redraw();
872 };
873 if (firstPage !== undefined) {
874 if (loaded.length === 0) {
875 const color = paint(palette.cosmic, tint);
876 body.push(el.Text({ ...(color === undefined ? {} : { color }), children: '✦ No unused exports found' }));
877 } else {
878 const nebula = paint(palette.nebula, tint);
879 body.push(
880 el.Box({
881 flexDirection: 'column',
882 children: [
883 el.Box({
884 flexDirection: 'row',
885 columnGap: 2,
886 children: [
887 el.Text({ ...(nebula === undefined ? {} : { color: nebula }), children: `${picked.size} selected` }),
888 el.Text({ dimColor: true, children: `of ${grouped(firstPage.total ?? loaded.length)} unused exports` }),
889 el.Button({ key: 'select-all', label: 'Select all', hotkey: 'a', plain: true, onPress: () => toggle(loaded.map((r) => r.symbolId), true) }),
890 ],
891 }),
892 // The list's keys sit here, not in the footer: a long list scrolls the footer out of view.
893 // Desktop shows each hotkey on its button and is used with a pointer, so the key hints are the terminal's.
894 ...(grid ? [el.Text({ dimColor: true, children: `tab/shift+tab move · enter toggle${picked.size > 0 ? ' · h hand off' : ''}` })] : []),
895 ...(handoffNote === undefined ? [] : [el.Text({ dimColor: true, children: handoffNote })]),
896 ],
897 }),
898 );
899 for (const [file, rows] of byFile(loaded)) {
900 const ids = rows.map((r) => r.symbolId);
901 const all = ids.every((id) => picked.has(id));
902 body.push(
903 el.Button({ key: `orphan-file:${file}`, label: `${check(all)} ${file}`, plain: true, onPress: () => toggle(ids, !all) }),
904 ...rows.map((row) =>
905 // The file is the header above, so a row names only its line. Desktop: the name is part of the
906 // checkbox, so it is a larger target, and the columns are shares of the width.
907 !grid
908 ? el.Box({
909 key: `row:${row.symbolId}`,
910 flexDirection: 'row',
911 paddingLeft: 2,
912 children: [
913 el.Box({
914 width: '50%',
915 children: [
916 el.Button({
917 key: `orphan:${row.symbolId}`,
918 label: `${check(picked.has(row.symbolId))} ${row.name}`,
919 plain: true,
920 onPress: () => toggle([row.symbolId], !picked.has(row.symbolId)),
921 }),
922 ],
923 }),
924 el.Box({ width: '25%', children: [badge(el, '', forTheme(kind(row.kind), tint))] }),
925 el.Box({ width: '25%', children: row.lineEnd === undefined ? [] : [el.Text({ dimColor: true, children: `line ${row.lineEnd}` })] }),
926 ],
927 })
928 : el.Box({
929 key: `row:${row.symbolId}`,
930 flexDirection: 'row',
931 columnGap: 1,
932 paddingLeft: 2,
933 children: [
934 fixed([
935 el.Button({
936 key: `orphan:${row.symbolId}`,
937 label: check(picked.has(row.symbolId)),
938 plain: true,
939 onPress: () => toggle([row.symbolId], !picked.has(row.symbolId)),
940 }),
941 el.Text({ children: row.name }),
942 badge(el, '', forTheme(kind(row.kind), tint)),
943 ]),
944 ...(row.lineEnd === undefined ? [] : [el.Text({ dimColor: true, children: `line ${row.lineEnd}` })]),
945 ],
946 }),
947 ),
948 );
949 }
950 if (nextOffset !== undefined && !loadingMore) {
951 body.push(el.Button({ key: 'load-more', label: 'Load more', plain: true, onPress: () => loadMore($) }));
952 }
953 if (loadingMore) body.push(badge(el, 'loading more', forTheme(status('pending'), tint)));
954 }
955 } else if (selected === 'stats') {
956 const cosmic = paint(palette.cosmic, tint);
957 const solar = paint(palette.solar, tint);
958 // Every row from one snapshot; while it is being read, the session's counts as they stand.
959 const snapshot = typeof history === 'object' ? history : undefined;
960 if (!grid) {
961 const colored = (color: string | undefined, text: string): RenderElement => el.Text({ ...(color === undefined ? {} : { color }), children: text });
962 body.push(
963 boxTable(
964 ['24%', '19%', '19%', '19%', '19%'],
965 statsCells(snapshot?.session ?? sessionCounts(), snapshot?.stored).map(({ label, figures: f }) => ({
966 key: `stats:${label}`,
967 cells: [
968 el.Text({ dimColor: true, children: label }),
969 colored(cosmic, f.calls),
970 colored(solar, f.symbol),
971 el.Text({ dimColor: true, children: f.literal }),
972 el.Text({ bold: true, children: f.share }),
973 ],
974 })),
975 ['', 'code_intel calls', 'symbol-like', 'literal', SHARE_LABEL],
976 ),
977 );
978 } else {
979 for (const row of statRows(snapshot?.session ?? sessionCounts(), snapshot?.stored)) {
980 const f = figures(row.counts);
981 body.push(
982 el.Box({
983 key: `stats:${row.label}`,
984 flexDirection: 'row',
985 children: [
986 el.Text({ dimColor: true, children: row.label.padEnd(LABEL_WIDTH) }),
987 el.Box({
988 flexDirection: 'column',
989 children: [
990 el.Box({
991 flexDirection: 'row',
992 columnGap: 2,
993 flexWrap: 'wrap',
994 children: [
995 el.Text({ ...(cosmic === undefined ? {} : { color: cosmic }), children: f.calls }),
996 el.Text({ ...(solar === undefined ? {} : { color: solar }), children: f.symbol }),
997 el.Text({ dimColor: true, children: f.literal }),
998 el.Box({
999 flexDirection: 'row',
1000 columnGap: 1,
1001 children: [el.Text({ bold: true, children: f.share }), el.Text({ dimColor: true, children: SHARE_LABEL })],
1002 }),
1003 ],
1004 }),
1005 ...(row.split === undefined ? [] : [el.Text({ dimColor: true, children: row.split })]),
1006 ],
1007 }),
1008 ],
1009 }),
1010 );
1011 }
1012 }
1013 if (snapshot === undefined) body.push(badge(el, 'reading the stored counts', forTheme(status('pending'), tint)));
1014 else if (snapshot.stored === undefined) body.push(el.Text({ dimColor: true, children: UNREAD_NOTE }));
1015 body.push(el.Text({ dimColor: true, children: SHARE_NOTE }));
1016 } else if (selected === 'explore' && summary !== undefined && summary.explanation === undefined) {
1017 const labeled = (label: string, value: string): RenderElement =>
1018 el.Box({ flexDirection: 'row', children: [el.Text({ dimColor: true, children: label.padEnd(LABEL_WIDTH) }), el.Text({ children: value })] });
1019 const stamp = (from: CodeIntelEnvelope | undefined): RenderElement[] => {
1020 const parts: string[] = [];
1021 if (from?.time !== undefined) parts.push(`${from.time} ms`);
1022 if (from?.asOfCommit) parts.push(`as of ${from.asOfCommit.slice(0, 7)}`);
1023 return parts.length === 0 ? [] : [el.Text({ dimColor: true, children: parts.join(' · ') })];
1024 };
1025 if (hit === undefined) {
1026 const found = rankExact(exploreQuery, hits(envelope?.result)).slice(0, EXPLORE_ROWS);
1027 if (found.length === 0) body.push(el.Text({ dimColor: true, children: 'No symbols match' }));
1028 for (const h of found) {
1029 body.push(
1030 el.Box({
1031 key: `row:${h.id}`,
1032 flexDirection: 'row',
1033 columnGap: 1,
1034 children: [
1035 fixed([
1036 el.Button({
1037 key: `hit:${h.id}`,
1038 label: h.name,
1039 plain: true,
1040 onPress: () => {
1041 focusHit = h;
1042 section = 'details';
1043 if (!drill.has(h.id) && !drillPending.has(h.id)) void runDrill($, h.id, h.kind);
1044 redraw();
1045 },
1046 }),
1047 badge(el, '', forTheme(kind(h.kind), tint)),
1048 ]),
1049 place(where(h)),
1050 ],
1051 }),
1052 );
1053 }
1054 body.push(...stamp(envelope));
1055 } else {
1056 const drilled = drill.get(hit.id);
1057 const result = isRecord(drilled?.result) ? drilled.result : {};
1058 const aliasNote = el.Text({ dimColor: true, children: 'Callers importing through path aliases or export * barrels may be missing.' });
1059 const sections: [Section, string][] = [
1060 ['details', 'Details'],
1061 ['usages', 'Usages'],
1062 ['impact', 'Impact'],
1063 // Only a kind with a call graph offers it; core refuses the read for any other.
1064 ...(hasCallGraph(hit.kind) ? [['calls', 'Call graph'] as [Section, string]] : []),
1065 ];
1066 body.push(
1067 el.Box({
1068 flexDirection: 'column',
1069 gap: 1,
1070 children: [
1071 el.Box({
1072 flexDirection: 'column',
1073 children: [
1074 el.Text({ dimColor: true, children: `results for ${exploreQuery}` }),
1075 el.Box({
1076 flexDirection: 'row',
1077 columnGap: 1,
1078 children: [
1079 fixed([badge(el, '', forTheme(kind(hit.kind), tint)), el.Text({ bold: true, children: hit.name })]),
1080 place(where(hit)),
1081 fixed([
1082 el.Button({
1083 key: 'copy-location',
1084 label: 'copy location',
1085 plain: true,
1086 dimColor: true,
1087 onPress: async (press) => {
1088 const { isCopied } = await $.ui.copy({ text: where(hit), surface: press.surface });
1089 exploreNote = isCopied ? 'Copied' : 'Could not copy';
1090 redraw();
1091 },
1092 }),
1093 ]),
1094 ],
1095 }),
1096 ...(exploreNote === undefined ? [] : [el.Text({ dimColor: true, children: exploreNote })]),
1097 ],
1098 }),
1099 el.Box({
1100 flexDirection: 'row',
1101 columnGap: 2,
1102 children: sections.map(([name, label]) =>
1103 el.Button({
1104 key: `section-${name}`,
1105 ...choice(name === section, label),
1106 onPress: () => {
1107 section = name;
1108 redraw();
1109 },
1110 }),
1111 ),
1112 }),
1113 drilled === undefined
1114 ? badge(el, 'querying Constellation', forTheme(status('pending'), tint))
1115 : !drilled.success
1116 ? errorView(el, summarize('explore', drilled).explanation ?? explain({ code: 'UNKNOWN', message: 'The request failed' }), tint)
1117 : el.Box({
1118 flexDirection: 'column',
1119 children: (() => {
1120 if (section === 'details') {
1121 const lines = detailLines(result['details']);
1122 if (lines.length === 0) return [el.Text({ dimColor: true, children: 'No details returned' })];
1123 return grid ? lines.map((l) => el.Text({ children: l })) : [factTable(detailRows(result['details']))];
1124 }
1125 if (section === 'usages') {
1126 const lines = usageLines(result['usages']);
1127 if (lines.length === 0) return [el.Text({ dimColor: true, children: 'No usages found' }), aliasNote];
1128 if (grid) return [...lines.map((l) => el.Text({ children: l })), aliasNote];
1129 const total = usageTotal(result['usages']);
1130 const rows = usageRows(result['usages']);
1131 return [
1132 ...(total === undefined ? [] : [el.Text({ children: total })]),
1133 ...(rows.length === 0 ? [] : [boxTable(['75%', '25%'], rows.map((r) => ({ cells: r.map((t) => el.Text({ children: t })) })), ['Location', 'Usage'])]),
1134 aliasNote,
1135 ];
1136 }
1137 if (section === 'impact') {
1138 const view = impactView(result['impact']);
1139 const facts: [string, string][] = [
1140 ...(view.files === undefined ? [] : [['Files', String(view.files)] as [string, string]]),
1141 ...(view.direct === undefined ? [] : [['Direct', String(view.direct)] as [string, string]]),
1142 ...(view.transitive === undefined ? [] : [['Transitive', String(view.transitive)] as [string, string]]),
1143 ...(view.tests === undefined && view.production === undefined
1144 ? []
1145 : [['Tests', `${view.tests ?? 0} test · ${view.production ?? 0} production`] as [string, string]]),
1146 ];
1147 return [
1148 ...(view.riskLevel === undefined ? [] : [badge(el, '', forTheme(risk(view.riskLevel), tint))]),
1149 ...(grid ? facts.map(([label, value]) => labeled(label, value)) : facts.length === 0 ? [] : [factTable(facts)]),
1150 ...(grid
1151 ? view.top.map((d) => badge(el, d.name, forTheme(kind(d.kind), tint)))
1152 : view.top.length === 0
1153 ? []
1154 : [boxTable(['60%', '40%'], view.top.map((d) => ({ cells: [el.Text({ children: d.name }), badge(el, '', forTheme(kind(d.kind), tint))] })), ['Dependent', 'Kind'])]),
1155 aliasNote,
1156 ];
1157 }
1158 const tree = callTree(result['calls']);
1159 return tree.length === 0
1160 ? [el.Text({ dimColor: true, children: 'No callers or callees found' })]
1161 : tree.map((l) => el.Text({ ...(l.depth === 0 ? { bold: true } : {}), children: `${' '.repeat(l.depth)}${l.text}` }));
1162 })(),
1163 }),
1164 ],
1165 }),
1166 ...stamp(drilled),
1167 );
1168 }
1169 } else if (summary?.explanation !== undefined && !summary.items.some((i) => i.project !== undefined)) {
1170 body.push(errorView(el, summary.explanation, tint));
1171 } else if (summary !== undefined) {
1172 let row = 0;
1173 // On Desktop, each run of labeled facts is one table.
1174 const facts: RenderElement[][] = [];
1175 const flush = (): void => {
1176 if (facts.length > 0) body.push(boxTable(['25%', '75%'], facts.splice(0).map((cells) => ({ cells }))));
1177 };
1178 for (const item of summary.items) {
1179 const path = item.path;
1180 const project = item.project;
1181 if (!grid && item.label !== undefined && project === undefined && path === undefined) {
1182 const tone =
1183 item.badge === undefined ? undefined : forTheme(item.badge.kind === 'status' ? status(item.badge.value) : kind(item.badge.value), tint);
1184 facts.push([el.Text({ dimColor: true, children: item.label }), tone === undefined ? el.Text({ children: item.text }) : badge(el, item.text, tone)]);
1185 continue;
1186 }
1187 flush();
1188 if (project !== undefined) {
1189 // The tabs are hidden while picking, so the digits are free for the rows.
1190 const n = row++;
1191 if (!details.has(project) && !detailsPending.has(project)) void runDetail($, project);
1192 body.push(
1193 el.Box({
1194 key: `row:${project}`,
1195 flexDirection: 'row',
1196 columnGap: 2,
1197 children: [
1198 el.Button({
1199 key: `project:${project}`,
1200 label: item.text,hooks/describe.ts 73 lines1import type { EngineInterface, On } from 'claude-code';
2import { freshnessLine, observeEnvelope, recheck, track } from './freshness';
3import type { Run } from './freshness';
4import { canDraw, isConfigured, projectRoot } from './lib';
5import type { McpPort } from './lib';
6import { pingProject } from './onboarding';
7
8/**
9 * Appended to the description of every search tool the model sees. Constant, so
10 * the cached description keeps the prompt cache valid.
11 */
12export const GUIDANCE =
13 '\n\nFor symbol definitions, references, dependents, call graphs or impact, use the code_intel tool. Grep, Glob and grep or rg in the shell are for literal text such as error messages, config values, log strings and comments.';
14
15/**
16 * The last gate result a description was built under; undefined until one is.
17 * Once the guidance is in, it stays: it is harmless outside a project, and
18 * taking it out again would change the tools block and rewrite the prompt cache
19 * every time Claude `cd`s out of and back into an indexed project.
20 */
21let lastGate: boolean | undefined;
22
23/** True when the access key starts with `ak:` and `cwd` sits inside an indexed project. */
24async function gateOpen($: EngineInterface, cwd: string): Promise<boolean> {
25 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return false;
26 return (await projectRoot(cwd, (p) => $.fs.exists(p))) !== null;
27}
28
29export function registerDescribe(on: On): void {
30 on('tool.describe', { tool: /^(Grep|Glob|Bash)$/ }, async ($, e, next) => {
31 const r = await next(e);
32 const open = lastGate === true || (await gateOpen($, await $.session.cwd()));
33 lastGate = open;
34 if (!open) return r;
35 return { ...r, description: r.description + GUIDANCE };
36 });
37
38 on('classic.CwdChanged', async ($, e, next) => {
39 const r = await next(e);
40 // The freshness indicator follows the project: a new one is pinged for what is indexed, the same one compared again.
41 try {
42 const root = isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY')) ? await projectRoot(e.new_cwd, (p) => $.fs.exists(p)) : null;
43 if (root !== null) {
44 const run: Run = (argv, init) => $.process.run(argv, init);
45 const invalidate = () => $.ui.invalidate('ui.render');
46 const log = async (text: string) => {
47 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
48 };
49 const showing = freshnessLine(Date.now()) !== undefined;
50 if (track(root)) {
51 // The last project's line goes.
52 if (showing) invalidate();
53 const mcp: McpPort = { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) };
54 void pingProject(root, { read: (p) => $.fs.read(p), mcp })
55 .then((envelope) => (envelope === undefined ? undefined : observeEnvelope(envelope, root, run, invalidate, log)))
56 .then(() => recheck(run, invalidate, log))
57 .catch(() => undefined);
58 } else {
59 void recheck(run, invalidate, log).catch(() => undefined);
60 }
61 }
62 } catch {
63 // The indicator stays as it was; the gate below still runs.
64 }
65 if (lastGate !== false) return r;
66 if (await gateOpen($, e.new_cwd)) {
67 lastGate = true;
68 $.ui.invalidate('tool.describe');
69 }
70 return r;
71 });
72}
73hooks/freshness.ts 295 lines1import type { ElementTable, On, PluginOptions, ProcessRunInit, ProcessRunResult, RenderElement, Timer } from 'claude-code';
2import { explain } from './explain';
3import { canDraw, type CodeIntelEnvelope, type CodeIntelError, GIT, GIT_ENV, GIT_TIMEOUT_MS, isConfigured, plural, stringArg } from './lib';
4import { forTheme, status } from './theme';
5import type { Scheme } from './theme';
6
7/** Runs a command, as `$.process.run`. */
8export type Run = (argv: readonly string[], init: ProcessRunInit) => Promise<Pick<ProcessRunResult, 'exitCode' | 'stdout'>>;
9
10/** Writes the line where nothing draws it. A promise it returns may reject; that is ignored. */
11export type Log = (text: string) => Promise<void> | void;
12
13/** What the graph was indexed at, from a code_intel envelope. */
14type Index = { asOfCommit: string; lastIndexedAt?: string; branch?: string };
15
16/** What the working tree is at. `behind` is undefined when the count could not be taken. */
17type Local = { head: string; branch: string; behind: number | undefined };
18
19/** What the indicator shows, or nothing when the graph matches the checkout. */
20export type FreshnessView =
21 | { kind: 'behind'; behind: number; lastIndexedAt?: string }
22 | { kind: 'mismatch'; asOfCommit: string; lastIndexedAt?: string }
23 | { kind: 'error'; failure: CodeIntelError; lastIndexedAt?: string };
24
25/** Error codes the onboarding band already shows, so the indicator stays quiet. */
26const ONBOARDING_CODES: ReadonlySet<string> = new Set(['AUTH_ERROR', 'PROJECT_NOT_INDEXED', 'NOT_CONFIGURED', 'MCP_UNAVAILABLE']);
27
28/** A commit id as git prints it. Anything else from the server never reaches a git argument. */
29const COMMIT = /^[0-9a-f]{7,64}$/i;
30
31const SEP = ' · ';
32
33/**
34 * A git command that moves HEAD or changes the tree: `git`, then options such
35 * as `-C dir`, then the subcommand, with no `;`, `&`, `|` or line break
36 * between them, so `cd x && git commit` counts and `echo git; commit` does not.
37 */
38const GIT_MOVES = /\bgit\s[^;&|\n]*\b(?:commit|pull|checkout|merge|rebase)\b/;
39
40/**
41 * Module state, cleared by `registerFreshness`. The index, the local checkout
42 * and the root describe the repository, so `resetFreshness` keeps them.
43 */
44let index: Index | undefined;
45let local: Local | undefined;
46let failure: CodeIntelError | undefined;
47let root: string | undefined;
48let logged = false;
49/** The latest recheck; an older one that finishes after a newer started drops its result. */
50let seq = 0;
51let timer: Timer | undefined;
52let colors: unknown;
53
54/** How long ago `iso` was: "just now", "5m ago", "2h ago" or "3d ago". Undefined when `iso` is not a date. */
55function age(iso: string, now: number): string | undefined {
56 const then = Date.parse(iso);
57 if (Number.isNaN(then)) return undefined;
58 const minutes = Math.floor(Math.max(0, now - then) / 60000);
59 if (minutes < 1) return 'just now';
60 if (minutes < 60) return `${minutes}m ago`;
61 const hours = Math.floor(minutes / 60);
62 return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
63}
64
65/**
66 * What to draw for the graph's commit against the checkout, or undefined for
67 * nothing. A failure with no index is an error. A HEAD that starts with the
68 * indexed commit (the envelope may carry a short id) is fresh.
69 * Otherwise HEAD is behind when the index commit has newer commits, and a
70 * mismatch when it has none or the count is unknown.
71 */
72export function freshnessView(
73 known: Index | undefined,
74 checkout: Local | undefined,
75 error: CodeIntelError | undefined,
76): FreshnessView | undefined {
77 if (known === undefined) {
78 return error === undefined ? undefined : { kind: 'error', failure: error };
79 }
80 if (checkout === undefined) return undefined;
81 if (checkout.head.startsWith(known.asOfCommit)) return undefined;
82 const shared = known.lastIndexedAt === undefined ? {} : { lastIndexedAt: known.lastIndexedAt };
83 if (checkout.behind !== undefined && checkout.behind > 0) return { kind: 'behind', behind: checkout.behind, ...shared };
84 return { kind: 'mismatch', asOfCommit: known.asOfCommit, ...shared };
85}
86
87/** What a behind or mismatch view says: its state words, and when the index was taken. */
88function stateParts(view: Exclude<FreshnessView, { kind: 'error' }>, now: number): { head: string; since?: string } {
89 const since = view.lastIndexedAt === undefined ? undefined : age(view.lastIndexedAt, now);
90 const head = view.kind === 'behind' ? `index ${plural(view.behind, 'commit')} behind` : `index at ${view.asOfCommit.slice(0, 7)}`;
91 return since === undefined ? { head } : { head, since: `indexed ${since}` };
92}
93
94/** The indicator as one plain line, for the band and for sessions that cannot draw. */
95export function freshnessText(view: FreshnessView, now: number): string {
96 if (view.kind === 'error') {
97 const ex = explain(view.failure);
98 return ['✦ ' + view.failure.code, ex.title, ...(ex.steps[0] === undefined ? [] : [ex.steps[0]])].join(SEP);
99 }
100 const { head, since } = stateParts(view, now);
101 return `✦ ${[head, ...(since === undefined ? [] : [since])].join(SEP)}`;
102}
103
104/**
105 * The indicator as one row: the mark and state words painted (gold for behind
106 * and mismatch, red for an error), the index age dim. An
107 * error's headline is primary text and its next step dim. `el` is the table
108 * from `$.ui.resolve(e)`.
109 */
110export function freshnessBand(el: ElementTable, view: FreshnessView, tint: Scheme, now: number): RenderElement {
111 const tone = forTheme(status(view.kind === 'error' ? 'error' : 'stale'), tint);
112 const painted = (text: string) => el.Text({ ...(tone.color === undefined ? {} : { color: tone.color }), children: text });
113 const dim = (text: string) => el.Text({ dimColor: true, children: text });
114 if (view.kind === 'error') {
115 const ex = explain(view.failure);
116 const step = ex.steps[0];
117 return el.Text({
118 wrap: 'truncate',
119 children: [painted(`✦ ${view.failure.code}`), el.Text({ children: `${SEP}${ex.title}` }), ...(step === undefined ? [] : [dim(`${SEP}${step}`)])],
120 });
121 }
122 const { head, since } = stateParts(view, now);
123 return el.Text({
124 wrap: 'truncate',
125 children: [painted(`✦ ${head}`), ...(since === undefined ? [] : [dim(`${SEP}${since}`)])],
126 });
127}
128
129/**
130 * Where HEAD is against `asOfCommit`, in the project at `root`: its commit and
131 * branch (`HEAD` when detached), how many commits it is past `asOfCommit`, and
132 * whether the tree has changes. Undefined when git fails, so the indicator
133 * stays as it was.
134 */
135export async function compareLocal(run: Run, root: string, asOfCommit: string): Promise<Local | undefined> {
136 const git = (args: string[]): Promise<Pick<ProcessRunResult, 'exitCode' | 'stdout'>> =>
137 run([...GIT, ...args], { cwd: root, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS });
138 try {
139 const parsed = await git(['rev-parse', 'HEAD', '--symbolic-full-name', 'HEAD']);
140 if (parsed.exitCode !== 0) return undefined;
141 const [head = '', ref = ''] = parsed.stdout.split('\n').map((l) => l.trim());
142 if (head === '') return undefined;
143 const branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : 'HEAD';
144 let behind: number | undefined = 0;
145 if (!head.startsWith(asOfCommit)) {
146 const counted = await git(['rev-list', '--count', `${asOfCommit}..HEAD`, '--']);
147 const n = Number(counted.stdout.trim());
148 behind = counted.exitCode === 0 && /^\d+$/.test(counted.stdout.trim()) ? n : undefined;
149 }
150 return { head, branch, behind };
151 } catch {
152 return undefined;
153 }
154}
155
156/** The line now, or undefined when nothing is shown. */
157function lineNow(): string | undefined {
158 const view = freshnessView(index, local, failure);
159 return view === undefined ? undefined : freshnessText(view, Date.now());
160}
161
162/** Draws again and logs once when the line changed from `before`. */
163function changed(before: string | undefined, invalidate: () => void, log?: Log): void {
164 const after = lineNow();
165 if (after === before) return;
166 invalidate();
167 // Only a stale index is worth a line where nothing draws the indicator.
168 if (after !== undefined && log !== undefined && !logged && freshnessView(index, local, failure)?.kind === 'behind') {
169 logged = true;
170 void Promise.resolve(log(after)).catch(() => undefined);
171 }
172}
173
174/** Compares the checkout with the index, then draws again when the line changed from `before`. Never rejects. */
175async function compare(before: string | undefined, run: Run, invalidate: () => void, log?: Log): Promise<void> {
176 if (root === undefined || index === undefined) return changed(before, invalidate, log);
177 const mine = ++seq;
178 const next = await compareLocal(run, root, index.asOfCommit);
179 if (mine !== seq) return;
180 if (next === undefined) return changed(before, invalidate, log);
181 // The snapshot is per branch: one taken on another branch says nothing here.
182 if (index.branch === undefined) index = { ...index, branch: next.branch };
183 else if (index.branch !== next.branch) index = undefined;
184 local = next;
185 changed(before, invalidate, log);
186}
187
188/**
189 * Compares the checkout with the index and draws again when the line changed.
190 * A no-op without a tracked root and an index. Never rejects.
191 */
192export function recheck(run: Run, invalidate: () => void, log?: Log): Promise<void> {
193 return compare(lineNow(), run, invalidate, log);
194}
195
196/**
197 * Learns what the graph was indexed at from a code_intel envelope for the
198 * project at `root`. Only a hex commit id is kept, so nothing else from the
199 * server reaches git or the text. A failure shows only while no index is
200 * known, and never for the codes the onboarding band owns.
201 */
202export async function observeEnvelope(
203 envelope: CodeIntelEnvelope,
204 at: string,
205 run: Run,
206 invalidate: () => void,
207 log?: Log,
208): Promise<void> {
209 if (at !== root) return;
210 const before = lineNow();
211 if (!envelope.success) {
212 const error = envelope.error;
213 if (index === undefined && error !== undefined && !ONBOARDING_CODES.has(error.code)) failure = error;
214 return changed(before, invalidate, log);
215 }
216 failure = undefined;
217 const commit = envelope.asOfCommit;
218 if (commit === undefined || !COMMIT.test(commit)) return changed(before, invalidate, log);
219 if (index?.asOfCommit === commit && index.lastIndexedAt === envelope.lastIndexedAt) return changed(before, invalidate, log);
220 index = { asOfCommit: commit, ...(envelope.lastIndexedAt === undefined ? {} : { lastIndexedAt: envelope.lastIndexedAt }) };
221 await compare(before, run, invalidate, log);
222}
223
224/** Tracks the project at `at`. A different root drops what was known about the last one; true when it changed. */
225export function track(at: string): boolean {
226 if (at === root) return false;
227 root = at;
228 index = undefined;
229 local = undefined;
230 failure = undefined;
231 return true;
232}
233
234/** Starts the redraw timer with `every`, a closure over `$.clock.every`, after cancelling the last one. */
235export function startTicks(every: () => Timer): void {
236 timer?.cancel();
237 timer = every();
238}
239
240/** The indicator now: what to draw and its line, or undefined for nothing. */
241export function freshnessLine(now: number): { view: FreshnessView; text: string } | undefined {
242 const view = freshnessView(index, local, failure);
243 return view === undefined ? undefined : { view, text: freshnessText(view, now) };
244}
245
246/** The `colors` option, for painting the band. */
247export function freshnessColors(): unknown {
248 return colors;
249}
250
251/**
252 * Starts the indicator over and keeps the colors option. A Bash call that ran
253 * a git commit, pull, checkout, merge or rebase compares the checkout again;
254 * the call's result goes back untouched and does not wait for git.
255 */
256export function registerFreshness(on: On, options: PluginOptions): void {
257 timer?.cancel();
258 timer = undefined;
259 index = undefined;
260 local = undefined;
261 failure = undefined;
262 root = undefined;
263 logged = false;
264 seq = 0;
265 colors = options.colors;
266
267 on('tool.call', { tool: /^Bash$/ }, async ($, e, next) => {
268 const r = await next(e);
269 if (r.deny !== undefined || r.isError === true) return r;
270 try {
271 if (!GIT_MOVES.test(stringArg(e, 'command') ?? '')) return r;
272 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return r;
273 void recheck(
274 (argv, init) => $.process.run(argv, init),
275 () => $.ui.invalidate('ui.render'),
276 async (text) => {
277 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
278 },
279 ).catch(() => undefined);
280 } catch {
281 // The indicator stays as it was.
282 }
283 return r;
284 });
285}
286
287/**
288 * Lets the next conversation (`/clear`, `/resume`, `/branch`) log the line
289 * again. The index, the checkout and the root stay: they describe the
290 * repository, not the conversation.
291 */
292export function resetFreshness(): void {
293 logged = false;
294}
295hooks/impact.ts 195 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code';
2import { agentKey } from './budget';
3import { absolute, isConfigured, projectRoot, stringArg, withinDeadline } from './lib';
4import { atLeast, collectEvidence, type FileRisk, fileRisk, forgetAgentEvidence, hasEvidence, resetRiskCache, type RiskLevel } from './risk';
5import { PROMPT, risk as tone } from './theme';
6
7/** What the gate does before an edit to a file at or above the threshold. */
8type Mode = 'off' | 'dialog' | 'native' | 'require-analysis';
9
10const MODES: readonly Mode[] = ['off', 'dialog', 'native', 'require-analysis'];
11
12/** How long the toast for an edit already headed to the permission prompt stays. */
13const TOAST_MS = 10_000;
14
15/**
16 * How long an edit waits for its file's risk before it goes ahead ungated. The
17 * hook's own time limit pauses while `$.mcp.call` runs, so a slow server would
18 * otherwise hold the edit for code_intel's full timeout. A late lookup still
19 * settles into the cache for the next edit.
20 */
21export const RISK_DEADLINE_MS = 3000;
22
23const PROCEED = 'Proceed';
24const PROCEED_REMEMBER = "Proceed, and don't ask again for this file";
25const CANCEL = 'Cancel';
26
27let mode: Mode = 'off';
28let threshold: RiskLevel = 'high';
29
30/**
31 * The session's permission mode as of the last prompt. In `auto` an `ask` goes
32 * to the classifier, not a person, so the gate treats it as auto-approved.
33 * Read from `classic.UserPromptSubmit`, because `tool.check` and the
34 * `classic.PreToolUse` envelope carry no mode; a mode switched mid-turn is seen
35 * at the next prompt.
36 */
37let permissionMode: string | undefined;
38
39/**
40 * Files the user chose not to be asked about again, by absolute path. Session
41 * wide, not per agent: `tool.check` carries no `agentId`.
42 */
43const remembered = new Set<string>();
44
45/** Per agent, the files require-analysis mode has already refused once, by absolute path. */
46const assessed = new Map<string, Set<string>>();
47
48/** One line naming the file, how many files depend on it and its risk word, with the indexed commit when known. */
49export function headline(risk: FileRisk): string {
50 const line = `${PROMPT} ${risk.path}: ${risk.dependents} dependents · ${tone(risk.level).word} risk`;
51 return risk.asOfCommit === undefined ? line : `${line} (as of ${risk.asOfCommit})`;
52}
53
54/** The dialog's question: the headline, the top dependents when there are any, and the question itself. */
55function question(risk: FileRisk): string {
56 const lines = [headline(risk)];
57 if (risk.topDependents.length > 0) lines.push(`Top dependents: ${risk.topDependents.join(', ')}`);
58 lines.push('Edit it anyway?');
59 return lines.join('\n');
60}
61
62/** The refusal the model reads when the user declines or dismisses the dialog, with what they typed under "Other". */
63function declined(risk: FileRisk, said?: string): string {
64 const words = said === undefined ? '' : ` The user said: "${said}"`;
65 return `The user declined this edit to ${risk.path} (${risk.dependents} dependents, ${tone(risk.level).word} risk). Ask before trying a different approach.${words}`;
66}
67
68/** The refusal the model reads before its first edit to a file it has not looked into. */
69function refusal(risk: FileRisk): string {
70 const commit = risk.asOfCommit === undefined ? '' : `, as of ${risk.asOfCommit}`;
71 const top = risk.topDependents.length > 0 ? ` Top dependents: ${risk.topDependents.join(', ')}.` : '';
72 const symbols = risk.usedSymbols.length > 0 ? ` Symbols they import from it: ${risk.usedSymbols.join(', ')}.` : '';
73 return `${PROMPT} ${risk.path} has ${risk.dependents} dependents (${tone(risk.level).word} risk${commit}).${top}${symbols} Check that your change keeps these callers working (use code_intel impactAnalysis / traceSymbolUsage on the symbols you change), then retry the edit.`;
74}
75
76function modeOf(value: unknown): Mode {
77 return MODES.find((m) => m === value) ?? 'off';
78}
79
80/**
81 * The risk of an edit by `tool` to `path` (absolute, normalized) when it is at
82 * or above the threshold, else undefined: no access key, no indexed project, a
83 * `Write` that creates the file, a failed lookup, or one slower than
84 * `RISK_DEADLINE_MS`.
85 */
86async function gatedRisk($: EngineInterface, tool: string, path: string, signal: AbortSignal): Promise<FileRisk | undefined> {
87 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return undefined;
88 const root = await projectRoot(absolute('..', path), (p) => $.fs.exists(p));
89 if (root === null) return undefined;
90 if (tool === 'Write' && !(await $.fs.exists(path))) return undefined;
91 const pending = fileRisk(
92 { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a), after: (ms, fn) => $.clock.after(ms, fn) },
93 root,
94 path,
95 );
96 const risk = await withinDeadline((ms, o) => $.clock.sleep(ms, o), pending, RISK_DEADLINE_MS, signal);
97 return risk !== undefined && atLeast(risk.level, threshold) ? risk : undefined;
98}
99
100export function registerImpactGate(on: On, options: PluginOptions): void {
101 resetImpact();
102 resetRiskCache();
103 mode = modeOf(options.impactGate);
104 threshold = options.impactThreshold === 'critical' ? 'critical' : 'high';
105 collectEvidence(mode === 'require-analysis');
106
107 if (mode === 'require-analysis') {
108 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
109 const raw = stringArg(e, 'file_path') ?? stringArg(e, 'notebook_path');
110 if (raw === undefined) return next(e);
111 const path = absolute(raw, await $.session.cwd());
112 const key = agentKey(e);
113 const seen = assessed.get(key) ?? new Set<string>();
114 if (seen.has(path)) return next(e);
115 // The first edit is the one chance: a lookup that misses the deadline or fails lets
116 // it through, and a later refusal would come after the file already changed.
117 assessed.set(key, seen);
118 seen.add(path);
119 const found = await gatedRisk($, String(e.tool), path, next.signal);
120 if (found === undefined || hasEvidence(key, [found.path, path], found.usedSymbols)) return next(e);
121 return { deny: refusal(found) };
122 });
123 return;
124 }
125 if (mode !== 'dialog' && mode !== 'native') return;
126
127 on('classic.UserPromptSubmit', async (_$, e, next) => {
128 permissionMode = e.permission_mode;
129 return next(e);
130 });
131
132 // Every return is `next(e)` or a fixed `ask` or `deny`: core's decision is read from a
133 // `$.tool.check` query, never from what `next` resolves to, so the gate cannot turn it into an allow.
134 on('tool.check', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
135 // A query (another plugin's `$.tool.check`) has no call id and never opens a dialog or toast.
136 if (e.tool_use_id === undefined) return next(e);
137 const input = typeof e.input === 'object' && e.input !== null ? e.input : undefined;
138 const raw = input === undefined ? undefined : (stringArg(input, 'file_path') ?? stringArg(input, 'notebook_path'));
139 if (raw === undefined) return next(e);
140 // The query skips this hook and asks no settings PreToolUse hook, so it is the rules' and the mode's decision.
141 const { decision } = await $.tool.check({ tool: e.tool, input: e.input });
142 if (decision === 'deny') return next(e);
143 // In auto mode an ask goes to the classifier: no one would see it, so the gate asks itself.
144 const classified = decision === 'ask' && permissionMode === 'auto';
145 // An edit headed to the permission prompt needs no downgrade (native) and gets only a toast (dialog).
146 if (mode === 'native' && decision === 'ask' && !classified) return next(e);
147 // A -p or SDK run has no one to answer: a downgrade there would turn the edit into a refusal.
148 if (mode === 'native' && decision === 'allow' && (await $.session.surfaces()).length === 0) return next(e);
149 const path = absolute(raw, await $.session.cwd());
150 const asks = (mode === 'dialog' && decision === 'allow') || classified;
151 if (asks && remembered.has(path)) return next(e);
152 const risk = await gatedRisk($, String(e.tool), path, next.signal);
153 if (risk === undefined) return next(e);
154
155 const line = headline(risk);
156 if (!asks) {
157 $.ui.toast(line, { timeoutMs: TOAST_MS });
158 // Dialog mode leaves a prompted edit at its prompt; native mode sends an auto-approved one there.
159 if (mode !== 'native') return next(e);
160 return { decision: 'ask', reason: `${risk.dependents} dependents, ${tone(risk.level).word} risk` };
161 }
162 // A -p or SDK run has no one to ask: no one would see the dialog, so fail open.
163 if ((await $.session.surfaces()).length === 0) {
164 $.ui.log(`${line} (could not ask, edit allowed)`);
165 return next(e);
166 }
167 let answer = CANCEL;
168 try {
169 answer = await $.ui.ask(question(risk), [PROCEED, PROCEED_REMEMBER, CANCEL]);
170 } catch {
171 // With someone to ask, a rejection is the dialog dismissed (Esc), which refuses.
172 }
173 // Proceed keeps core's decision, so in auto mode the classifier still decides: the gate only adds a check.
174 if (answer === PROCEED) return next(e);
175 if (answer === PROCEED_REMEMBER) {
176 remembered.add(path);
177 return next(e);
178 }
179 return { decision: 'deny', reason: declined(risk, answer === CANCEL ? undefined : answer) };
180 });
181}
182
183/** Forgets every file the user chose not to be asked about, for a new conversation (`/clear`, `/resume`, `/branch`). */
184export function resetImpact(): void {
185 permissionMode = undefined;
186 remembered.clear();
187 assessed.clear();
188}
189
190/** Drops what the subagent `agentId` has been refused and looked at, when its run ends. */
191export function forgetAgentImpact(agentId: string): void {
192 assessed.delete(agentId);
193 forgetAgentEvidence(agentId);
194}
195hooks/nudge.ts 71 lines1import type { On } from 'claude-code';
2import { background, noteSearch, save, showsAdoption } from './adoption';
3import { agentKey, hasNudgeLeft, MAIN, spendNudge, usedCodeIntelThisTurn } from './budget';
4import { searchTarget } from './classify';
5import { isConfigured, projectRoot } from './lib';
6
7/** Added to the model's context when a session or a subagent starts. */
8export const SESSION_TEXT =
9 'You have access to the code_intel source code intelligence tool, this should be your preferred tool for searching or navigating the code base (finding definitions or references, impact analysis, architecture details, etc.). Other search tools (e.g. grep, glob, awk, rg) should be used for literal text search or as a fallback.';
10
11/** Added to the model's context with a search tool call's result. */
12export const REMINDER_TEXT =
13 'Use the code_intel tool before other tools for searching or navigating the codebase. Other search tools (e.g. grep, glob, awk, rg) should be used for literal text search or as a fallback.';
14
15type WithContext = { additionalContext?: string[] };
16
17/** `result` with `text` appended to its context, whatever decision it already carries. */
18function withContext<R extends WithContext>(result: R, text: string): R {
19 return { ...result, additionalContext: [...(result.additionalContext ?? []), text] };
20}
21
22export function registerNudges(on: On): void {
23 on('classic.SessionStart', async ($, e, next) => {
24 const r = await next(e);
25 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return r;
26 return withContext(r, SESSION_TEXT);
27 });
28
29 on('classic.SubagentStart', async ($, e, next) => {
30 const r = await next(e);
31 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return r;
32 return withContext(r, SESSION_TEXT);
33 });
34
35 // On `tool.call`, not `classic.PreToolUse`: adding context means reading what `next` resolved to,
36 // and a hook on a permission event has to return that unread.
37 on('tool.call', { tool: /^(Grep|Glob|Bash)$/ }, async ($, e, next) => {
38 const { isSearch, symbolLike, path } = searchTarget(e);
39 const r = await next(e);
40 if (r.deny !== undefined) return r;
41 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return r;
42 // One walk for constellation.json per call, shared by the count and the reminder.
43 let walk: Promise<boolean> | undefined;
44 const inProject = (): Promise<boolean> =>
45 (walk ??= (async () => (await projectRoot(await $.session.cwd(), (p) => $.fs.exists(p), path)) !== null)());
46 // Counted only where code_intel could have answered: the agent's own search, with a key, inside an indexed project.
47 // It runs in the background, so the search's answer waits on neither the walk nor the store.
48 if (isSearch && next.origin.plugin === 'engine') {
49 const isMain = agentKey(e) === MAIN;
50 background(async () => {
51 if (!(await inProject())) return;
52 const counted = noteSearch(isMain, symbolLike);
53 // A literal search changes nothing the spinner shows.
54 if (symbolLike && showsAdoption()) $.ui.invalidate('ui.render');
55 await save(counted, {
56 now: () => $.clock.now(),
57 sessionId: () => $.session.id(),
58 get: (k) => $.store.get(k),
59 set: (k, entry) => $.store.set(k, entry),
60 keys: () => $.store.keys(),
61 del: (k) => $.store.delete(k),
62 });
63 });
64 }
65 if (!symbolLike || usedCodeIntelThisTurn(e) || !hasNudgeLeft(e)) return r;
66 if (!(await inProject())) return r;
67 if (!spendNudge(e)) return r;
68 return { ...r, context: [...(r.context ?? []), REMINDER_TEXT] };
69 });
70}
71hooks/onboarding.ts 824 lines1import type {
2 ElementTable,
3 EngineInterface,
4 On,
5 PluginOptions,
6 ProcessRunInit,
7 ProcessRunResult,
8 ProcessSpawnChunk,
9 ProcessSpawnRequest,
10 RenderElement,
11} from 'claude-code';
12import { freshnessBand, freshnessLine } from './freshness';
13import { absolute, canDraw, codeIntel, gitRoot, isRecord, parseToolText, projectRoot, relativeTo } from './lib';
14import type { CodeIntelEnvelope, McpPort } from './lib';
15import { PROMPT, badge, buttonRow, forTheme, onboarding, scheme } from './theme';
16import type { Scheme, Tone } from './theme';
17
18/** The access key format the CLI writes: `ak:` and 32 hex digits. */
19export const KEY_PATTERN = /^ak:[0-9a-f]{32}$/i;
20
21/** What `constellation index` prints when the project ID is unknown to Constellation. */
22export const NOT_REGISTERED_OUTPUT = /Project not registered|PROJECT_NOT_REGISTERED/;
23
24/** How long a theme read serves draws: a running CLI draws the band again for each line it prints. */
25const THEME_MS = 1000;
26
27/** Where a project is created and its ID found. */
28const WEB_APP = 'https://app.constellationdev.io';
29
30/** How long the stored key read-back may take: a profile that prompts must not hold up the session. */
31const READ_BACK_MS = 5000;
32
33/** Where Windows keeps `reg`: by absolute path, so a `reg` in the working directory is never run. */
34const REG = 'C:\\Windows\\System32\\reg.exe';
35
36/** Prints the shell's PATH, then where the CLI is: the PATH line is marked, so it never reads as the CLI's path. */
37export const CLI_LOOKUP = `printf 'PATH=%s\\n' "$PATH"; command -v constellation`;
38
39/** How the CLI is installed, and where the docs say so. */
40const INSTALL_COMMAND = 'npm i -g @constellationdev/cli';
41const INSTALL_DOCS = 'https://docs.constellationdev.io/cli/#installation';
42
43/** What a sign-in that stored a new key says, before the reload. */
44const CONNECTED = '✦ Constellation connected';
45const CONNECTED_NO_PROJECT = '✦ Constellation connected. Run constellation init in this repo to set it up';
46
47/** A failed sign-in's line where the CLI said nothing, and the sign-in step where no `/bin/sh` runs (Windows). */
48const AUTH_IN_TERMINAL = 'Run `constellation auth` in a terminal';
49
50/** An index press where no `constellation.json` sits at or above the session's directory, and the index step where no `/bin/sh` runs. */
51const INDEX_IN_PROJECT = 'Run `constellation index` in a terminal, in the project directory';
52
53/** The band's states. The first that holds wins, in this order. */
54export type OnboardingState = 'not-set-up' | 'sign-in-again' | 'no-project' | 'not-registered' | 'not-indexed' | 'working';
55
56/** The CLI run in progress. */
57export type Task = 'auth' | 'index';
58
59/** What the band's state is decided from. */
60export type OnboardingFacts = {
61 /** The session's `CONSTELLATION_ACCESS_KEY` starts with `ak:`. */
62 configured: boolean;
63 /** A key the CLI stored was read back. */
64 stored?: boolean;
65 /** The error code of the latest code_intel answer. */
66 code?: string;
67 /** The project roots `CWD_NOT_INDEXED` found under the git root. */
68 candidates?: readonly string[];
69 /** The CLI's own output said the project is not registered. */
70 notRegistered?: boolean;
71 /** `constellation auth` or `constellation index` is running. */
72 running?: boolean;
73};
74
75/** The band's state for `facts`: the first row that holds, else undefined (nothing to show). */
76export function onboardingState(facts: OnboardingFacts): OnboardingState | undefined {
77 if (!facts.configured && facts.stored !== true) return 'not-set-up';
78 if (facts.code === 'AUTH_ERROR') return 'sign-in-again';
79 if (facts.code === 'CWD_NOT_INDEXED' && (facts.candidates ?? []).length === 0) return 'no-project';
80 if (facts.code === 'PROJECT_NOT_REGISTERED' || facts.notRegistered === true) return 'not-registered';
81 if (facts.code === 'PROJECT_NOT_INDEXED') return 'not-indexed';
82 if (facts.running === true) return 'working';
83 return undefined;
84}
85
86/**
87 * The last key in `output` that matches `KEY_PATTERN`, else undefined. Each
88 * line's last word counts, which covers `printenv` (the value alone, between
89 * whatever a profile prints) and `reg query`
90 * (`CONSTELLATION_ACCESS_KEY REG_SZ ak:...`).
91 */
92export function parseStoredKey(output: string): string | undefined {
93 let found: string | undefined;
94 for (const line of output.split('\n')) {
95 const token = line.trim().split(/\s+/).pop() ?? '';
96 if (KEY_PATTERN.test(token)) found = token;
97 }
98 return found;
99}
100
101/** The last line of `output` that is an absolute POSIX path, else undefined. */
102export function parseCliPath(output: string): string | undefined {
103 let found: string | undefined;
104 for (const raw of output.split('\n')) {
105 const line = raw.trim();
106 if (line.startsWith('/')) found = line;
107 }
108 return found;
109}
110
111/**
112 * The last complete, non-empty line of `buffer`, else undefined. Text after
113 * the last newline is still being written, so it waits. A carriage return
114 * redraws a line in place, so what follows the last one in a line is kept.
115 */
116export function lastLine(buffer: string): string | undefined {
117 const lines = buffer.split('\n').slice(0, -1);
118 for (let i = lines.length - 1; i >= 0; i--) {
119 const shown = (lines[i] ?? '')
120 .split('\r')
121 .map((part) => part.trim())
122 .filter((part) => part !== '')
123 .pop();
124 if (shown !== undefined) return shown;
125 }
126 return undefined;
127}
128
129/** The http(s) URL on or after the line that says "open this URL manually", else undefined. */
130export function manualUrl(buffer: string): string | undefined {
131 const at = buffer.indexOf('open this URL manually');
132 if (at < 0) return undefined;
133 const from = buffer.lastIndexOf('\n', at) + 1;
134 return /https?:\/\/[^\s'"<>]+/.exec(buffer.slice(from))?.[0];
135}
136
137/** Runs a command, as `$.process.run`. */
138export type Run = (argv: readonly string[], init: ProcessRunInit) => Promise<Pick<ProcessRunResult, 'exitCode' | 'stdout'>>;
139
140/**
141 * What the onboarding calls. `claude plugin validate` follows `$` only into
142 * functions of the same file, so a handler spells each call in a closure.
143 */
144export type OnboardingPorts = {
145 run: Run;
146 exists: (path: string) => Promise<boolean>;
147 /** Reads a file's text, as `$.fs.read`. */
148 read: (path: string) => Promise<string>;
149 /** Sets `CONSTELLATION_ACCESS_KEY` for the session and what it starts. */
150 envSet: (key: string) => Promise<void>;
151 after: (ms: number, fn: () => void) => void;
152 mcp: McpPort;
153 /** Runs `/reload-plugins`, which restarts the MCP server with the session's environment. */
154 reload: () => Promise<unknown>;
155 toast: (text: string) => void;
156 /** Draws the band again. */
157 invalidate: () => void;
158 /** Writes `text` as a transcript line when no surface of the session draws the band. */
159 log: (text: string) => Promise<void>;
160};
161
162/** How a state change is told: a redraw, and a line where nothing draws. */
163type Notify = Pick<OnboardingPorts, 'invalidate'> & Partial<Pick<OnboardingPorts, 'log'>>;
164
165/** The one line a session that cannot draw the band gets, with the command to run. */
166const LOG_LINE: Readonly<Record<Exclude<OnboardingState, 'working'>, string>> = {
167 'not-set-up': `${PROMPT} not signed in: run constellation auth`,
168 'sign-in-again': `${PROMPT} sign-in failed: run constellation auth`,
169 'no-project': `${PROMPT} not set up for this project: run constellation init`,
170 'not-registered': `${PROMPT} project not registered: check projectId in constellation.json`,
171 'not-indexed': `${PROMPT} project not indexed: run constellation index`,
172};
173
174/** The states an error put up, which a later success takes down. */
175const ERROR_STATES: ReadonlySet<OnboardingState> = new Set(['sign-in-again', 'not-indexed', 'not-registered', 'no-project']);
176
177/**
178 * Module state, cleared by `registerOnboarding` and `resetOnboarding`.
179 * `generation` changes with each, so work started before a reset can tell.
180 */
181let generation = 0;
182let state: OnboardingState | undefined;
183/** The CLI run in progress; it ends with its child, whatever resets meanwhile. */
184let running: Task | undefined;
185/** What the running or last CLI run printed. */
186let buffer = '';
187/** The line under the state's headline once a CLI run has ended, such as its last output line. */
188let detail: string | undefined;
189/** The last button press found no CLI: the band says how to install it. */
190let cliMissing = false;
191/** A session that cannot draw gets one line. */
192let logged = false;
193/** Whether a POSIX `/bin/sh` is present, once asked: Windows has none, and the band starts no CLI there. */
194let shell: boolean | undefined;
195/** The repository's stored dismissal, by its store key: only this plugin writes it, so one read serves every draw. */
196let dismissal: { key: string; value: unknown } | undefined;
197/** Claude Code's theme and when it was read: `/theme` raises no event, so a read older than THEME_MS is read again. */
198let theme: { value: unknown; readAt: number } | undefined;
199/** The git root (else the working directory) the session started in: dismissals are kept per repository. */
200let repoRoot: string | undefined;
201
202/** Draws the band again. */
203function redraw(notify: Pick<OnboardingPorts, 'invalidate'>): void {
204 try {
205 notify.invalidate();
206 } catch {
207 // Refused while a band draws: the next draw reads the new state.
208 }
209}
210
211/** Puts up `next`, with `line` under its headline, and tells the band, unless a CLI run holds the band. */
212function apply(next: OnboardingState | undefined, notify: Notify, line?: string): void {
213 if (running !== undefined || next === state) return;
214 state = next;
215 detail = line;
216 redraw(notify);
217 if (next === undefined || next === 'working' || logged || notify.log === undefined) return;
218 logged = true;
219 notify.log(LOG_LINE[next]).catch(() => undefined);
220}
221
222/**
223 * Starts a CLI run: the band shows it as working, and nothing else writes the
224 * band until `endTask`. Returns the generation, which a run compares once its
225 * child exits to tell whether a reset came meanwhile, or undefined (refused)
226 * while another run is live, so sign-in and indexing never overlap.
227 */
228export function startTask(task: Task, notify: Pick<OnboardingPorts, 'invalidate'>): number | undefined {
229 if (running !== undefined) return undefined;
230 buffer = '';
231 apply(onboardingState({ configured: true, running: true }), notify);
232 running = task;
233 return generation;
234}
235
236/** Ends the CLI run, whatever reset came meanwhile. */
237export function endTask(): void {
238 running = undefined;
239}
240
241/**
242 * Records the git root of `cwd` (else `cwd` itself) as the repository whose
243 * dismissal the band reads, and returns the git root, or null outside one.
244 */
245export async function rememberRepo(cwd: string, exists: (path: string) => Promise<boolean>): Promise<string | null> {
246 const root = await gitRoot(cwd, exists);
247 repoRoot = root ?? cwd;
248 return root;
249}
250
251/** Whether a POSIX `/bin/sh` is present (Windows has none), asked once per load. */
252async function hasShell(exists: (path: string) => Promise<boolean>): Promise<boolean> {
253 shell ??= await exists('/bin/sh');
254 return shell;
255}
256
257/**
258 * Reads back the key the CLI stored, or undefined: never logged or shown.
259 * A login `/bin/sh` reads `~/.profile`, where the CLI writes the key, whatever
260 * the person's shell; the empty override keeps an inherited key from masking
261 * it. A run that fails or runs past the timeout counts as no key. Only where
262 * there is no `/bin/sh` (Windows: the engine exposes no OS) is the registry read.
263 */
264export async function storedKey(ports: Pick<OnboardingPorts, 'run' | 'exists'>): Promise<string | undefined> {
265 try {
266 const r = (await hasShell(ports.exists))
267 ? await ports.run(['/bin/sh', '-lc', 'printenv CONSTELLATION_ACCESS_KEY'], {
268 env: { CONSTELLATION_ACCESS_KEY: '' },
269 timeoutMs: READ_BACK_MS,
270 })
271 : await ports.run([REG, 'query', 'HKCU\\Environment', '/v', 'CONSTELLATION_ACCESS_KEY'], { timeoutMs: READ_BACK_MS });
272 return r.exitCode === 0 ? parseStoredKey(r.stdout) : undefined;
273 } catch {
274 return undefined;
275 }
276}
277
278/** The PATH a `CLI_LOOKUP` run printed, else undefined. */
279export function parseShellPath(output: string): string | undefined {
280 let found: string | undefined;
281 for (const line of output.split('\n')) {
282 if (line.startsWith('PATH=')) found = line.slice('PATH='.length).trim();
283 }
284 return found;
285}
286
287/** Where the CLI is (undefined when not found), and what its child's environment sets over the host's. */
288export type CliLookup = { path: string | undefined; env: Record<string, string> };
289
290/**
291 * Finds the CLI the way a terminal would: a plain `/bin/sh` looks on the
292 * host's PATH, then a login one on the PATH the profile sets. A CLI only the
293 * login shell finds runs with that PATH before the host's, so its
294 * `#!/usr/bin/env node` finds the same node. A path inside one of `untrusted`
295 * (the session's directory and its git root) is a repository's file and
296 * counts as not found. Never rejects.
297 */
298export async function cliPath(run: Run, untrusted: readonly string[]): Promise<CliLookup> {
299 const look = async (flag: '-c' | '-lc') => {
300 const r = await run(['/bin/sh', flag, CLI_LOOKUP], { timeoutMs: READ_BACK_MS });
301 const path = r.exitCode === 0 ? parseCliPath(r.stdout) : undefined;
302 const trusted = path !== undefined && untrusted.every((dir) => relativeTo(dir, absolute(path, dir)) === null);
303 return { path: trusted ? path : undefined, PATH: parseShellPath(r.stdout) };
304 };
305 try {
306 const host = await look('-c');
307 if (host.path !== undefined) return { path: host.path, env: {} };
308 const login = await look('-lc');
309 if (login.path === undefined) return { path: undefined, env: {} };
310 return { path: login.path, env: { PATH: [login.PATH, host.PATH].filter((p) => p !== undefined && p !== '').join(':') } };
311 } catch {
312 return { path: undefined, env: {} };
313 }
314}
315
316/** A stored key found at session start, for the caller to set before the session goes on. */
317export type FoundKey = { key: string; projectRoot: string | null };
318
319/**
320 * At session start with no key set: outside a git repository nothing runs. In
321 * one, the stored key is read back; with none the band says not set up, else
322 * the key and the project root (null without a `constellation.json`, which
323 * then gets no ping) return.
324 */
325export async function readStoredKeyAtStart(
326 cwd: string,
327 ports: Pick<OnboardingPorts, 'run' | 'exists'> & Notify,
328): Promise<FoundKey | undefined> {
329 if ((await rememberRepo(cwd, ports.exists)) === null) return undefined;
330 const key = await storedKey(ports);
331 if (key === undefined) {
332 apply(onboardingState({ configured: false, stored: false }), ports);
333 return undefined;
334 }
335 return { key, projectRoot: await projectRoot(cwd, ports.exists) };
336}
337
338/**
339 * Takes the band down, shows `toast` when given, and reloads the plugins on a
340 * timer. The reload unloads this module, so nothing is chained after it.
341 */
342function reloadPlugins(ports: OnboardingPorts, toast?: string): void {
343 state = undefined;
344 detail = undefined;
345 buffer = '';
346 ports.invalidate();
347 if (toast !== undefined) ports.toast(toast);
348 // `$.command.run` is refused inside a hook the turn waits on, so it always goes through the timer.
349 ports.after(0, () => {
350 ports.reload().catch(() => undefined);
351 });
352}
353
354/**
355 * Sets `key` for the session, shows `toast`, then reloads the plugins so the
356 * MCP server starts with the key. A reload unloads the module and kills a
357 * running child, so call it once a CLI run has ended (`endTask`).
358 */
359export async function connect(key: string, ports: OnboardingPorts, toast: string): Promise<void> {
360 await ports.envSet(key);
361 reloadPlugins(ports, toast);
362}
363
364/** The API a `constellation.json` with no `apiUrl` reaches. */
365const DEFAULT_API = 'https://api.constellationdev.io';
366
367/**
368 * True when the `constellation.json` in `root` leaves the API at the default
369 * (the MCP server sends the access key to the one it names, and takes any
370 * falsy `apiUrl` as the default). A file that cannot be read or parsed counts
371 * as naming another.
372 */
373export async function usesDefaultApi(root: string, read: (path: string) => Promise<string>): Promise<boolean> {
374 try {
375 const config: unknown = JSON.parse(await read(`${root.replace(/\/$/, '')}/constellation.json`));
376 if (!isRecord(config)) return false;
377 return !config.apiUrl || (typeof config.apiUrl === 'string' && config.apiUrl.replace(/\/+$/, '') === DEFAULT_API);
378 } catch {
379 return false;
380 }
381}
382
383/**
384 * Pings the project at `root`, unless its `constellation.json` names an API
385 * other than the default, which a repository chooses, so it is not pinged
386 * unasked. Undefined when skipped or when anything throws. Never rejects.
387 */
388export async function pingProject(root: string, ports: Pick<OnboardingPorts, 'read' | 'mcp'>): Promise<CodeIntelEnvelope | undefined> {
389 try {
390 if (!(await usesDefaultApi(root, ports.read))) return undefined;
391 return await codeIntel(ports.mcp, 'return await api.ping()', { cwd: root });
392 } catch {
393 return undefined;
394 }
395}
396
397/**
398 * After a stored key was set at session start, in the project at `root`: a
399 * ping (`pingProject`) decides, and `observe` sees its answer first.
400 * `AUTH_ERROR` means the server started before the key was set, so the
401 * plugins reload; `PROJECT_NOT_INDEXED` (also sent for an unregistered
402 * project) puts up the index button. Never rejects.
403 */
404export async function checkConnection(root: string, ports: OnboardingPorts, observe?: (envelope: CodeIntelEnvelope) => void): Promise<void> {
405 try {
406 const envelope = await pingProject(root, ports);
407 if (envelope === undefined) return;
408 try {
409 observe?.(envelope);
410 } catch {
411 // What observes the ping never changes what the ping decides.
412 }
413 const code = envelope.error?.code;
414 if (code === 'AUTH_ERROR') {
415 // No toast: nothing is known to work. The agent's next AUTH_ERROR says sign in again.
416 // A sign-in already running connects when it ends; a reload now would kill its child.
417 if (running === undefined) reloadPlugins(ports);
418 return;
419 }
420 const next = onboardingState({ configured: true, code });
421 if (next !== undefined) apply(next, ports);
422 } catch {
423 // The band stays as it was.
424 }
425}
426
427/**
428 * Reads the agent's own code_intel answer `r`: an error that has a fix puts up
429 * its state, and a success takes down a state an error put up. A run of the
430 * CLI holds the band, and `MCP_UNAVAILABLE` (the server is not connected) and
431 * a `CWD_NOT_INDEXED` that lists project roots (a monorepo) change nothing.
432 * `configured` is whether the session's key starts with `ak:`; with none,
433 * `AUTH_ERROR` says "not signed in": the server then got no key.
434 */
435export function observeCodeIntel(
436 r: { deny?: string; text?: string; isError?: boolean },
437 configured: boolean,
438 invalidate: () => void,
439 log?: (text: string) => Promise<void>,
440): void {
441 if (r.deny !== undefined || running !== undefined) return;
442 const notify: Notify = log === undefined ? { invalidate } : { invalidate, log };
443 const envelope = parseToolText(r.text, r.isError === true);
444 if (envelope.success) {
445 if (state !== undefined && ERROR_STATES.has(state)) apply(undefined, notify);
446 return;
447 }
448 const error = envelope.error;
449 if (error === undefined || error.code === 'MCP_UNAVAILABLE') return;
450 const next = onboardingState({ configured, code: error.code, candidates: error.candidates });
451 if (next !== undefined) apply(next, notify);
452}
453
454/** What the band's buttons call beyond the session start's ports. */
455export type ButtonPorts = OnboardingPorts & {
456 /** Starts a child, as `$.process.spawn`: the loop over its pieces is its life. */
457 spawn: (request: ProcessSpawnRequest) => AsyncIterable<ProcessSpawnChunk>;
458 /** The session's working directory. */
459 cwd: () => Promise<string>;
460};
461
462/** A claimed CLI run: the generation it started in, the CLI it runs and what its environment sets. */
463type Claim = { gen: number; cli: string; env: Record<string, string> };
464
465/**
466 * Claims the band for `task` before anything is awaited, so a second press
467 * is refused at once, then finds the CLI. Undefined when a run is live, when
468 * a reset came during the lookup (the claim is released), or when no CLI is
469 * found: the band then goes back to what it showed and says how to install
470 * it, and nothing is spawned.
471 */
472async function claim(task: Task, ports: ButtonPorts): Promise<Claim | undefined> {
473 const prior = state;
474 const gen = startTask(task, ports);
475 if (gen === undefined) return undefined;
476 const { path, env } = await ports
477 .cwd()
478 .then(async (cwd) => cliPath(ports.run, [cwd, (await gitRoot(cwd, ports.exists)) ?? cwd]))
479 .catch((): CliLookup => ({ path: undefined, env: {} }));
480 if (path !== undefined && generation === gen) {
481 cliMissing = false;
482 return { gen, cli: path, env };
483 }
484 endTask();
485 if (generation === gen) {
486 cliMissing = true;
487 apply(prior, ports);
488 }
489 return undefined;
490}
491
492/**
493 * Reads a CLI run's child to its end, both streams into `buffer`, and draws
494 * the band again when its latest line changes. False when the child could not
495 * start. What it prints after a reset is not kept: the band it was for is gone.
496 */
497async function follow(start: () => AsyncIterable<ProcessSpawnChunk>, gen: number, notify: Pick<OnboardingPorts, 'invalidate'>): Promise<boolean> {
498 try {
499 for await (const { text } of start()) {
500 if (generation !== gen) continue;
501 const was = lastLine(buffer);
502 buffer += text;
503 if (lastLine(buffer) !== was) redraw(notify);
504 }
505 return true;
506 } catch {
507 return false;
508 }
509}
510
511/**
512 * The Sign in button: runs `constellation auth` with the inherited key
513 * emptied, so the CLI neither reuses nor asks to replace it, then reads the
514 * stored key back. A new key connects; anything else (the exit code is not
515 * read) says the sign-in failed. The run holds the band until that is
516 * decided, and the plugins reload only after the child has exited, since a
517 * reload kills it. A run that outlives a reset acts on nothing.
518 */
519export async function startSignIn(ports: ButtonPorts): Promise<void> {
520 const claimed = await claim('auth', ports);
521 if (claimed === undefined) return;
522 const { gen, cli, env } = claimed;
523 const before = await storedKey(ports);
524 if (generation !== gen) {
525 endTask();
526 return;
527 }
528 void (async () => {
529 let after: string | undefined;
530 let root: string | null | undefined;
531 try {
532 const started = await follow(
533 // Never through a login shell: its profile would export the stored key again over the empty one.
534 () => ports.spawn({ argv: [cli, 'auth'], env: { ...env, CONSTELLATION_ACCESS_KEY: '', NO_COLOR: '1' } }),
535 gen,
536 ports,
537 );
538 if (started && generation === gen) after = await storedKey(ports);
539 if (after !== undefined && after !== before) {
540 root = await ports
541 .cwd()
542 .then((cwd) => projectRoot(cwd, ports.exists))
543 .catch(() => undefined);
544 }
545 } finally {
546 endTask();
547 }
548 if (generation !== gen) return;
549 if (after !== undefined && after !== before) {
550 try {
551 await connect(after, ports, root === null ? CONNECTED_NO_PROJECT : CONNECTED);
552 return;
553 } catch {
554 // The key could not be set: the sign-in failed.
555 }
556 }
557 apply('sign-in-again', ports, lastLine(buffer) ?? AUTH_IN_TERMINAL);
558 })();
559}
560
561/**
562 * The Index button: runs `constellation index --wait` in the project root,
563 * then a ping decides (the exit code is not read). Only a successful ping
564 * takes the band down. An error with a state of its own puts it up (a
565 * rejected key says sign in again; not registered when the CLI said so);
566 * anything else stays not indexed. The run holds the band until the ping
567 * answers. A run that outlives a reset acts on nothing.
568 */
569export async function startIndex(ports: ButtonPorts): Promise<void> {
570 const prior = state;
571 const claimed = await claim('index', ports);
572 if (claimed === undefined) return;
573 const { gen, cli, env } = claimed;
574 const root = await ports
575 .cwd()
576 .then((cwd) => projectRoot(cwd, ports.exists))
577 .catch(() => null);
578 if (root === null || generation !== gen) {
579 endTask();
580 if (generation === gen) apply(prior, ports, INDEX_IN_PROJECT);
581 return;
582 }
583 void (async () => {
584 let ping: CodeIntelEnvelope | undefined;
585 try {
586 await follow(() => ports.spawn({ argv: [cli, 'index', '--wait'], cwd: root, env: { ...env, NO_COLOR: '1' } }), gen, ports);
587 if (generation === gen) ping = await codeIntel(ports.mcp, 'return await api.ping()', { cwd: root });
588 } finally {
589 endTask();
590 }
591 if (generation !== gen) return;
592 if (ping?.success === true) {
593 buffer = '';
594 apply(undefined, ports);
595 ports.toast('✦ Constellation indexed this project');
596 return;
597 }
598 const code = ping?.error?.code;
599 apply(onboardingState({ configured: true, code, notRegistered: NOT_REGISTERED_OUTPUT.test(buffer) }) ?? 'not-indexed', ports, lastLine(buffer));
600 })();
601}
602
603/** The second spelling of the ports, for the band's buttons; the first is the session start's in `command.ts`. */
604function portsOf($: EngineInterface): ButtonPorts {
605 return {
606 spawn: (request) => $.process.spawn(request),
607 cwd: () => $.session.cwd(),
608 run: (argv, init) => $.process.run(argv, init),
609 exists: (p) => $.fs.exists(p),
610 read: (p) => $.fs.read(p),
611 envSet: (key) => $.env.set('CONSTELLATION_ACCESS_KEY', key),
612 after: (ms, fn) => {
613 $.clock.after(ms, fn);
614 },
615 mcp: { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
616 reload: () => $.command.run({ command: 'reload-plugins' }),
617 toast: (text) => $.ui.toast(text),
618 invalidate: () => $.ui.invalidate('ui.render'),
619 log: async (text) => {
620 if (!canDraw(await $.session.surfaces())) $.ui.log(text);
621 },
622 };
623}
624
625/** The store key of a repository's dismissed state. */
626function dismissalKey(repo: string): string {
627 return `onboarding-dismissed:${repo}`;
628}
629
630const TONE: Readonly<Record<OnboardingState, Tone>> = {
631 'not-set-up': onboarding.notSetUp,
632 'sign-in-again': onboarding.failed,
633 'no-project': onboarding.notSetUp,
634 'not-registered': onboarding.failed,
635 'not-indexed': onboarding.notSetUp,
636 working: onboarding.pending,
637};
638
639const HEADLINE: Readonly<Record<Exclude<OnboardingState, 'working'>, string>> = {
640 'not-set-up': "Constellation isn't signed in",
641 'sign-in-again': 'Constellation sign-in failed',
642 'no-project': 'Not set up for this project',
643 'not-registered': "This project isn't registered with Constellation",
644 'not-indexed': "This project isn't indexed yet",
645};
646
647/** What the band draws and what its buttons do. */
648export type BandView = {
649 state: OnboardingState;
650 tint: Scheme;
651 /** The CLI's latest output line, or what to do next. */
652 detail?: string;
653 /** The CLI run behind `working`. */
654 task?: Task;
655 /** The address a sign-in asks to be opened when no browser opened. */
656 url?: string;
657 /** The last press found no CLI: how to install it shows above the buttons. */
658 cliMissing?: boolean;
659 /** No `/bin/sh` runs here (Windows): the band names the command to run in a terminal instead of a button. */
660 noShell?: boolean;
661 dismiss: () => void;
662 signIn: () => void;
663 index: () => void;
664};
665
666/**
667 * The band for a state: a badge and headline, the steps for states a button
668 * cannot fix (a command to run in a terminal and a link), the CLI's latest
669 * line, then the buttons, Dismiss left and the fix rightmost. A running CLI
670 * gets no buttons.
671 */
672export function band(el: ElementTable, view: BandView): RenderElement {
673 const tone = forTheme(TONE[view.state], view.tint);
674 const headline =
675 view.state === 'working' ? (view.task === 'index' ? 'Indexing this project' : 'Signing in to Constellation') : HEADLINE[view.state];
676 const rows: RenderElement[] = [
677 el.Box({ flexDirection: 'row', columnGap: 1, children: [badge(el, '', tone), el.Text({ bold: true, children: headline })] }),
678 ];
679 if (view.state === 'no-project') {
680 rows.push(el.Text({ children: 'Run constellation init in a terminal, with the project ID from the web app:' }), el.Link({ href: WEB_APP }));
681 }
682 if (view.state === 'not-registered') {
683 rows.push(el.Text({ children: 'Check projectId in constellation.json against the web app:' }), el.Link({ href: WEB_APP }));
684 }
685 if (view.detail !== undefined) rows.push(el.Text({ dimColor: true, wrap: 'truncate', children: view.detail }));
686 if (view.url !== undefined) rows.push(el.Link({ href: view.url, label: view.url }));
687 // A run passes on its own, so it is never dismissed: a dismissal would hide every later run.
688 if (view.state === 'working') return el.Box({ flexDirection: 'column', children: rows });
689 const dismiss = { key: 'onboarding-dismiss', label: 'Dismiss', onPress: view.dismiss };
690 const signsIn = view.state === 'not-set-up' || view.state === 'sign-in-again';
691 if (view.noShell === true && (signsIn || view.state === 'not-indexed')) {
692 rows.push(el.Text({ children: signsIn ? AUTH_IN_TERMINAL : INDEX_IN_PROJECT }));
693 }
694 const action =
695 view.noShell === true
696 ? undefined
697 : signsIn
698 ? { key: 'onboarding-sign-in', label: 'Sign in', onPress: view.signIn }
699 : view.state === 'not-indexed'
700 ? { key: 'onboarding-index', label: 'Index this project', onPress: view.index }
701 : undefined;
702 if (action !== undefined && view.cliMissing === true) {
703 rows.push(el.Text({ children: `Install the Constellation CLI, then press ${action.label} again: ${INSTALL_COMMAND}` }), el.Link({ href: INSTALL_DOCS }));
704 }
705 rows.push(
706 action === undefined
707 ? el.Box({ flexDirection: 'row', justifyContent: 'flex-end', children: [el.Button(dismiss)] })
708 : buttonRow(el, dismiss, action),
709 );
710 return el.Box({ flexDirection: 'column', children: rows });
711}
712
713/**
714 * The color scheme now, with the clock it was read at. The theme row is read at
715 * most once a second (`THEME_MS`), so a running CLI's redraws read it once.
716 */
717async function readTint($: EngineInterface, colors: unknown): Promise<{ now: number; tint: Scheme }> {
718 const now = await $.clock.now();
719 if (theme === undefined || now - theme.readAt > THEME_MS) {
720 try {
721 theme = { value: (await $.config.list()).find((r) => r.key === 'theme')?.value, readAt: now };
722 } catch {
723 // The default colors, until a read succeeds.
724 }
725 }
726 return { now, tint: scheme(colors, theme?.value) };
727}
728
729/** True when a lower mod drew nothing: core's own drawing, or a container with no children. */
730function isEmpty(element: RenderElement): boolean {
731 if (element.type === 'engine') return true;
732 if (element.type !== 'Box' && element.type !== 'Text') return false;
733 const children: unknown = element.props?.children;
734 return children === undefined || (Array.isArray(children) && children.length === 0);
735}
736
737/**
738 * The onboarding band above the prompt. Draws from module state only; a
739 * repository whose stored dismissal equals the current state passes, so a
740 * new, different error shows the band again. The dismissal is read once and
741 * the theme at most once a second, so a running CLI's redraws read neither.
742 */
743export function registerOnboarding(on: On, options: PluginOptions): void {
744 generation += 1;
745 state = undefined;
746 running = undefined;
747 buffer = '';
748 detail = undefined;
749 cliMissing = false;
750 logged = false;
751 repoRoot = undefined;
752 shell = undefined;
753 dismissal = undefined;
754 theme = undefined;
755
756 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
757 const shown = state;
758 if (e.props.hasSurvey) return next(e);
759 if (shown === undefined) {
760 // A fresh index reads nothing; the line draws only while no onboarding state exists.
761 const row = freshnessLine(Date.now());
762 if (row === undefined) return next(e);
763 const { now, tint } = await readTint($, options.colors);
764 const below = await next(e);
765 const el = $.ui.resolve(e);
766 const line = freshnessBand(el, row.view, tint, now);
767 return isEmpty(below) ? line : el.Box({ flexDirection: 'column', children: [line, below] });
768 }
769 const key = dismissalKey(repoRoot ?? (await $.session.cwd()));
770 if (dismissal?.key !== key) {
771 try {
772 dismissal = { key, value: await $.store.get(key) };
773 } catch {
774 // The store is unavailable: the band shows, and the next draw reads again.
775 }
776 }
777 if (dismissal?.key === key && dismissal.value === shown) return next(e);
778 const { tint } = await readTint($, options.colors);
779 const noShell = !(await hasShell((p) => $.fs.exists(p)));
780 // While a run goes, its latest line; then the line it ended on.
781 const line = running === undefined ? detail : lastLine(buffer);
782 // Only complete lines: a URL still being written is not offered.
783 const url = running === 'auth' ? manualUrl(buffer.slice(0, buffer.lastIndexOf('\n') + 1)) : undefined;
784 return band($.ui.resolve(e), {
785 state: shown,
786 tint,
787 ...(line === undefined ? {} : { detail: line }),
788 ...(running === undefined ? {} : { task: running }),
789 ...(url === undefined ? {} : { url }),
790 ...(cliMissing ? { cliMissing } : {}),
791 ...(noShell ? { noShell } : {}),
792 dismiss: async () => {
793 dismissal = { key, value: shown };
794 try {
795 await $.store.set(key, shown);
796 } catch {
797 // Kept for this session only: the next one shows the band again.
798 }
799 portsOf($).invalidate();
800 },
801 signIn: () => startSignIn(portsOf($)),
802 index: () => startIndex(portsOf($)),
803 });
804 });
805}
806
807/**
808 * Starts the band over for a new conversation (`/clear`, `/resume`, `/branch`).
809 * Not set up and no project stay: they describe the process and the
810 * repository, not the conversation. A CLI run still going keeps `running`: its
811 * loop clears it when the child exits, so sign-in and indexing never overlap,
812 * and the changed generation tells it not to act on what it finds.
813 */
814export function resetOnboarding(): void {
815 generation += 1;
816 if (state !== 'not-set-up' && state !== 'no-project') {
817 state = undefined;
818 detail = undefined;
819 }
820 buffer = '';
821 cliMissing = false;
822 logged = false;
823}
824hooks/primpact.ts 183 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code';
2import { type Blast, blastRadius } from './blast';
3import { type GhPrCreate, ghPrCreate } from './classify';
4import { absolute, GIT, GIT_ENV, GIT_TIMEOUT_MS, isConfigured, plural, projectRoot, relativeTo, stringArg, withinDeadline } from './lib';
5
6/** What happens before `gh pr create`. */
7type Mode = 'require' | 'inform' | 'off';
8
9const MODES: readonly Mode[] = ['require', 'inform', 'off'];
10
11/**
12 * How long `gh pr create` waits for the impact lookup before it goes ahead
13 * without the section. The hook's own time limit pauses while `$.mcp.call`
14 * runs, so a slow server would otherwise hold the command for code_intel's
15 * full timeout.
16 */
17export const PR_DEADLINE_MS = 8000;
18
19/** An `## Impact` heading at a line start or after whitespace or a quote (`--body "## Impact`). */
20const HEADING = /(^|[\s"'])##\s+Impact\b/m;
21
22const MOST_AFFECTED = 5;
23const MAX_EXPORTS = 10;
24
25const INSTRUCTION = 'Add this Impact section to the PR body and run gh pr create again.';
26
27let mode: Mode = 'inform';
28
29/** The branches require mode has refused once, keyed by project root and branch. */
30const refused = new Set<string>();
31
32function modeOf(value: unknown): Mode {
33 return MODES.find((m) => m === value) ?? 'inform';
34}
35
36function quoted(names: readonly string[]): string {
37 return names.map((n) => `\`${n}\``).join(', ');
38}
39
40/**
41 * The PR body's `## Impact` section for the `changed` project-relative files
42 * and their blast radius: the counts, the five dependents that import the
43 * most changed files, up to ten of the changed files' symbols other files
44 * import, and where the numbers come from.
45 */
46export function buildImpactSection(changed: string[], blast: Blast): string {
47 const lines = [
48 '## Impact',
49 '',
50 `- **Changed files:** ${changed.length}`,
51 `- **Downstream dependents:** ${blast.dependents.length}${blast.atLimit === undefined ? '' : '+'} (${plural(blast.tests, 'test file')})${
52 blast.skipped === undefined ? '' : `, of the first ${changed.length - blast.skipped} changed files`
53 }`,
54 ];
55 if (blast.dependents.length > 0) {
56 lines.push(`- **Most affected:** ${quoted(blast.dependents.slice(0, MOST_AFFECTED))}`);
57 }
58 if (blast.exports.length > 0) {
59 const more = blast.exports.length - MAX_EXPORTS;
60 const shown = quoted(blast.exports.slice(0, MAX_EXPORTS));
61 lines.push(`- **Exported symbols in use:** ${more > 0 ? `${shown}, +${more} more` : shown}`);
62 }
63 const asOf = blast.asOfCommit === undefined ? '' : ` as of ${blast.asOfCommit}`;
64 lines.push(
65 '',
66 `_From the code graph${asOf}; imports through tsconfig path aliases or export * barrels may be undercounted._`,
67 );
68 return lines.join('\n');
69}
70
71/** True when `line` writes `path` (`> path`, `>> path`, `tee path`): the file is not there yet, or is stale, until the command runs. */
72function writes(line: string, path: string): boolean {
73 const name = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74 return new RegExp(`(?:>>?|\\btee(?:\\s+-a)?)\\s*['"]?${name}['"]?(?=[\\s;&|)]|$)`).test(line);
75}
76
77/**
78 * The remote's default branch: `origin/HEAD`, which only a clone or
79 * `git remote set-head` sets, else `origin/main` or `origin/master` when that
80 * ref exists. Undefined when none does; nothing is guessed.
81 */
82async function defaultBase($: EngineInterface, root: string): Promise<string | undefined> {
83 const head = await $.process.run([...GIT, 'symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: root, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS });
84 const ref = head.stdout.trim();
85 if (head.exitCode === 0 && ref.startsWith('refs/remotes/') && ref.length > 'refs/remotes/'.length) return ref.slice('refs/remotes/'.length);
86 for (const name of ['main', 'master']) {
87 const found = await $.process.run([...GIT, 'rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`], { cwd: root, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS });
88 if (found.exitCode === 0) return `origin/${name}`;
89 }
90 return undefined;
91}
92
93/** The branch checked out in `root`, or undefined when git fails. */
94async function branchOf($: EngineInterface, root: string): Promise<string | undefined> {
95 const head = await $.process.run([...GIT, 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS });
96 const branch = head.stdout.trim();
97 return head.exitCode === 0 && branch !== '' ? branch : undefined;
98}
99
100/**
101 * The impact section for the branch checked out in `root`, against the PR's
102 * base (`origin/<--base>`, else the remote's default branch). Undefined when
103 * a git command fails, nothing changed, or the lookup failed or missed
104 * `PR_DEADLINE_MS`. Rejects when a git command cannot start or times out.
105 */
106async function impactSection($: EngineInterface, pr: GhPrCreate, root: string, signal: AbortSignal): Promise<string | undefined> {
107 const base = pr.base !== undefined && pr.base !== '' ? `origin/${pr.base}` : await defaultBase($, root);
108 if (base === undefined) return undefined;
109 // `-z` gives each path as is, NUL-terminated; without it git quotes unusual names.
110 const diff = await $.process.run([...GIT, 'diff', '--no-ext-diff', '--no-renames', '--name-only', '-z', '--relative', `${base}...HEAD`], { cwd: root, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS });
111 if (diff.exitCode !== 0) return undefined;
112 const changed = diff.stdout.split('\0').filter((path) => path !== '');
113 if (changed.length === 0) return undefined;
114 const blast = await withinDeadline(
115 (ms, o) => $.clock.sleep(ms, o),
116 blastRadius({ connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) }, root, changed, { exports: true }),
117 PR_DEADLINE_MS,
118 signal,
119 );
120 return blast === undefined ? undefined : buildImpactSection(changed, blast);
121}
122
123export function registerPrImpact(on: On, options: PluginOptions): void {
124 mode = modeOf(options.prImpact);
125 refused.clear();
126 if (mode === 'off') return;
127
128 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
129 const raw = stringArg(e, 'command') ?? '';
130 const pr = ghPrCreate(raw);
131 // A PR for another branch or repository is not the checkout's to describe.
132 if (pr === null || pr.head !== undefined || pr.repo !== undefined) return next(e);
133 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return next(e);
134 const session = absolute('.', await $.session.cwd());
135 const cwd = absolute(pr.dir ?? '.', session);
136 // git runs only under the session's directory, never one a `cd` in the command leads out to.
137 if (cwd !== session && relativeTo(session, cwd) === null) return next(e);
138 const root = await projectRoot(cwd, (p) => $.fs.exists(p));
139 if (root === null) return next(e);
140
141 if (mode === 'inform') {
142 // The lookup runs while the command does, so the command's result waits for it as little as possible.
143 // A git command that cannot start or times out shows no section.
144 const pending = impactSection($, pr, root, next.signal).catch(() => undefined);
145 const r = await next(e);
146 if (r.deny !== undefined || r.isError) return r;
147 const section = await pending;
148 if (section !== undefined) $.ui.log(section);
149 return r;
150 }
151
152 try {
153 // The raw line covers inline and heredoc bodies, whatever their quoting.
154 if (HEADING.test(raw) || (pr.body !== undefined && HEADING.test(pr.body))) return next(e);
155 // A body on standard input cannot be read here.
156 if (pr.bodyFile === '-') return next(e);
157 // A body file this command writes is checked through the command line above. A file that cannot be
158 // read has no heading: gh would fail on it too, so the refusal costs nothing.
159 if (pr.bodyFile !== undefined && !writes(raw, pr.bodyFile)) {
160 const text = await $.fs.read(absolute(pr.bodyFile, cwd)).catch(() => '');
161 if (HEADING.test(text)) return next(e);
162 }
163 const branch = await branchOf($, root);
164 if (branch === undefined) return next(e);
165 const key = `${root}\0${branch}`;
166 // Each branch is refused once, so the gate never loops.
167 if (refused.has(key)) return next(e);
168 const section = await impactSection($, pr, root, next.signal);
169 if (section === undefined) return next(e);
170 refused.add(key);
171 return { deny: `${section}\n\n${INSTRUCTION}` };
172 } catch {
173 // A git command that cannot start or times out.
174 return next(e);
175 }
176 });
177}
178
179/** Forgets the branches already refused, for a new conversation (`/clear`, `/resume`, `/branch`). */
180export function resetPrImpact(): void {
181 refused.clear();
182}
183hooks/session.ts 68 lines1import type { On } from 'claude-code';
2import { resetAdoption } from './adoption';
3import { forgetAgentLines, resetAugment } from './augment';
4import { forgetAgentBudget, resetBudgets } from './budget';
5import { resetFreshness } from './freshness';
6import { forgetAgentImpact, resetImpact } from './impact';
7import { isConfigured } from './lib';
8import { resetOnboarding } from './onboarding';
9import { resetPrImpact } from './primpact';
10import { resetRiskCache } from './risk';
11import { resetToolRows } from './toolrows';
12import { resetTurnSummary, summarizeTurn } from './turnsummary';
13
14/**
15 * Session lifecycle for the module's state, in one place: a new conversation
16 * (`/clear`, `/resume`, `/branch`) starts every budget and shown line over, and
17 * a subagent's state is dropped when its run ends. The main loop's answered
18 * turn also gets its impact summary line here.
19 */
20export function registerSession(on: On): void {
21 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async (_$, e, next) => {
22 resetBudgets();
23 resetAugment();
24 resetRiskCache();
25 resetImpact();
26 resetTurnSummary();
27 resetPrImpact();
28 resetToolRows();
29 resetOnboarding();
30 resetAdoption();
31 resetFreshness();
32 return next(e);
33 });
34
35 on('turn.complete', async ($, e, next) => {
36 if (e.agentId !== undefined) {
37 forgetAgentBudget(e.agentId);
38 forgetAgentLines(e.agentId);
39 forgetAgentImpact(e.agentId);
40 return next(e);
41 }
42 if (e.isAborted || e.reason !== 'answer') {
43 resetTurnSummary();
44 return next(e);
45 }
46 const r = await next(e);
47 try {
48 if (!isConfigured(await $.env.get('CONSTELLATION_ACCESS_KEY'))) return r;
49 const line = await summarizeTurn(
50 {
51 exists: (p) => $.fs.exists(p),
52 mcp: { connect: (s) => $.mcp.connect(s), call: (s, t, a) => $.mcp.call(s, t, a) },
53 sleep: (ms, o) => $.clock.sleep(ms, o),
54 },
55 next.signal,
56 );
57 if (line === undefined) return r;
58 // A line another hook beneath already set stays, with this one under it.
59 return { ...r, text: r.text !== undefined && r.text !== e.answer ? `${r.text}\n${line}` : line };
60 } catch {
61 return r;
62 } finally {
63 // Every main-loop turn ends here, so the next one starts with an empty record, whatever happened above.
64 resetTurnSummary();
65 }
66 });
67}
68