SLOPSHOPPER

Constellation

Upgrade Claude from text search to code understanding.

newpanebandspinnerrowsguard
★ 2v1.3.1AGPL-3.0updated 2026-10-05ShiftinBits/constellation-claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · constellation
│ ┃ Constellation ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭─────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ >_CONSTELLATION:// │ ⎿ Read 6 lines │ ┃ │ constellationdev.io │ ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ app ⎿ 3 pass, 1 fail │ ┃ │ ┃ 1: ▸ Status 2: Diagnose 3: Deps 4: Unuse ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ────────────────────────────────────────── │ ┃ ──────────── ✻ Worked for 42s · done 4:20 PM │ ┃ Whether Constellation is reachable and │ ┃ your access key is accepted. › /constellation │ ┃ │ ┃ ✗ error The Constellation MCP server isn't │ ┃ connected │ ┃ │ ┃ undefined: undefined │ ┃ │ ┃ Code MCP_UNAVAILABLE │ ┃ │ ┃ [ Close ] [ Refresh ] │ ┃ 1-6 switch tabs · r refresh · esc close │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Constellation
╭─────────────────────╮ │ >_CONSTELLATION:// │ │ constellationdev.io │ ╰─────────────────────╯ app 1: ▸ Status 2: Diagnose 3: Deps 4: Unused 5: Explore 6 ────────────────────────────────────────────────────── Whether Constellation is reachable and your access key is accepted. ✗ error The Constellation MCP server isn't connected undefined: undefined Code MCP_UNAVAILABLE [ Close ] [ Refresh ] 1-6 switch tabs · r refresh · esc close
README

<img src="https://constellationdev.io/clawd-icon.svg" height="30"> Constellation Plugin for Claude Code

MCP Server License: AGPL-3.0

While Constellation's MCP server provides raw code intelligence capabilities, this plugin enhances your Claude Code experience with:

FeatureBenefit
Slash CommandsQuick access to common workflows
Contextual SkillsClaude automatically loads relevant knowledge — including proactive impact analysis before risky changes
Safety HooksNudges 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

Features

Commands

Execute powerful analysis with simple slash commands:

CommandDescription
/constellation:statusCheck API connectivity and project indexing status
/constellation:diagnoseQuick 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:unusedDiscover orphaned exports and dead code
/constellation:architectureGet a high-level overview of your codebase structure

Skills

Claude automatically activates specialized knowledge based on your questions:

SkillTriggers When You Ask About...
constellation-troubleshootingError codes, connectivity issues, debugging problems
impact-analysisRenaming, 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]

Hooks

Event hooks enable intelligent, transparent assistance. They run in-process inside Claude Code, declared by the plugin's hooks module (hooks/register.ts):

HookEvent (matcher)Behavior
Session Awarenessclassic.SessionStartInjects code_intel MCP tool awareness at session start, including after clear, resume, and compact
Subagent Awarenessclassic.SubagentStartInjects code_intel awareness into spawned subagents (built-ins like Explore/Plan don't inherit project AGENTS.md)
Search Tool Nudgetool.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 Nudgetool.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 guidancetool.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 budgetturn.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 countertool.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 hinttool.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.

Reminder limit

The search reminders are limited so they stay useful instead of repeating on every search:

  • Per session: Claude gets at most 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.
  • Same turn: when 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.
  • Reset: the count starts over after /clear and when a session is resumed or forked.
  • Not limited: the session and subagent awareness text, and the tool description guidance, are not counted.

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.

Search result hint

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.

  • Once per symbol: each symbol gets the hint once per project and per agent (the main conversation and each subagent), and again after /clear, /resume or /branch. Two searches running at once show it once.
  • Never in the way: the lookup starts with the search and has 2.5 seconds after the search finishes, which allows for the MCP server starting up. Past that, or on any error, the search result comes back unchanged. A symbol with no exact match is remembered, and a failed lookup (server down, sign-in needed) is not retried for a minute, so neither slows later searches.
  • Skipped when 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.
  • Not limited by 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.

Adoption counter

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.

  • What counts: each 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).
  • What does not: the plugin's own 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.
  • Structural lookups: the percentage is 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.
  • Where to see it: the Stats tab of /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.
  • What is kept: numbers only, one entry per session per day, in the plugin's own store. They are saved in the background, so a tool's result never waits on the store. Entries older than 30 days are deleted on the first count of a session and when the Stats tab or /constellation stats reads them. The session's counts start over after /clear and when a session is resumed or forked; the daily totals stay.

Mods

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.
  • Layout: the pane opens with the Constellation banner in the CLI's gradient, then the project name with the index's commit and age, the tabs with a one-line description, labeled rows, and a line naming the keys. The header follows the pane's width: the full banner from 72 columns, a smaller boxed header from 23, and one line below that or in the Desktop app.
  • Project picker: run from a folder that holds several Constellation projects (such as a monorepo root), the pane lists them, one per row with when each was indexed, its languages and its file count. Press a row's number (or Enter on the first) to open it. The pick is remembered for that folder across sessions; p (switch project) in the pane's header forgets it and shows the list again.
  • Colors: the 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.
  • Impact gate: the 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 freshness: when the index is behind your checkout or was taken at another commit, a line shows above the prompt, for example ✦ 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.
  • Tool rows: 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.
  • Turn impact summary: the 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.
  • PR impact section: the 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.
  • Spinner counts: the 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.
  • Version floor: Claude Code 2.1.287. 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.
  • Turn it off: disable the plugin from /plugin, start Claude Code with --safe-mode, or set disableAllHooks in your settings.

Data Handling

What the plugin runs, reads, and sends:

ComponentRuns / readsSends
MCP serverStarted 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 resultsQueries (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 metricsRuns after each code_intel call. On by default; set CONSTELLATION_USAGE_METRICS=false to turn it offA 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

Source 22 files
hooks/register.ts 31 lines
1import 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};
31
hooks/adoption.ts 409 lines
1import 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}
409
hooks/augment.ts 165 lines
1import 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}
165
hooks/budget.ts 160 lines
1import 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}
160
hooks/command.ts 1409 lines
1import 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 lines
1import 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}
73
hooks/freshness.ts 295 lines
1import 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}
295
hooks/impact.ts 195 lines
1import 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}
195
hooks/nudge.ts 71 lines
1import 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}
71
hooks/onboarding.ts 824 lines
1import 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}
824
hooks/primpact.ts 183 lines
1import 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}
183
hooks/session.ts 68 lines
1import 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