Verinoda in Claude Code: keeps the code index fresh while Claude edits, checks each edit for names that do not exist, reviews commits, lists what could be…

A function-hooks plugin ("mod") for Claude Code that keeps a Verinoda index fresh while Claude edits, helps the agent use it, and reviews commits. It finds the project (the nearest folder at or above the session's with a .verinoda index; never a home folder or a drive root unless the settings name it) and the CLI (the project's .venv one, else verinoda on PATH) by itself; /config overrides both (root, cli, python) and turns the mascot's motion off (motion).
| feature | how | default | ||||
|---|---|---|---|---|---|---|
| Re-index after edits | Edit / Write / NotebookEdit inside ROOT mark the file; at the end of the main loop's turn one verinoda update --fast runs (subagent turns do not start one). A failed update keeps its files for /verinoda-update. | on | ||||
| Graph state | After --fast the graph is rebuilt in the background; the status line says text fresh · graph pending… until no build holds the lock and no file changed since the latest snapshot, then fresh ✓. A graph left behind with no build coming (files changed outside Claude's edit tools) is named, and /verinoda-update takes it in. Edits made while the build runs wait for it and are indexed when it ends. | on | ||||
| Auto-context (`/verinoda-auto nudge\ | search\ | off`) | On a code question the person types in a session started inside ROOT: nudge attaches an instruction to start with verinoda analyze (nothing runs before the prompt); search runs verinoda query on the prompt (filter syntax taken apart) and attaches its passages. The auto setting (/config, or pluginConfigs in the settings of a scripted session) is the mode a session starts in; a choice made with /verinoda-auto is kept and wins, except in a scripted session (claude -p, the Agent SDK), which does not read what was chosen in a terminal (the store is shared by every session that loads the mod and rewritten by each at its end) and takes the settings as given. The prompt of a claude -p / Agent SDK run counts as the person's. A nudge goes with any code question up to 20,000 characters (a pasted bug report is one); a search is only for prompts up to 2,000. | off | ||
| Check after edits (`/verinoda-check on\ | off`) | After each Edit / Write of a Python, Java or Kotlin file inside ROOT, verinoda check --diff (falling back to the file itself without git) reads the changed lines; names on them that do not exist in the project or its environment are told to the model with the edit's result (as a PostToolUse note), with the nearest real names. The session start warms the checker (its first run in an environment builds a name index, about 90 s); a cached check takes about 3 s, never more than 60 s, and never fails the edit. The pane shows the last check (k toggles). | on | |||
| Review after commits (`/verinoda-guard on\ | off`) | When a Bash or PowerShell command moved the project's HEAD (read before and after), verinoda review --base HEAD~1 runs in the background and a toast gives its risk and findings; a commit made while one runs is reviewed after it. | off | |||
| Where a change belongs (`/verinoda-assist off\ | inject\ | tool\ | full\ | strict, or a list of inject, coupled, tool, prompt, gate`) | Ways to put what verinoda locate and verinoda coupled know (the files a change touches besides the first one an agent finds: what changes together with them in the git history, same-stem partners, same-name twins, imports) in front of the agent, each its own feature. inject adds the located files to a code question (the agent is not asked to call anything). coupled adds to the result of each source file the agent opens (at most 6 files per session) the files that change together with it. tool registers locate and coupled as tools, listed in front of the model (tool.describe with isDeferred: false: a plugin's tool is otherwise behind ToolSearch, which the agent studies never saw it use) and prompt adds a paragraph to the system prompt. gate answers the first search of a task (Grep, Glob, or grep, rg, find in a shell) with the located files instead of running it; the agent searches again if it still wants to. Presets: inject; tool (tool, prompt); full (coupled, tool, prompt); strict (full, gate). A daemon keeps the graph loaded (verinoda locate --daemon, started at session start; the commands the mod runs ask it by themselves); without one, or with a Verinoda that has none, every lookup is a command that loads the graph itself. The assist setting is where a session starts; a choice made with /verinoda-assist is kept and wins. Whether any of this raises the files an agent finds is measured in benchmarks/agent_compare/DESIGN_ASSIST.md (the pre-registration) and the results next to it. | off |
/verinoda-improve [what to look at] pane | A vague request ("make this better") becomes a list the person can recognise their wish in. The command asks Claude to look and change nothing; Claude hands the mod a ranked list through improve_propose; a second Turkish pane (İyileştirme) shows it in three groups (possible problems with file:line evidence, functional improvements, matters of taste) with nothing selected, the seven best ranked first and the rest folded. The person marks each item (u apply, k keep, c check first for a suspected problem), may add items of their own, and sends with g; only then does Claude work, on what was chosen, and report each outcome through improve_report. A suspicion is never drawn as a fact (kanıtsız, tahmin), an item without a decision is never read as rejected, and every change that was not on the list is reported. Nothing of it runs, and neither tool is listed to the model, before the command (or the improveOffer setting: one line in the system prompt that offers the list instead of guessing). A scripted session gets none of it. No Verinoda index is needed and nothing is stored. | command only | ||||
/verinoda-panel pane | In Turkish (technical nouns kept): the index state as glyph + words + colour with the one action that applies (u), the last commit review with its risk band and a 2-line summary (d for the rest), auto-context as a fixed three-way control (1-3) and commit review as a two-way control (r), and a purple Claude mascot with glasses in the room left over, animated like Claude Code's own (it blinks and shuffles its feet; while Verinoda works it reads, a glint sweeping its lenses; hooks/mascot.tsx, drawn by the surface as a Client, so frames repaint only its region; still on surfaces without Client) (hidden inline, below 24 columns or when the content needs the rows). | - |
Two pre-registered studies with a model in the loop:
benchmarks/results/agent-compare-2026-10-02/): offered Verinoda, the agent called it in 6 of 57 sessions; told to start with analyze (what nudge says), it found the same facts as searching by hand at 16 % fewer input tokens, 22 % fewer output tokens and 29 % fewer tool calls; handed query results up front (what search does), it found 5-6 facts fewer: ranked leads anchor it;benchmarks/results/agent-compare-realworld-2026-10-02/): told to start with analyze, it found the same facts (109 against 107 of 113, no measurable difference) at 1.14x input tokens and 0.84x output tokens: one more turn per session, the agent still reading the cited code. The input saving of the small corpora did not replicate.And what freshness itself is worth (benchmarks/results/agent-compare-stale-2026-10-02/, 50 questions about code that changed after the index was built): the fresh index the mod keeps and a stale one found the same facts, and no stale index led the agent into describing removed code (it reads the files on disk). On projects under 300 files analyze refreshes a stale index by itself, so there the mod adds nothing; on the larger ones a stale index cost about a fifth more input tokens than no index, and the fresh one brought that back (significant in one of two runs).
And the check after edits (benchmarks/results/agent-compare-guard-2026-10-02/, 30 coding tasks, 60 real sessions): with it and without it the agent passed every hidden test and left no name that does not exist; the check never fired, because the agent read the code before every edit. It works (a deliberate removed name is caught and the model told), it costs about nothing, and it is on by default as a safety net; it did not change an outcome in this study.
And on a 27,078-file repository (benchmarks/results/agent-compare-big-2026-10-02/, 40 real Home Assistant bug reports, 160 real sessions, twice): the agent with the mod and its nudge, with Graphify's own integration and with nothing found the same files (recall 37.3, 37.0 and 35.8 of 40; no decision reaches a claim, and a second run shows the differences are chance). The nudge reached every session and the agent never used Verinoda (0 of 80 sessions there, and 0 of 126 on seL4, the C microkernel, benchmarks/results/agent-compare-sel4-2026-10-02/, where an index was most likely to matter: 21 real bug reports, each session run three times, recall of 21 = 17.67 with nothing, 17.78 with the mod, no claim). The tools work (a probe asked the agent to use them and they did). An instruction at the moment of the first grep, Graphify's hook, was followed by use of its tool in a third of the seL4 sessions; the nudge, given with the prompt, in none. The mod added nothing to Verinoda's own setup in either study.
So neither mode is a measured win on real code: nudge trades input tokens for output tokens at the same facts, search costs facts. Both stay off until the person turns one on. What the mod adds regardless of the mode is an index that stays fresh while Claude edits, and commit reviews.
The live copy Claude Code hot-reloads is the session's dev-mods folder; this folder is its versioned source. Load it from here in a terminal with claude --plugin-dir <this repository>/claude-mods/verinoda-live.
claude plugin validate <folder>: what the module hooks and calls, and anything the engine would refuse.claude plugin test <folder>: hooks/register.test.ts (re-index and graph watcher) and hooks/features.test.ts (question heuristic, prompt origins, the three modes, commit review, robustness, the pane on terminal and desktop). hooks/improve.test.ts holds the improvement list (the proposed list and its first sight, decisions, the three prompts, reports, the wired command, tools and pane); hooks/improve.ts is its pure part and hooks/improve-pane.tsx its pane and hooks. hooks/assist.test.ts holds the assist features (presets, the tools and their placement, inject, the note on a file read, the gate, the daemon and its fallback, /verinoda-assist); hooks/assist.ts is their pure part..claude-plugin/types/ and a tsconfig.json; then tsc -p <folder>. Both are generated and not kept here.The 2026-10-02 version was reviewed by three independent lenses (API, state and failure paths, Windows and real-use edge cases) with two skeptics per finding; the 13 upheld findings are fixed and covered by tests.
Note: the pane's command is /verinoda-panel, not /verinoda: the Verinoda agent skill (~/.claude/skills/verinoda, installed by verinoda install) owns /verinoda, and a skill of that name takes the slash before a plugin command.
The pane was redesigned from a user-eyes evaluation (a first-time Turkish speaker, an all-day user, a terminal and accessibility specialist), two mascot designs and one synthesized spec; the tests mount it on the terminal and desktop surfaces, check every state's words, the hotkeys shown, and when the mascot appears.
hooks/register.tsx 1269 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AutoMode, CheckInfo, ContextInfo, ImproveState, IndexKind, IndexState, Notice, ReviewInfo } from '../types'
5import {
6 ASSIST_USAGE, assistFeatures, assistPrompt, COUPLED_TOOL, coupledNote, isBlockedSearch, isSourceFile, LOCATE_TOOL, parseAssist,
7 renderLocate, TOOL_SPECS,
8} from './assist'
9import type { Features, Located } from './assist'
10import { IMPROVE_SPECS, initialImprove, reviewPrompt, undoSend } from './improve'
11import { COMMAND as IMPROVE_COMMAND, endOfTurn, improveSections, mod as improveMod, registerImprove } from './improve-pane'
12import { frameRows, MASCOT_HEIGHT, MASCOT_WIDTH } from './mascot'
13import type { MascotProps, Mood } from './mascot'
14
15const PANE = 'verinoda'
16const GRAPH_POLL_MS = 1_000
17const GRAPH_WAIT_MS = 10 * 60_000
18// Unlocked yet behind this long: no build is coming (files changed outside Claude's edit tools).
19const GRAPH_STALL_MS = 15_000
20// Auto-context runs `query` (median 2.2 s, at most 4.5 s on the agent study's corpora), not `analyze` (up to 55 s
21// on this repository): it runs before the prompt is sent, so it must be quick.
22const CONTEXT_TIMEOUT_MS = 15_000
23const CONTEXT_MAX_CHARS = 12_000
24const REVIEW_TIMEOUT_MS = 3 * 60_000
25// The host's longest wait for a process (10 min). `verinoda update` itself waits up to 600 s for the build lock, so
26// the mod never starts one while its own background build holds it (reindex defers to the watcher instead).
27const UPDATE_TIMEOUT_MS = 600_000
28const NOTICE_MS = 6_000
29
30// `update --fast` takes the text in at once and rebuilds the graph in a background `verinoda update`.
31// The graph has caught up when no build holds the lock and no file changed since the latest snapshot
32// (the background build records it); the lock alone misses a build that has not taken it yet.
33// Run with the repo as cwd, `-c` puts the cwd ('') first on sys.path, which would import the repo's own verinoda/
34// when the repo is Verinoda's source; dropped first (`-P` would need Python 3.11, Verinoda supports 3.10).
35const GRAPH_STATE =
36 'import sys; sys.path[:] = [p for p in sys.path if p]; ' +
37 'import json; from pathlib import Path; from verinoda import buildlock, freshness; ' +
38 "r = Path(sys.argv[1]); print(json.dumps({'locked': buildlock.is_locked(r), " +
39 "'behind': freshness.check(r).get('count', 0)}))"
40const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const
41const SHELL_TOOLS = ['Bash', 'PowerShell'] as const
42
43// After each edit of a file `verinoda check` reads (Python, Java, Kotlin), the names the edited lines use that do not
44// exist in the project or its environment are told to the model with the edit's result. A cached check takes about
45// 3 s; the first one in an environment builds a name index (about 90 s on sqlmodel), so the session start warms it
46// on a snippet that is not in the project. The edit never waits longer than CHECK_TIMEOUT_MS for it.
47const CHECKABLE = /\.(py|pyi|java|kt|kts)$/i
48const CHECK_TIMEOUT_MS = 60_000
49const WARM_TIMEOUT_MS = 300_000
50const CHECK_NOTE_MAX = 8
51const WARM_SNIPPET = 'import os\n\n\ndef _verinoda_warm(x):\n return os.path.join(x.name, "a")\n'
52
53// A prompt worth a code search: a question by its mark, its first word or a Turkish question particle. Boundaries
54// are spelled out because \b is ASCII-only and Turkish words end in letters it does not know.
55const END = String.raw`(?=[\s?.,!:;]|$)`
56const QUESTION_START = new RegExp(
57 String.raw`^(how|why|where|what|which|who|when|does|do|is|are|can|could|should|explain|show|find|list|trace|` +
58 String.raw`nasıl|neden|niye|niçin|nerede|nereden|nereye|ne|neler|hangi|kim|kimler|açıkla|göster|bul|listele)` + END,
59 'i',
60)
61const QUESTION_PARTICLE = new RegExp(String.raw`\s(mi|mı|mu|mü|misin|mısın|mudur|müdür)` + END, 'i')
62// Turkish puts its question word anywhere ("Kor Ocağı her tickte ne yapıyor"); so do English how/why/where/which.
63const QUESTION_ANYWHERE = new RegExp(
64 String.raw`(?:^|[\s"'(])(nasıl|neden|niye|niçin|nerede|nereden|nereye|ne|neler|hangi|hangisi|kim|kimler|how|why|where|which)` + END,
65 'i',
66)
67// A shell command that may move HEAD; whether it did is read from HEAD itself, before and after.
68const GIT_WORD = /\bgit\b/
69
70// The assist features (./assist.ts) run `verinoda locate` and `verinoda coupled`. A daemon, when the CLI has one, keeps the
71// graph loaded (loading it takes 3 s on seL4 and about 30 s on Home Assistant) and the commands ask it by themselves; the mod
72// only runs them. (A call of the host's own HTTP has a time limit that a big repository's answer can pass: the first
73// version asked the daemon that way and Home Assistant's answers, 10 to 100 s, were dropped.) A command's limit is ten minutes.
74const LOCATE_TIMEOUT_MS = 300_000
75const DAEMON_START_TIMEOUT_MS = 120_000
76const LOCATE_TEXT_MAX = 4_000 // of a report, what the lookup is given: a command line has a limit
77const LOCATE_ANSWER_MAX = 1_800
78const ANCHORS_MAX = 5
79const COUPLED_BUDGET = 6 // notes about a file's partners, per session: each is a lookup and a few hundred tokens
80const TASK_MIN_CHARS = 40 // a prompt shorter than this is not a task to locate
81const ASSIST_TOOL = (name: string) => `mcp__verinoda-live__${name}`
82
83const CHECKING: IndexState = { kind: 'checking', n: 0, waiting: false, since: null, at: null, error: '' }
84
85// Session state the drawing reads (declared in ../types); settings are also kept in $.store across sessions.
86const status = atom({ plugin: 'verinoda-live', key: 'status' } as const, '')
87const idx = atom({ plugin: 'verinoda-live', key: 'idx' } as const, CHECKING)
88const notice = atom({ plugin: 'verinoda-live', key: 'notice' } as const, null as Notice | null)
89const auto = atom({ plugin: 'verinoda-live', key: 'auto' } as const, 'off' as AutoMode)
90const guard = atom({ plugin: 'verinoda-live', key: 'guard' } as const, false)
91const check = atom({ plugin: 'verinoda-live', key: 'check' } as const, true)
92const lastCheck = atom({ plugin: 'verinoda-live', key: 'lastCheck' } as const, null as CheckInfo | null)
93const lastContext = atom({ plugin: 'verinoda-live', key: 'lastContext' } as const, null as ContextInfo | null)
94const lastReview = atom({ plugin: 'verinoda-live', key: 'lastReview' } as const, null as ReviewInfo | null)
95const reviewing = atom({ plugin: 'verinoda-live', key: 'reviewing' } as const, null as string | null)
96const expanded = atom({ plugin: 'verinoda-live', key: 'expanded' } as const, false)
97const assist = atom({ plugin: 'verinoda-live', key: 'assist' } as const, 'off')
98// the improvement list's state (D175); ./improve-pane.tsx declares the same source, because the scan reads an atom where it is used
99const improve = atom({ plugin: 'verinoda-live', key: 'improve' } as const, initialImprove())
100
101// Where Verinoda is, settled at session start from the plugin's settings (`userConfig`), else found: the project is
102// the nearest folder at or above the session's with a .verinoda index; the CLI is the project's own .venv one if it
103// has one, else `verinoda` on PATH; the Python beside that CLI reads the graph's state.
104const cfg = { cli: 'verinoda', python: 'python', motion: true, auto: 'off' as AutoMode, assist: 'off' }
105
106// Module state: a reload starts it over, which only forgets edits not yet indexed.
107const live = {
108 root: undefined as string | undefined,
109 cwd: '',
110 pending: new Set<string>(),
111 isChecking: true, // the session-start look at the graph has not answered yet
112 isRunning: false,
113 runningCount: 0,
114 isGraphBehind: false,
115 graphSince: null as number | null,
116 graphFiles: 0,
117 stalledBehind: 0, // files the graph is behind on with no build coming; /verinoda-update takes them in
118 isSlow: false,
119 isUnknown: false,
120 failed: null as { n: number; error: string } | null,
121 freshAt: null as number | null,
122 isReviewing: false,
123 reviewAgain: false, // a commit landed while a review ran: review once more when it ends
124 task: '', // the person's latest prompt long enough to be a task: what the gate and the tools locate from
125 reads: [] as string[], // the project's files the model has opened, relative, in order
126 coupledAsked: new Set<string>(),
127 usedLocate: false, // the model called the locate tool for this task
128 gated: false, // the gate has answered the first search of this task
129 daemonStart: undefined as Promise<void> | undefined,
130}
131
132export function norm(p: string): string {
133 return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
134}
135
136// The person's own words: typed at the composer, sent through the Remote Control bridge, or submitted by a plugin
137// as the person's (`asUser`); never a notification, a peer, a schedule or a plugin speaking for itself.
138export function isPersonsPrompt(origin: { kind: string; asUser?: true } | undefined): boolean {
139 if (origin === undefined) return false
140 // `sdk` is `claude -p` and the Agent SDK: the script's own prompt is the person's, there is no one else it could be
141 return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' ||
142 (origin.kind === 'plugin' && origin.asUser === true)
143}
144
145// `max` is what a prompt may be to count: a search takes the prompt as its query, so it stays short (2,000); a nudge
146// searches nothing, and a bug report pasted into the question is still a question about where the code is.
147export const NUDGE_MAX_CHARS = 20_000
148export function looksLikeCodeQuestion(text: string, max = 2_000): boolean {
149 const t = text.trim()
150 if (t.length < 12 || t.length > max || /^[/!#]/.test(t)) return false
151 return t.includes('?') || QUESTION_START.test(t) || QUESTION_PARTICLE.test(t) || QUESTION_ANYWHERE.test(t)
152}
153
154// The nudge is round 2's `verinoda_first` instruction of the agent comparison (benchmarks/agent_compare): with it the
155// agent found the same facts as searching by hand at 16 % fewer input and 29 % fewer tool calls. Nothing runs first.
156export function nudgeBlock(root: string, verinoda: string): string {
157 // the path has no spaces, so it runs unquoted in bash and PowerShell alike
158 return (
159 `[Verinoda auto-context] This project has a Verinoda code index (${root}). For this question, start by running ` +
160 `Verinoda's analyze on it before any other search: ${verinoda} analyze "<the question>" --repo ${root} ` +
161 `(and ${verinoda} query "<names it surfaces>" --repo ${root}); then verify and complete with your own reading ` +
162 'as needed. Use this CLI with --repo: a Verinoda MCP server, if one is connected, may index another folder. ' +
163 'Its claims carry file:line evidence and a status; never state an inference or unknown as fact.'
164 )
165}
166
167export function isCheckable(path: string): boolean {
168 return CHECKABLE.test(path)
169}
170
171type CheckSite = { at?: string; path?: string; expr?: string; name?: string; verdict?: string; message?: string;
172 nearest?: { name?: string }[] }
173
174// A file under the project, relative to it, slashes forward and case kept (it is shown and passed on as spelled).
175function relPath(root: string, file: string): string {
176 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
177 return file.replace(/\\/g, '/').slice(r.length + 1)
178}
179
180function badSites(report: { sites?: CheckSite[] }, rel: string): CheckSite[] {
181 return (report.sites ?? []).filter(s =>
182 (s.verdict === 'absent' || s.verdict === 'mismatch') && norm(s.path ?? '') === norm(rel))
183}
184
185// The note the model reads after an edit: the edited file's sites `verinoda check` found absent or mismatched (an
186// unknown is not checked, so it is never reported), with the nearest real names; undefined when there are none.
187export function checkNote(root: string, file: string, report: { sites?: CheckSite[] }, cli: string): string | undefined {
188 const rel = relPath(root, file)
189 const bad = badSites(report, rel)
190 if (bad.length === 0) return undefined
191 const lines = bad.slice(0, CHECK_NOTE_MAX).map(s => {
192 const near = (s.nearest ?? []).map(n => n.name).filter(Boolean).slice(0, 3)
193 return `- ${s.at ?? rel} ${s.expr ?? s.name ?? ''}: ${s.message ?? s.verdict}${near.length ? `; nearest: ${near.join(', ')}` : ''}`
194 })
195 const more = bad.length > CHECK_NOTE_MAX ? [`(${bad.length - CHECK_NOTE_MAX} more: ${cli} check --diff --repo ${root})`] : []
196 return [
197 `[Verinoda check] The lines you just changed in ${rel} use ` +
198 `${bad.length === 1 ? 'a name that does' : `${bad.length} names that do`} not exist in this project or its ` +
199 'environment (a static check; runtime-made names are never reported):',
200 ...lines,
201 ...more,
202 'Fix them before relying on this code; the nearest real names are listed, and ' +
203 `${cli} api <module or class> --repo ${root} lists what one defines.`,
204 ].join('\n')
205}
206
207// `verinoda query` reads filters in its question (path: file: lang: symbol: is:, /regex/, AND OR NOT); a prompt is
208// plain text, so those spellings are taken apart before it is passed on.
209export function plainQuery(prompt: string): string {
210 return prompt
211 .replace(/(^|[\s(])(-?)(path|file|lang|language|symbol|is):(?=\S)/gi, '$1$2$3 ')
212 .replace(/(^|\s)\/([^/\s][^/]*)\/(?=\s|$)/g, '$1$2')
213 .replace(/(^|\s)(AND|OR|NOT)(?=\s|$)/g, (_, pre: string, op: string) => pre + op.toLowerCase())
214 .replace(/[()]/g, ' ')
215}
216
217export function contextBlock(root: string, retrieved: string): string {
218 const body = retrieved.length > CONTEXT_MAX_CHARS ? `${retrieved.slice(0, CONTEXT_MAX_CHARS)}\n[cut]` : retrieved
219 return (
220 `[Verinoda auto-context] Ranked code passages Verinoda retrieved for this prompt from its index of ${root} ` +
221 '(the index can be behind the working tree). They are leads with file:line locations, not verified answers: ' +
222 'use what helps, check what you rely on.\n\n' +
223 body
224 )
225}
226
227// Work left running after a hook returns (a re-index, the graph watcher, a review): a reload or the end of the
228// session aborts its $ calls, which is no error of the person's, so the rejection is dropped here.
229function background(work: Promise<unknown>): void {
230 work.catch(() => undefined)
231}
232
233function plural(n: number): string {
234 return `${n} file${n === 1 ? '' : 's'}`
235}
236
237function seconds(fromMs: number, toMs: number): number {
238 return Math.round((toMs - fromMs) / 100) / 10
239}
240
241function isInside(path: string, root: string): boolean {
242 const p = norm(path)
243 const r = norm(root)
244 return p === r || p.startsWith(`${r}/`)
245}
246
247// The index state from the module's fields, as the pane reads it and as the status line says it (in English, as the
248// slash commands do). The first matching rule wins: a running update over a failure, a failure over waiting edits.
249export function indexState(): { state: IndexState; line: string } {
250 const base = { waiting: false, since: null, at: live.freshAt, error: '' }
251 const at = (kind: IndexKind, n: number, line: string, extra: Partial<IndexState> = {}) =>
252 ({ state: { ...base, kind, n, ...extra }, line })
253 if (live.root === undefined) return at('noindex', 0, `no .verinoda index at or above ${live.cwd}`)
254 if (live.isRunning) return at('updating', live.runningCount, `updating (${plural(live.runningCount)})…`)
255 if (live.failed !== null) {
256 return at('failed', live.failed.n, `update failed · ${plural(live.failed.n)} kept for /verinoda-update`, { error: live.failed.error })
257 }
258 if (live.pending.size > 0) return at('pending', live.pending.size, `${plural(live.pending.size)} pending`, { waiting: live.isGraphBehind })
259 if (live.isGraphBehind) return at('graph', live.graphFiles, 'text fresh · graph pending…', { since: live.graphSince })
260 if (live.stalledBehind > 0) return at('behind', live.stalledBehind, `graph behind ${plural(live.stalledBehind)} · /verinoda-update`)
261 if (live.isSlow) return at('slow', 0, 'graph build still running (10 min+)')
262 if (live.isUnknown) return at('unknown', 0, 'graph state unknown')
263 if (live.isChecking) return at('checking', 0, 'checking…')
264 return at('fresh', 0, 'fresh ✓')
265}
266
267function publish($: EngineInterface): void {
268 const { state, line } = indexState()
269 $.ui.status(live.root === undefined ? undefined : `Verinoda: ${line}`)
270 // the pane's copy is best effort: a write refused after the module unloaded is dropped
271 update($, status, () => line).catch(() => undefined)
272 update($, idx, () => state).catch(() => undefined)
273}
274
275const MODES: readonly AutoMode[] = ['off', 'nudge', 'search']
276
277export function asMode(value: unknown): AutoMode {
278 if (value === true) return 'nudge' // 0.2.0-dev stored a boolean
279 return MODES.includes(value as AutoMode) ? (value as AutoMode) : 'off'
280}
281
282async function setAuto($: EngineInterface, mode: AutoMode): Promise<void> {
283 await update($, auto, () => mode)
284 await $.store.set('auto', mode)
285}
286
287async function setGuard($: EngineInterface, value: boolean): Promise<void> {
288 await update($, guard, () => value)
289 await $.store.set('guard', value)
290}
291
292async function setCheck($: EngineInterface, value: boolean): Promise<void> {
293 await update($, check, () => value)
294 await $.store.set('check', value)
295}
296
297async function setAssist($: EngineInterface, value: string): Promise<void> {
298 await update($, assist, () => value)
299 await $.store.set('assist', value)
300}
301
302async function features($: EngineInterface): Promise<Features> {
303 return assistFeatures(await read($, assist))
304}
305
306// Auto-context and commit review start off, the check after edits on; what the person chose last is kept in the store.
307// A scripted session (`claude -p`, the Agent SDK) has nobody at the keyboard, so what was chosen there is not read: it
308// takes the settings (`pluginConfigs`) as they are given. (The store is shared by every session that loads the mod and
309// rewritten by each at its end: a choice made once in a terminal would otherwise reach the arms of a study that never
310// asked for it, and did.)
311async function loadSettings($: EngineInterface, isInteractive: boolean): Promise<void> {
312 // the setting (`userConfig.auto`) is where a session starts that has never been asked; a stored choice wins
313 const storedChoice = (key: string) => (isInteractive ? $.store.get(key) : Promise.resolve(undefined))
314 const stored = await storedChoice('auto')
315 const mode = stored === undefined ? cfg.auto : asMode(stored)
316 const isGuard = (await storedChoice('guard')) === true
317 const isCheck = (await storedChoice('check')) !== false
318 const storedAssist = await storedChoice('assist')
319 await update($, auto, () => mode)
320 await update($, guard, () => isGuard)
321 await update($, check, () => isCheck)
322 await update($, assist, () => (typeof storedAssist === 'string' ? storedAssist : cfg.assist))
323}
324
325// `check --diff` reads the lines changed against HEAD (and new files whole); a project without git, or a diff the
326// CLI cannot read, falls back to the edited file whole. Exit 3 (something absent) and 4 (something not checked) are
327// answers too. Whatever happens, the edit itself has already gone through.
328async function runCheck($: EngineInterface, root: string, file: string): Promise<{ sites?: CheckSite[] } | undefined> {
329 for (const argv of [[cfg.cli, 'check', '--diff', '--json', '--repo', root], [cfg.cli, 'check', file, '--json', '--repo', root]]) {
330 try {
331 const { exitCode, stdout } = await $.process.run(argv, { cwd: root, timeoutMs: CHECK_TIMEOUT_MS })
332 if (exitCode !== 0 && exitCode !== 3 && exitCode !== 4) continue
333 const report = JSON.parse(stdout) as { sites?: CheckSite[] }
334 if (typeof report === 'object' && report !== null) return report
335 } catch {
336 return undefined // a timeout or a CLI that will not start: the second command would wait as long
337 }
338 }
339 return undefined
340}
341
342async function checkEdit($: EngineInterface, root: string, file: string): Promise<string | undefined> {
343 const report = await runCheck($, root, file)
344 const note = report === undefined ? undefined : checkNote(root, file, report, cfg.cli)
345 const at = await $.clock.now()
346 const rel = relPath(root, file)
347 const n = report === undefined ? 0 : badSites(report, rel).length
348 await update($, lastCheck, () => ({ file: rel, n, ok: report !== undefined, at }))
349 return note
350}
351
352// The first check in an environment builds its name index; done once at session start, on code outside the project.
353async function warmCheck($: EngineInterface, root: string): Promise<void> {
354 try {
355 await $.process.run([cfg.cli, 'check', '--stdin', '--as', '_verinoda_warm.py', '--json', '--repo', root],
356 { cwd: root, stdin: WARM_SNIPPET, timeoutMs: WARM_TIMEOUT_MS })
357 } catch {
358 // a cold first check is slower, nothing more
359 }
360}
361
362type GraphState = { locked: boolean; behind: number }
363
364async function graphState($: EngineInterface, root: string): Promise<GraphState | undefined> {
365 try {
366 const { exitCode, stdout } = await $.process.run([cfg.python, '-c', GRAPH_STATE, root], { cwd: root })
367 if (exitCode !== 0) return undefined
368 const d = JSON.parse(stdout) as Partial<GraphState>
369 return typeof d.locked === 'boolean' && typeof d.behind === 'number' ? { locked: d.locked, behind: d.behind } : undefined
370 } catch {
371 return undefined
372 }
373}
374
375// One look at the graph (session start, or the pane's "check again"): fresh, a build to watch, or behind.
376async function checkGraph($: EngineInterface): Promise<void> {
377 const root = live.root
378 if (root === undefined || live.isRunning || live.isGraphBehind) return
379 live.isChecking = true
380 live.isUnknown = false
381 live.isSlow = false
382 publish($)
383 const state = await graphState($, root)
384 live.isChecking = false
385 if (state === undefined) live.isUnknown = true
386 else if (state.locked) background(watchGraph($, root, state.behind))
387 else if (state.behind > 0) live.stalledBehind = state.behind
388 else live.freshAt = await $.clock.now()
389 publish($)
390}
391
392// One watcher at a time; a later update while it runs only keeps it going.
393async function watchGraph($: EngineInterface, root: string, files: number): Promise<void> {
394 if (live.isGraphBehind) return
395 live.isGraphBehind = true
396 live.graphFiles = files
397 live.stalledBehind = 0
398 live.isSlow = false
399 live.isUnknown = false
400 const started = await $.clock.now()
401 live.graphSince = started
402 publish($)
403 let idleSince: number | undefined // first poll that saw no build running while behind
404 try {
405 while ((await $.clock.now()) - started < GRAPH_WAIT_MS) {
406 await $.clock.sleep(GRAPH_POLL_MS)
407 if (live.isRunning) {
408 idleSince = undefined
409 continue
410 }
411 const state = await graphState($, root)
412 if (state === undefined) {
413 live.isUnknown = true
414 return
415 }
416 const now = await $.clock.now()
417 if (state.locked || state.behind === 0) idleSince = undefined
418 else idleSince ??= now
419 const isStalled = idleSince !== undefined && now - idleSince >= GRAPH_STALL_MS
420 if (state.behind === 0 || isStalled) {
421 live.stalledBehind = isStalled ? state.behind : 0
422 if (!isStalled) live.freshAt = now
423 return
424 }
425 }
426 live.isSlow = true
427 } finally {
428 live.isGraphBehind = false
429 live.graphSince = null
430 publish($)
431 if (live.pending.size > 0 && !live.isRunning) background(reindex($)) // edits made while the build ran
432 }
433}
434
435async function exists($: EngineInterface, path: string): Promise<boolean> {
436 return (await $.fs.stat(path).catch(() => undefined)) !== undefined
437}
438
439// The project: the folder the settings name, else the nearest one at or above the session's with a .verinoda index.
440async function findRoot($: EngineInterface, named: string, cwd: string): Promise<string | undefined> {
441 // slashes made forward, case kept: a case-sensitive file system finds the folder only as it is spelled
442 const slashes = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
443 if (named !== '') return (await exists($, `${slashes(named)}/.verinoda`)) ? slashes(named) : undefined
444 // a home folder or a drive root is never taken for a project unless the settings name it (Verinoda's own first
445 // scan refuses them too): every edit anywhere under it would re-index it
446 const homes = new Set<string>()
447 const profile = await $.env.get('USERPROFILE').catch(() => undefined)
448 const home = await $.env.get('HOME').catch(() => undefined)
449 for (const h of [profile, home]) if (h) homes.add(norm(h))
450 let dir = slashes(cwd)
451 for (let i = 0; i < 40 && dir !== ''; i++) {
452 const isTooWide = homes.has(norm(dir)) || /^[A-Za-z]:$/.test(dir) || dir === ''
453 if (!isTooWide && (await exists($, `${dir}/.verinoda`))) return dir
454 const up = dir.replace(/\/[^/]*$/, '')
455 if (up === dir || /^[A-Za-z]:$/.test(dir)) break
456 dir = up
457 }
458 return undefined
459}
460
461// The CLI and its Python: the settings' when given; else the project's .venv; else what PATH finds.
462async function resolveTools($: EngineInterface, root: string, options: Record<string, unknown>): Promise<void> {
463 const given = (k: string) => (typeof options[k] === 'string' ? (options[k] as string).trim() : '')
464 const venv = [`${root}/.venv/Scripts/verinoda.exe`, `${root}/.venv/bin/verinoda`]
465 let cli = given('cli')
466 if (cli === '') {
467 for (const c of venv) if (await exists($, c)) { cli = c; break }
468 }
469 cfg.cli = cli || 'verinoda'
470 let python = given('python')
471 if (python === '' && /[/\\]/.test(cfg.cli)) {
472 const dir = cfg.cli.replace(/\\/g, '/').replace(/\/[^/]*$/, '')
473 for (const c of [`${dir}/python.exe`, `${dir}/python`]) if (await exists($, c)) { python = c; break }
474 }
475 cfg.python = python || 'python'
476 cfg.motion = options.motion !== false
477}
478
479// What a re-index came to: `text` is the command's answer (English, as before); `code` lets the pane say it.
480type Outcome = {
481 code: 'noindex' | 'running' | 'graph-fresh' | 'fresh' | 'waiting' | 'updated' | 'failed'
482 n: number
483 text: string
484}
485
486async function reindex($: EngineInterface): Promise<Outcome> {
487 const { root, pending } = live
488 if (root === undefined) return { code: 'noindex', n: 0, text: `no .verinoda index at or above ${live.cwd}` }
489 if (live.isRunning) return { code: 'running', n: live.runningCount, text: 'an update is already running' }
490 if (pending.size === 0 && live.isGraphBehind) {
491 return { code: 'graph-fresh', n: 0, text: 'text index fresh; the graph is still being rebuilt' }
492 }
493 if (pending.size === 0 && live.stalledBehind === 0 && live.failed === null) return { code: 'fresh', n: 0, text: 'index already fresh' }
494 if (live.isGraphBehind) {
495 // the background build holds the lock; `update` would wait for it, so the files wait instead
496 publish($)
497 return { code: 'waiting', n: pending.size, text: `waiting for the graph build; ${plural(pending.size)} will be indexed when it ends` }
498 }
499
500 live.isRunning = true
501 const files = [...pending]
502 const stalled = live.stalledBehind
503 const count = files.length || stalled || live.failed?.n || 0
504 live.runningCount = count
505 pending.clear()
506 live.stalledBehind = 0
507 live.failed = null
508 live.isSlow = false
509 live.isUnknown = false
510 const fail = (error: string) => {
511 files.forEach(f => live.pending.add(f))
512 live.stalledBehind = Math.max(live.stalledBehind, stalled)
513 live.failed = { n: count, error }
514 }
515 publish($)
516 try {
517 const { exitCode, stderr } = await $.process.run(
518 [cfg.cli, 'update', '--fast', '--repo', root],
519 { cwd: root, timeoutMs: UPDATE_TIMEOUT_MS },
520 )
521 if (exitCode !== 0) {
522 fail(`çıkış ${exitCode}: ${stderr.trim().split('\n').pop()?.slice(0, 160) ?? ''}`)
523 return { code: 'failed', n: count, text: `update failed (exit ${exitCode}): ${stderr.slice(0, 300)}` }
524 }
525 live.isRunning = false
526 background(watchGraph($, root, count))
527 return { code: 'updated', n: count, text: `text index updated (${plural(count)}); the graph is rebuilt in the background` }
528 } catch (err) {
529 fail(String(err).slice(0, 160))
530 return { code: 'failed', n: count, text: `update failed: ${String(err)}` }
531 } finally {
532 live.isRunning = false
533 publish($)
534 }
535}
536
537const NOTICE_TEXT: Record<Outcome['code'], (n: number) => Notice> = {
538 noindex: () => ({ text: 'Bu projede index yok', tone: 'err' }),
539 running: n => ({ text: `Zaten güncelleniyor (${n} dosya)`, tone: 'ok' }),
540 'graph-fresh': () => ({ text: 'Metin güncel · graph hâlâ kuruluyor', tone: 'ok' }),
541 fresh: () => ({ text: 'Index zaten güncel', tone: 'ok' }),
542 waiting: n => ({ text: `Graph bitince ${n} dosya eklenecek`, tone: 'ok' }),
543 updated: n => ({ text: `Güncellendi (${n} dosya) · graph arka planda kuruluyor`, tone: 'ok' }),
544 failed: () => ({ text: 'Güncelleme başarısız · ayrıntı yukarıda', tone: 'err' }),
545}
546
547// The pane's update button: run it, and say what happened under the button for a few seconds.
548async function updateFromPane($: EngineInterface): Promise<void> {
549 const outcome = await reindex($)
550 const said = NOTICE_TEXT[outcome.code](outcome.n)
551 await update($, notice, () => said)
552 await $.clock.sleep(NOTICE_MS)
553 await update($, notice, n => (n === said ? null : n))
554}
555
556async function recheckFromPane($: EngineInterface): Promise<void> {
557 await update($, notice, () => ({ text: 'Kontrol ediliyor…', tone: 'ok' }))
558 await checkGraph($)
559 await update($, notice, () => null)
560}
561
562// Verinoda's query on the prompt, as one context block; undefined (with the reason recorded) when nothing is attached.
563async function retrieve($: EngineInterface, prompt: string): Promise<string | undefined> {
564 const root = live.root
565 if (root === undefined) return undefined
566 const t0 = await $.clock.now()
567 const record = (info: Pick<ContextInfo, 'ok' | 'chars' | 'note'>, t1: number) =>
568 update($, lastContext, () => ({ prompt: prompt.trim().slice(0, 80), seconds: seconds(t0, t1), mode: 'search' as const, at: t1, ...info }))
569 try {
570 const { exitCode, stdout, stderr } = await $.process.run(
571 [cfg.cli, 'query', plainQuery(prompt), '--repo', root, '--max-chars', '6000'],
572 { cwd: root, timeoutMs: CONTEXT_TIMEOUT_MS },
573 )
574 const t1 = await $.clock.now()
575 if (exitCode !== 0 || stdout.trim() === '') {
576 await record({ ok: false, chars: 0, note: exitCode !== 0 ? `query exit ${exitCode}: ${stderr.slice(0, 120)}` : 'nothing found' }, t1)
577 return undefined
578 }
579 const block = contextBlock(root, stdout.trim())
580 await record({ ok: true, chars: block.length, note: '' }, t1) // shown in the pane, not over the index state
581 return block
582 } catch (err) {
583 await record({ ok: false, chars: 0, note: `query failed: ${String(err).slice(0, 120)}` }, await $.clock.now())
584 return undefined
585 }
586}
587
588// ---- the assist features: lookups, tools, notes (./assist.ts holds the pure parts) ------------------------------
589
590// The daemon, started in the background at session start when a feature will ask for it (it listens at once and loads the
591// graph meanwhile); the commands find it by themselves. An old CLI that knows no `locate --daemon` exits non-zero, and
592// every lookup is then a command that loads the graph itself.
593async function startDaemon($: EngineInterface, root: string): Promise<void> {
594 try {
595 await $.process.run([cfg.cli, 'locate', '--daemon', 'start', '--repo', root, '--json'], { cwd: root, timeoutMs: DAEMON_START_TIMEOUT_MS })
596 } catch {
597 // no daemon, nothing lost
598 }
599}
600
601function wantsDaemon(f: Features): boolean {
602 return f.inject || f.coupled || f.tool || f.gate
603}
604
605function ensureDaemon($: EngineInterface, f: Features): void {
606 if (live.root === undefined || !inProject() || live.daemonStart !== undefined || !wantsDaemon(f)) return
607 live.daemonStart = startDaemon($, live.root)
608 background(live.daemonStart)
609}
610
611function parseLocated(text: string): Located | undefined {
612 try {
613 const d = JSON.parse(text) as unknown
614 return typeof d === 'object' && d !== null && !Array.isArray(d) ? (d as Located) : undefined
615 } catch {
616 return undefined
617 }
618}
619
620type Lookup = { op: 'locate'; text: string; anchors: string[] } | { op: 'coupled'; files: string[] }
621
622// One lookup: the command, which asks this repository's daemon when one runs; undefined when it fails.
623async function lookup($: EngineInterface, root: string, q: Lookup): Promise<Located | undefined> {
624 await live.daemonStart?.catch(() => undefined)
625 const argv = q.op === 'locate'
626 ? [cfg.cli, 'locate', '--repo', root, '--json', '--max-chars', String(LOCATE_ANSWER_MAX), ...q.anchors.flatMap(a => ['--anchor', a]), '--', q.text]
627 : [cfg.cli, 'coupled', '--repo', root, '--json', ...q.files]
628 try {
629 const { exitCode, stdout } = await $.process.run(argv, { cwd: root, timeoutMs: LOCATE_TIMEOUT_MS })
630 return exitCode === 0 ? parseLocated(stdout) : undefined
631 } catch {
632 return undefined
633 }
634}
635
636async function registerAssistTools($: EngineInterface): Promise<void> {
637 for (const spec of TOOL_SPECS) {
638 await $.tool.register({ name: spec.name, description: spec.description, inputSchema: spec.inputSchema as unknown as Record<string, unknown> })
639 }
640}
641
642async function registerImproveTools($: EngineInterface): Promise<void> {
643 for (const spec of IMPROVE_SPECS) {
644 await $.tool.register({ name: spec.name, description: spec.description, inputSchema: spec.inputSchema as unknown as Record<string, unknown> })
645 }
646}
647
648// A path as the lookups spell it: relative to the project, slashes forward.
649function toRel(root: string, file: string): string {
650 const p = file.replace(/\\/g, '/')
651 return isInside(p, root) ? relPath(root, p) : p.replace(/^\.\//, '')
652}
653
654// The assist features serve a session that started inside the project, as the prompt hook always did: a session
655// started somewhere else with the project named in the settings gets no tools, no prompt line, no gate and no notes.
656function inProject(): boolean {
657 return live.root !== undefined && isInside(live.cwd, live.root)
658}
659
660function noteRead(rel: string): void {
661 if (!live.reads.includes(rel) && live.reads.length < 50) live.reads.push(rel)
662}
663
664// A prompt that could be a task starts a new hunt: the gate may answer its first search, the tool counts as not yet used.
665function startTask(text: string): void {
666 const t = text.trim()
667 if (t.length < TASK_MIN_CHARS || /^[/!#]/.test(t)) return
668 live.task = t
669 live.reads = [] // what was read for the last task is not what this one starts from
670 live.gated = false
671 live.usedLocate = false
672}
673
674export function locateBlock(answer: string): string {
675 return (
676 "[Verinoda locate] Files Verinoda's index, the project's git history and sibling directories point to for this " +
677 'request. They are leads, not verified answers: read them, check what you rely on, and name every file that ' +
678 `needs the change.\n\n${answer}`
679 )
680}
681
682export function gateText(answer: string): string {
683 return (
684 "[Verinoda] This search was not run. Before searching, Verinoda's index, the project's git history and sibling " +
685 `directories point to these files for the task:\n\n${answer}\n\nRead them first and name every file that needs ` +
686 'the change; run the search again only if you still need it.'
687 )
688}
689
690// What the last commit changed, by concern: Verinoda's review of the working tree against the commit's parent.
691async function reviewCommit($: EngineInterface, sha: string): Promise<void> {
692 const root = live.root
693 if (root === undefined) return
694 if (live.isReviewing) {
695 live.reviewAgain = true
696 return
697 }
698 live.isReviewing = true
699 const short = sha.slice(0, 7)
700 await update($, reviewing, () => short)
701 const t0 = await $.clock.now()
702 const failedInfo = (summary: string, t1: number): ReviewInfo =>
703 ({ ok: false, risk: '', score: null, of: 100, band: '', findings: 0, summary, seconds: seconds(t0, t1), sha: short, at: t1 })
704 try {
705 const { exitCode, stdout } = await $.process.run(
706 [cfg.cli, 'review', '--json', '--repo', root, '--base', 'HEAD~1', '--max-chars', '4000'],
707 { cwd: root, timeoutMs: REVIEW_TIMEOUT_MS },
708 )
709 const t1 = await $.clock.now()
710 if (exitCode !== 0 && exitCode !== 3) { // 3: findings or unknowns to report, an answer
711 await update($, lastReview, () => failedInfo(`çıkış ${exitCode}`, t1))
712 return
713 }
714 const d = JSON.parse(stdout) as {
715 summary?: string
716 risk?: { score?: number; of?: number; band?: string }
717 counts?: { findings?: number }
718 }
719 const score = d.risk?.score ?? null
720 const of = d.risk?.of ?? 100
721 const band = d.risk?.band ?? ''
722 const risk = score === null ? '' : `${score}/${of} (${band || '?'})`
723 const info: ReviewInfo = {
724 ok: true, risk, score, of, band, findings: d.counts?.findings ?? 0, summary: (d.summary ?? '').slice(0, 600),
725 seconds: seconds(t0, t1), sha: short, at: t1,
726 }
727 await update($, lastReview, () => info)
728 await update($, expanded, () => false)
729 $.ui.toast(`Verinoda review of the commit: risk ${risk || 'n/a'}, ${info.findings} finding(s) · /verinoda-panel`)
730 } catch (err) {
731 await update($, lastReview, () => failedInfo(String(err).slice(0, 200), 0))
732 } finally {
733 live.isReviewing = false
734 await update($, reviewing, () => null).catch(() => undefined)
735 if (live.reviewAgain) {
736 live.reviewAgain = false
737 const head = await gitHead($, root)
738 background(reviewCommit($, head ?? sha))
739 }
740 }
741}
742
743async function gitHead($: EngineInterface, root: string): Promise<string | undefined> {
744 try {
745 const { exitCode, stdout } = await $.process.run(['git', '-C', root, 'rev-parse', 'HEAD'], { cwd: root, timeoutMs: 10_000 })
746 return exitCode === 0 ? stdout.trim() : undefined
747 } catch {
748 return undefined
749 }
750}
751
752export function modeCommand(arg: string, current: AutoMode): AutoMode | undefined {
753 const a = arg.trim().toLowerCase()
754 if (a === '') return current === 'off' ? 'nudge' : 'off'
755 if (a === 'on' || a === 'açık' || a === 'aç') return 'nudge'
756 if (a === 'kapalı' || a === 'kapat') return 'off'
757 return MODES.includes(a as AutoMode) ? (a as AutoMode) : undefined
758}
759
760export function settingCommand(arg: string, current: boolean): boolean | undefined {
761 const a = arg.trim().toLowerCase()
762 if (a === '') return !current
763 if (a === 'on' || a === 'açık' || a === 'ac' || a === 'aç') return true
764 if (a === 'off' || a === 'kapalı' || a === 'kapat') return false
765 return undefined
766}
767
768// ---- the pane's words, pictures and layout (pure: tested without the engine) ----------------------------------
769
770export function hhmm(ms: number | null): string {
771 if (ms === null) return '--:--'
772 const d = new Date(ms)
773 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
774}
775
776export type Tone = 'ok' | 'busy' | 'warn' | 'err' | 'idle'
777export const TONE_COLOR: Record<Tone, string> = { ok: 'green', busy: 'cyan', warn: 'yellow', err: 'red', idle: 'gray' }
778export type Action = 'update' | 'retry' | 'recheck'
779
780// Every state as a glyph, words and a tone; the glyph and words carry the meaning, the colour only reinforces it.
781export function describeIndex(s: IndexState): { head: string; tone: Tone; details: string[]; action: Action | null } {
782 switch (s.kind) {
783 case 'checking': return { head: '◌ Kontrol ediliyor…', tone: 'idle', details: [], action: null }
784 case 'fresh': return { head: '✓ Index güncel', tone: 'ok', details: [s.at === null ? 'bu oturumda değişiklik yok' : `son kontrol ${hhmm(s.at)}`], action: null }
785 case 'pending':
786 return s.waiting
787 ? { head: `● ${s.n} dosya bekliyor`, tone: 'warn', details: ['graph bitince eklenecek'], action: null }
788 : { head: `● ${s.n} dosya bekliyor`, tone: 'warn', details: ['tur bitince kendiliğinden güncellenir'], action: 'update' }
789 case 'updating': return { head: `◐ Güncelleniyor · ${s.n} dosya`, tone: 'busy', details: [], action: null }
790 case 'graph':
791 return { head: s.n > 0 ? `◐ Graph kuruluyor · ${s.n} dosya` : '◐ Graph kuruluyor', tone: 'busy',
792 details: [`${hhmm(s.since)}'den beri · genelde 2-4 dk`, 'Metin araması şimdiden güncel'], action: null }
793 case 'behind': return { head: `▲ Graph ${s.n} dosya geride`, tone: 'warn', details: ["Claude'un dışında değişen dosyalar"], action: 'update' }
794 case 'slow': return { head: "▲ Graph kurulumu 10 dk'yı geçti", tone: 'warn', details: [], action: 'recheck' }
795 case 'failed': return { head: `✗ Güncelleme başarısız · ${s.n} dosya bekletiliyor`, tone: 'err', details: [s.error], action: 'retry' }
796 case 'unknown': return { head: '? Graph durumu okunamadı', tone: 'idle', details: [], action: 'recheck' }
797 case 'noindex': return { head: "? Bu projede Verinoda index'i yok", tone: 'idle', details: ['Proje klasöründe çalıştır:', 'verinoda init, sonra verinoda update'], action: null }
798 }
799}
800
801export const ACTION_LABEL: Record<Action, string> = { update: 'Şimdi güncelle', retry: 'Tekrar dene', recheck: 'Tekrar kontrol et' }
802
803export function describeReview(r: ReviewInfo | null, sha: string | null): { head: string; tone: Tone; meta: string } | null {
804 if (sha !== null) return { head: `◐ İnceleniyor · ${sha}…`, tone: 'busy', meta: '' }
805 if (r === null) return null
806 const meta = `${r.sha} · ${hhmm(r.at)}`
807 if (!r.ok) return { head: `✗ İnceleme başarısız (${r.summary})`, tone: 'err', meta }
808 const tail = `${r.findings} bulgu`
809 if (r.score === null) return { head: `? Risk yok · ${tail}`, tone: 'idle', meta }
810 const score = `${r.score}/${r.of}`
811 if (r.band === 'high') return { head: `▲ Yüksek risk ${score} · ${tail}`, tone: 'err', meta }
812 if (r.band === 'medium') return { head: `● Orta risk ${score} · ${tail}`, tone: 'warn', meta }
813 return { head: `✓ Düşük risk ${score} · ${tail}`, tone: 'ok', meta }
814}
815
816export const MODE_LABEL: Record<AutoMode, string> = { off: 'Kapalı', nudge: 'Yönlendir', search: 'Arama' }
817export const MODE_HINT: Record<AutoMode, string> = {
818 off: 'Sorulara bağlam eklenmez',
819 nudge: 'Önerilen · Claude önce analyze çalıştırır',
820 search: 'Arama sonuçları eklenir · ölçümde daha zayıf',
821}
822export const OUTSIDE_HINT = '▲ Oturum proje dışında başladı; bağlam eklenmez'
823export const GUARD_HINT = 'Commit sonrası risk ve bulgular bildirilir'
824export const CHECK_HINT = "Python/Java/Kotlin düzenlemesindeki var olmayan isimler Claude'a söylenir"
825
826export function checkLine(c: CheckInfo | null): { text: string; tone: Tone } | null {
827 if (c === null) return null
828 if (!c.ok) return { text: `Son kontrol çalışmadı: ${c.file} (düzenleme yapıldı)`, tone: 'warn' }
829 if (c.n === 0) return { text: `Son kontrol: ${c.file} · temiz`, tone: 'idle' }
830 return { text: `Son kontrol: ${c.file} · ${c.n} isim bulunamadı, Claude'a söylendi`, tone: 'warn' }
831}
832
833export function contextLine(c: ContextInfo | null): { text: string; tone: Tone } | null {
834 if (c === null) return null
835 const head = `"${c.prompt.slice(0, 40)}${c.prompt.length > 40 ? '…' : ''}"`
836 if (c.mode === 'nudge') return { text: `Son ${hhmm(c.at)} · yönlendirildi · ${head}`, tone: 'idle' }
837 if (c.ok) return { text: `Son ${hhmm(c.at)} · ${c.chars.toLocaleString('tr')} karakter eklendi (${c.seconds.toLocaleString('tr')} sn) · ${head}`, tone: 'idle' }
838 return { text: `Son ${hhmm(c.at)} · eklenmedi: ${c.note === 'nothing found' ? 'sonuç yok' : 'arama hatası'} · ${head}`, tone: 'warn' }
839}
840
841// The mascot (./mascot.tsx) is drawn by the surface as a `Client`, animated there; a surface without `Client`
842// (VS Code, mobile) gets its first frame, still.
843const ANIMATED_SURFACES = ['terminal', 'desktop']
844const MOOD: Record<Tone, Mood> = { ok: 'idle', idle: 'idle', busy: 'busy', warn: 'warn', err: 'err' }
845
846export function mascotCaption(s: IndexState, r: ReviewInfo | null, sha: string | null): { text: string; tone: Tone } {
847 if (s.kind === 'failed' || (r !== null && !r.ok)) return { text: 'Bir şey ters gitti', tone: 'err' }
848 if (r !== null && r.ok && r.band === 'high' && sha === null) return { text: "Commit'e bir bak", tone: 'err' }
849 if (s.kind === 'pending' || s.kind === 'behind' || s.kind === 'slow') return { text: 'Güncelleme bekliyor', tone: 'warn' }
850 if (s.kind === 'updating' || s.kind === 'graph' || s.kind === 'checking' || sha !== null) return { text: 'Kodu okuyorum…', tone: 'busy' }
851 if (s.kind === 'noindex') return { text: 'Index yok', tone: 'idle' }
852 return { text: 'Her şey güncel', tone: 'idle' }
853}
854
855// Rows a line takes at this width (a wrapped line may take several).
856export function rowsOf(text: string, columns: number): number {
857 return Math.max(1, Math.ceil(text.length / Math.max(1, columns)))
858}
859
860export const register: Register = (on, options) => {
861 registerImprove(on)
862
863 on('session.start', async ($, e, next) => {
864 live.cwd = await $.session.cwd()
865 cfg.auto = asMode(options.auto)
866 cfg.assist = typeof options.assist === 'string' ? options.assist : 'off'
867 live.root = await findRoot($, typeof options.root === 'string' ? options.root.trim() : '', live.cwd)
868 if (live.root !== undefined) await resolveTools($, live.root, options)
869 await loadSettings($, e.isInteractive !== false)
870 // the improvement list (D175): its command is always there, its tools only for a person and when a list is in play or offered
871 improveMod.interactive = e.isInteractive !== false
872 improveMod.offered = options.improveOffer === true
873 await $.command.register(IMPROVE_COMMAND)
874 if (improveMod.interactive && (improveMod.offered || (await read($, improve)).phase !== 'idle')) await registerImproveTools($)
875 await $.command.register({ name: 'verinoda-update', description: 'Re-index the files edited this session now.' })
876 await $.command.register({ name: 'verinoda-assist', description: 'Where a change belongs: locate files, what changes together, a tool and the first search (off, inject, tool, full, strict).' })
877 await $.command.register({ name: 'verinoda-auto', description: 'Code questions: nudge (start with Verinoda analyze), search (attach results) or off.' })
878 await $.command.register({ name: 'verinoda-guard', description: 'Review each commit with Verinoda afterwards: on, off, or toggle.' })
879 await $.command.register({ name: 'verinoda-check', description: 'Check each edit of Python/Java/Kotlin code for names that do not exist: on, off, or toggle.' })
880 // not plain `verinoda`: the Verinoda agent skill of that name takes `/verinoda` first
881 await $.command.register({ name: 'verinoda-panel', description: 'Open the Verinoda pane: index state, settings, last context and review.' })
882 publish($)
883 background(checkGraph($))
884 if (live.root !== undefined && (await read($, check))) background(warmCheck($, live.root))
885 const f = await features($)
886 if (inProject() && f.tool) await registerAssistTools($) // awaited: listed by the first turn
887 ensureDaemon($, f)
888 return next(e)
889 })
890
891 // The tools are first-class: their schemas in the prompt's list, not behind ToolSearch, where the agent studies found
892 // a tool that must first be looked up is not called (in this build a plugin's tool is deferred unless a hook says not).
893 on('tool.describe', { tool: [LOCATE_TOOL, COUPLED_TOOL] }, ($, e) => {
894 const spec = TOOL_SPECS.find(s => ASSIST_TOOL(s.name) === e.tool)
895 return { description: spec?.description ?? e.description, isDeferred: false }
896 })
897
898 on('prompt.compose', async ($, e, next) => {
899 const out = await next(e)
900 const f = await features($)
901 const sections = improveSections(out.sections)
902 if (!f.prompt || !inProject()) return sections === out.sections ? out : { sections }
903 return { sections: [...sections, { id: 'verinoda-live:assist', text: assistPrompt(f), scope: 'session' as const }] }
904 })
905
906 // The tools answer a plain string: any other shape fails the engine's check of a plugin's tool result.
907 on('tool.call', { tool: LOCATE_TOOL }, async ($, e) => {
908 live.usedLocate = true
909 const root = live.root
910 if (root === undefined || !inProject()) return { result: 'verinoda locate: this session did not start in a project with a Verinoda index' }
911 const text = typeof e.text === 'string' ? e.text.trim() : ''
912 if (text === '') return { result: 'verinoda locate needs the text of the bug report or task (the text argument)' }
913 const named = Array.isArray(e.files) ? e.files.filter((f): f is string => typeof f === 'string' && f !== '') : []
914 const anchors = (named.length > 0 ? named.map(f => toRel(root, f)) : live.reads).slice(0, ANCHORS_MAX)
915 const located = await lookup($, root, { op: 'locate', text: text.slice(0, LOCATE_TEXT_MAX), anchors })
916 if (located === undefined) return { result: 'verinoda locate failed (the CLI did not answer); search the code yourself' }
917 return { result: renderLocate(located, LOCATE_ANSWER_MAX) ?? 'verinoda locate found no file for this text' }
918 })
919
920 on('tool.call', { tool: COUPLED_TOOL }, async ($, e) => {
921 const root = live.root
922 if (root === undefined || !inProject()) return { result: 'verinoda coupled: this session did not start in a project with a Verinoda index' }
923 const files = Array.isArray(e.files) ? e.files.filter((f): f is string => typeof f === 'string' && f !== '').map(f => toRel(root, f)).slice(0, ANCHORS_MAX) : []
924 if (files.length === 0) return { result: 'verinoda coupled needs the files to look up (the files argument)' }
925 const located = await lookup($, root, { op: 'coupled', files })
926 if (located === undefined) return { result: 'verinoda coupled failed (the CLI did not answer); search the code yourself' }
927 return { result: renderLocate(located, LOCATE_ANSWER_MAX) ?? 'verinoda coupled found no file that changes together with these' }
928 })
929
930 // A file the model opens brings the files that change together with it: attached to a call the agent makes anyway.
931 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
932 const ran = await next(e)
933 const root = live.root
934 if (root === undefined || !inProject() || ran.deny !== undefined || ran.isError === true || typeof e.file_path !== 'string') return ran
935 if (!isInside(e.file_path, root)) return ran
936 const rel = toRel(root, e.file_path)
937 if (rel.startsWith('.verinoda/')) return ran
938 noteRead(rel)
939 // one note per file whatever its spelling (a path is as the model typed it: another case, a drive letter in lower case)
940 const key = rel.toLowerCase()
941 if (!(await features($)).coupled || !isSourceFile(rel) || live.coupledAsked.has(key) || live.coupledAsked.size >= COUPLED_BUDGET) return ran
942 live.coupledAsked.add(key)
943 const note = coupledNote(rel, await lookup($, root, { op: 'coupled', files: [rel] }))
944 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
945 })
946
947 // The task's first search is answered with the files that point to it, and run again if still wanted: the answer
948 // reaches an agent that did not ask. Once per task, and never once the model has called the tool itself.
949 on('tool.call', { tool: ['Grep', 'Glob', ...SHELL_TOOLS] }, async ($, e, next) => {
950 const root = live.root
951 if (root === undefined || !inProject() || live.gated || live.usedLocate || live.task === '' || !isBlockedSearch(e)) return next(e)
952 if (!(await features($)).gate) return next(e)
953 live.gated = true
954 const located = await lookup($, root, { op: 'locate', text: live.task.slice(0, LOCATE_TEXT_MAX), anchors: live.reads.slice(0, ANCHORS_MAX) })
955 const answer = renderLocate(located, LOCATE_ANSWER_MAX)
956 return answer === undefined ? next(e) : { deny: gateText(answer) }
957 })
958
959 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
960 const ran = await next(e)
961 const root = live.root
962 if (root === undefined || ran.deny !== undefined || ran.isError === true) return ran
963
964 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : undefined
965 const file = path === undefined ? '' : norm(path)
966 if (!file.startsWith(`${norm(root)}/`) || file.includes('/.verinoda/')) return ran
967 live.pending.add(file)
968 publish($)
969 if (path === undefined || !isCheckable(path) || !(await read($, check))) return ran
970 // the edit has gone through: nothing the check does may change that
971 const note = await checkEdit($, root, path.replace(/\\/g, '/')).catch(() => undefined)
972 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
973 })
974
975 // After a command that moved the project's HEAD (a commit, an amend, an alias of either), review what it changed;
976 // HEAD is read before and after, so a git command that made no commit there is never reviewed.
977 on('tool.call', { tool: SHELL_TOOLS }, async ($, e, next) => {
978 const root = live.root
979 if (root === undefined || !GIT_WORD.test(e.command) || !(await read($, guard))) return next(e)
980 const before = await gitHead($, root)
981 const ran = await next(e)
982 const after = await gitHead($, root)
983 if (ran.deny === undefined && before !== undefined && after !== undefined && after !== before) background(reviewCommit($, after))
984 return ran
985 })
986
987 // A code question typed in the project gets the nudge (or, in search mode, Verinoda's results) as context.
988 on('prompt.submit', async ($, e, next) => {
989 // the improvement list's own prompts (D175) are the person's decision, sent as theirs: no task, no context
990 if (e.origin?.kind === 'plugin' && e.origin.name === 'verinoda-live') return next(e)
991 const isUser = isPersonsPrompt(e.origin)
992 if (isUser) startTask(e.text)
993 if (live.root === undefined || !isUser || !isInside(live.cwd, live.root)) return next(e)
994 const blocks: string[] = []
995 if ((await features($)).inject && looksLikeCodeQuestion(e.text, NUDGE_MAX_CHARS)) {
996 const located = await lookup($, live.root, { op: 'locate', text: e.text.trim().slice(0, LOCATE_TEXT_MAX), anchors: [] })
997 const answer = renderLocate(located, LOCATE_ANSWER_MAX)
998 if (answer !== undefined) blocks.push(locateBlock(answer))
999 }
1000 const mode = await read($, auto)
1001 if (mode !== 'off' && looksLikeCodeQuestion(e.text, mode === 'nudge' ? NUDGE_MAX_CHARS : 2_000)) {
1002 let block: string | undefined
1003 if (mode === 'nudge') {
1004 const nudge = nudgeBlock(live.root, cfg.cli)
1005 block = nudge
1006 const at = await $.clock.now()
1007 await update($, lastContext, () => ({ prompt: e.text.trim().slice(0, 80), mode, ok: true, chars: nudge.length, seconds: 0, note: '', at }))
1008 } else {
1009 block = await retrieve($, e.text)
1010 }
1011 if (block !== undefined) blocks.push(block)
1012 }
1013 return next(blocks.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...blocks] })
1014 })
1015
1016 // One re-index per turn, not per edit; left running so the turn ends at once.
1017 on('turn.complete', async ($, e, next) => {
1018 const improving = await read($, improve)
1019 if (e.agentId === undefined && improving.turn !== null && e.turnId === improving.turn) await update($, improve, endOfTurn)
1020 // the improvement review waits for the command's own turn to end: a submit from the command's hook is refused by the host
1021 if (e.agentId === undefined && improving.queue === 'review') {
1022 await update($, improve, (s: ImproveState) => ({ ...s, queue: '' as const, expect: 'review' as const, turn: null, note: '' as const }))
1023 const failed = () => update($, improve, (s: ImproveState) => (s.expect === 'review' ? undoSend(s, 'review') : s))
1024 background($.prompt.submit({ text: reviewPrompt(improving.subject), asUser: true }).then(r => (r.drop === undefined ? undefined : failed()), failed))
1025 }
1026 if (e.agentId === undefined && live.pending.size > 0 && !live.isRunning) background(reindex($))
1027 return next(e)
1028 })
1029
1030 on('command.run', { command: 'verinoda-update' }, async $ => ({ text: (await reindex($)).text }))
1031
1032 on('command.run', { command: 'verinoda-auto' }, async ($, e) => {
1033 const mode = modeCommand(e.args, await read($, auto))
1034 if (mode === undefined) return { text: 'usage: /verinoda-auto [nudge|search|off] (no argument toggles nudge/off)' }
1035 await setAuto($, mode)
1036 const where = mode !== 'off' && live.root !== undefined && !isInside(live.cwd, live.root) ? ` (only for sessions started in ${live.root})` : ''
1037 return { text: `auto-context: ${mode}${where}` }
1038 })
1039
1040 on('command.run', { command: 'verinoda-assist' }, async ($, e) => {
1041 if (e.args.trim() === '') return { text: `assist: ${await read($, assist)} (${ASSIST_USAGE})` }
1042 const value = parseAssist(e.args)
1043 if (value === undefined) return { text: ASSIST_USAGE }
1044 await setAssist($, value)
1045 const f = assistFeatures(value)
1046 if (inProject() && f.tool) await registerAssistTools($)
1047 ensureDaemon($, f)
1048 const where = value !== 'off' && live.root !== undefined && !isInside(live.cwd, live.root) ? ` (only for sessions started in ${live.root})` : ''
1049 return { text: `assist: ${value}${where}` }
1050 })
1051
1052 on('command.run', { command: 'verinoda-guard' }, async ($, e) => {
1053 const value = settingCommand(e.args, await read($, guard))
1054 if (value === undefined) return { text: 'usage: /verinoda-guard [on|off]' }
1055 await setGuard($, value)
1056 return { text: `review after commits ${value ? 'on' : 'off'}` }
1057 })
1058
1059 on('command.run', { command: 'verinoda-check' }, async ($, e) => {
1060 const value = settingCommand(e.args, await read($, check))
1061 if (value === undefined) return { text: 'usage: /verinoda-check [on|off]' }
1062 await setCheck($, value)
1063 return { text: `check after edits ${value ? 'on' : 'off'}` }
1064 })
1065
1066 on('command.run', { command: 'verinoda-panel' }, async $ => {
1067 await $.ui.open({ id: PANE, title: 'Verinoda', focus: true })
1068 return { text: 'Verinoda pane opened.' }
1069 })
1070
1071 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1072 const { Box, Button, Text } = $.ui.resolve(e)
1073 const s = await read($, idx)
1074 const said = await read($, notice)
1075 const mode = await read($, auto)
1076 const isGuard = await read($, guard)
1077 const ctx = await read($, lastContext)
1078 const rev = await read($, lastReview)
1079 const sha = await read($, reviewing)
1080 const isExpanded = await read($, expanded)
1081 const isCheck = await read($, check)
1082 const lastChk = await read($, lastCheck)
1083
1084 const columns = Math.max(20, e.props.bodyColumns ?? 48)
1085 const isInline = e.props.placement === 'inline'
1086 const bodyRows = e.props.scroll?.bodyRows ?? 0
1087 const tone = (t: Tone) => TONE_COLOR[t]
1088 let used = 0 // rows the content takes, counted while the tree is built, to know what is left for the mascot
1089 const count = (text: string) => { used += rowsOf(text, columns) }
1090
1091 // 1 · the index
1092 const ix = describeIndex(s)
1093 const action = ix.action
1094 const indexBlock = (
1095 <Box key="index" flexDirection="column">
1096 <Text bold color={tone(ix.tone)} wrap="wrap">{ix.head}</Text>
1097 {!isInline && ix.details.map((d, i) => <Text key={`d${i}`} dimColor wrap="truncate">{` ${d}`}</Text>)}
1098 {action !== null && (
1099 <Button key="update" label={ACTION_LABEL[action]} hotkey="u" variant={action === 'recheck' ? 'secondary' : 'primary'}
1100 onPress={() => background(action === 'recheck' ? recheckFromPane($) : updateFromPane($))} />
1101 )}
1102 {said !== null && (
1103 <Text key="notice" color={said.tone === 'err' ? 'red' : said.tone === 'warn' ? 'yellow' : undefined}
1104 dimColor={said.tone === 'ok'} wrap="truncate">{` ${said.text}`}</Text>
1105 )}
1106 </Box>
1107 )
1108 count(ix.head)
1109 if (!isInline) used += ix.details.length
1110 if (action !== null) used += 1
1111 if (said !== null) used += 1
1112
1113 if (s.kind === 'noindex') return <Box flexDirection="column">{indexBlock}</Box>
1114
1115 // 2 · the last commit
1116 const rv = describeReview(rev, sha)
1117 const showReview = rv !== null || isGuard
1118 const summary = rev !== null && rev.ok && rev.findings > 0 && sha === null ? rev.summary : ''
1119 const clamp = columns * 2 - 3
1120 const isCut = summary.length > clamp
1121 const shown = isExpanded || !isCut ? summary : `${summary.slice(0, clamp)}…`
1122 const emptyReview = 'Henüz commit incelenmedi'
1123 const reviewBlock = !showReview ? null : (
1124 <Box key="review" flexDirection="column">
1125 {!isInline && <Text bold>Son commit</Text>}
1126 {rv === null
1127 ? <Text dimColor wrap="truncate">{emptyReview}</Text>
1128 : <Text bold={rv.tone === 'err'} color={tone(rv.tone)} wrap="wrap">{isInline ? `Son commit: ${rv.head}` : rv.head}</Text>}
1129 {!isInline && rv !== null && rv.meta !== '' && <Text dimColor wrap="truncate">{` ${rv.meta}`}</Text>}
1130 {!isInline && shown !== '' && <Text wrap="wrap">{shown}</Text>}
1131 {!isInline && isCut && (
1132 <Button key="review-more" label={isExpanded ? 'Kısalt' : 'Ayrıntı'} hotkey="d" variant="secondary"
1133 onPress={() => update($, expanded, x => !x)} />
1134 )}
1135 </Box>
1136 )
1137 if (reviewBlock !== null) {
1138 if (!isInline) used += 2 // the title and the gap above it
1139 count(rv === null ? emptyReview : rv.head)
1140 if (!isInline && rv !== null && rv.meta !== '') used += 1
1141 if (!isInline && shown !== '') count(shown)
1142 if (!isInline && isCut) used += 1
1143 }
1144
1145 // 3 · auto-context: all three modes in a fixed order, the selected one primary and marked
1146 const outside = mode !== 'off' && live.root !== undefined && !isInside(live.cwd, live.root)
1147 const last = mode === 'off' ? null : contextLine(ctx)
1148 const isNarrow = columns < 36
1149 const contextEl = (
1150 <Box key="context" flexDirection="column">
1151 {!isInline && <Text bold>Otomatik bağlam</Text>}
1152 <Box flexDirection={isNarrow ? 'column' : 'row'} columnGap={1}>
1153 {isInline && <Text>Bağlam</Text>}
1154 {MODES.map((m, i) => (
1155 <Button key={`auto-${m}`} label={`${m === mode ? '● ' : ''}${MODE_LABEL[m]}`} hotkey={String(i + 1)}
1156 variant={m === mode ? 'primary' : 'secondary'} onPress={() => (m === mode ? undefined : setAuto($, m))} />
1157 ))}
1158 </Box>
1159 {!isInline && (outside
1160 ? <Text color="yellow" wrap="wrap">{OUTSIDE_HINT}</Text>
1161 : <Text dimColor wrap="wrap">{MODE_HINT[mode]}</Text>)}
1162 {!isInline && last !== null && (
1163 <Text dimColor={last.tone === 'idle'} color={last.tone === 'warn' ? 'yellow' : undefined} wrap="truncate">{last.text}</Text>
1164 )}
1165 </Box>
1166 )
1167 if (!isInline) {
1168 used += 2 // the title and the gap above it
1169 used += isNarrow ? MODES.length : 1
1170 count(outside ? OUTSIDE_HINT : MODE_HINT[mode])
1171 if (last !== null) used += 1
1172 }
1173
1174 // 4 · review after commits: the same two-option control; r always flips it
1175 const guardEl = (
1176 <Box key="guard" flexDirection="column">
1177 <Box flexDirection={columns < 40 ? 'column' : 'row'} columnGap={1}>
1178 <Text bold={!isInline}>{isInline ? 'İnceleme' : 'Commit incelemesi'}</Text>
1179 <Button key="guard-on" label={isGuard ? '● Açık' : 'Açık'} hotkey={isGuard ? undefined : 'r'}
1180 variant={isGuard ? 'primary' : 'secondary'} onPress={() => (isGuard ? undefined : setGuard($, true))} />
1181 <Button key="guard-off" label={isGuard ? 'Kapalı' : '● Kapalı'} hotkey={isGuard ? 'r' : undefined}
1182 variant={isGuard ? 'secondary' : 'primary'} onPress={() => (isGuard ? setGuard($, false) : undefined)} />
1183 </Box>
1184 {!isInline && <Text dimColor wrap="wrap">{GUARD_HINT}</Text>}
1185 </Box>
1186 )
1187 if (!isInline) {
1188 used += 2 + (columns < 40 ? 2 : 0) // the row (stacked when narrow) and the gap above it
1189 count(GUARD_HINT)
1190 }
1191
1192 // 5 · the check after edits: the same two-option control; k always flips it; the last check under it
1193 const lastLine = checkLine(lastChk)
1194 const checkEl = (
1195 <Box key="check" flexDirection="column">
1196 <Box flexDirection={columns < 40 ? 'column' : 'row'} columnGap={1}>
1197 <Text bold={!isInline}>{isInline ? 'Kontrol' : 'Kod kontrolü'}</Text>
1198 <Button key="check-on" label={isCheck ? '● Açık' : 'Açık'} hotkey={isCheck ? undefined : 'k'}
1199 variant={isCheck ? 'primary' : 'secondary'} onPress={() => (isCheck ? undefined : setCheck($, true))} />
1200 <Button key="check-off" label={isCheck ? 'Kapalı' : '● Kapalı'} hotkey={isCheck ? 'k' : undefined}hooks/assist.ts 185 lines1// The pure parts of the assist features (0.5): which are on, how a located answer reads to the model, which calls open
2// a code hunt. Nothing here runs a process or touches the engine; register.tsx does.
3//
4// What the features are for: the agent studies (benchmarks/agent_compare) found the agent never calls a tool it is
5// only told about, and that what it misses are the sibling files of the ones it found. So the answer is put where the
6// agent is already looking: with the prompt (`inject`), with the result of a file it reads (`coupled`), in place of its
7// first search (`gate`); and the same lookups are offered as tools (`tool`) with a line in the system prompt (`prompt`).
8
9export type Features = { inject: boolean; coupled: boolean; tool: boolean; prompt: boolean; gate: boolean }
10
11export const FEATURE_NAMES = ['inject', 'coupled', 'tool', 'prompt', 'gate'] as const
12
13const NONE: Features = { inject: false, coupled: false, tool: false, prompt: false, gate: false }
14
15export const PRESETS: Readonly<Record<string, Features>> = {
16 off: NONE,
17 inject: { ...NONE, inject: true },
18 tool: { ...NONE, tool: true, prompt: true },
19 full: { ...NONE, coupled: true, tool: true, prompt: true },
20 strict: { ...NONE, coupled: true, tool: true, prompt: true, gate: true },
21}
22
23const squeeze = (value: string) => value.trim().toLowerCase().replace(/\s+/g, '')
24const isPreset = (name: string) => Object.prototype.hasOwnProperty.call(PRESETS, name)
25
26// A setting as the features it turns on: a preset's name, or a comma list of features. Whatever is not a feature (a
27// value stored by another version, a typo in the settings) turns nothing on.
28export function assistFeatures(value: unknown): Features {
29 if (typeof value !== 'string') return NONE
30 const v = squeeze(value)
31 if (isPreset(v)) return PRESETS[v] as Features
32 const on = new Set(v.split(','))
33 return {
34 inject: on.has('inject'), coupled: on.has('coupled'), tool: on.has('tool'), prompt: on.has('prompt'), gate: on.has('gate'),
35 }
36}
37
38// What /verinoda-assist accepts: a preset, or a list of features only; undefined for anything else.
39export function parseAssist(arg: string): string | undefined {
40 const v = squeeze(arg)
41 if (v === '') return undefined
42 if (isPreset(v)) return v
43 const names = v.split(',')
44 return names.every(n => (FEATURE_NAMES as readonly string[]).includes(n)) ? names.join(',') : undefined
45}
46
47export const ASSIST_USAGE =
48 `usage: /verinoda-assist ${Object.keys(PRESETS).join('|')}, or a list of ${FEATURE_NAMES.join(', ')} (for example inject,coupled)`
49
50// What `verinoda locate` and `verinoda coupled` print with --json: the answer as text, and the files behind it.
51export type LocatedFile = { path?: string; tier?: string; why?: string }
52export type Located = { text?: string; files?: LocatedFile[] }
53
54const named = (files: LocatedFile[] | undefined) =>
55 (files ?? []).filter((f): f is LocatedFile & { path: string } => typeof f.path === 'string' && f.path !== '')
56
57const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text)
58
59// The answer the model reads: the CLI's own text when it has one, else built from the files; never longer than `max`;
60// undefined when it found no file (an answer of "nothing" is not worth the model's attention).
61export function renderLocate(located: Located | undefined, max: number): string | undefined {
62 if (located === undefined) return undefined
63 const files = named(located.files)
64 if (Array.isArray(located.files) && files.length === 0) return undefined
65 const given = typeof located.text === 'string' ? located.text.trim() : ''
66 if (given !== '') return cut(given, max)
67 if (files.length === 0) return undefined
68 const lines = files.map(f => `- ${f.path}${f.tier ? ` (${f.tier})` : ''}${f.why ? `: ${f.why}` : ''}`)
69 return cut(['Verinoda locate: files this change may belong in', ...lines].join('\n'), max)
70}
71
72const COUPLED_LISTED = 8
73
74// What the model reads after a file it opened: the files that change together with it, or undefined when there are none.
75export function coupledNote(file: string, located: Located | undefined): string | undefined {
76 const files = named(located?.files).filter(f => f.path !== file)
77 if (files.length === 0) return undefined
78 const lines = files.slice(0, COUPLED_LISTED).map(f => `- ${f.path}${f.why ? `: ${f.why}` : ''}`)
79 return (
80 `[Verinoda coupled] ${file} is usually changed together with these files (git history, same-named and mirrored ` +
81 'files). A fix to it may need the same fix there; they are leads, not rules, so check each:\n' +
82 lines.join('\n')
83 )
84}
85
86// Commands that look for code: grep and its relatives, find, git grep, PowerShell's Select-String. A pipeline or a
87// `cd x && grep` is read segment by segment; the first word of a segment is the command (after any NAME=value).
88const SEARCH_COMMANDS = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack', 'find', 'fd', 'fdfind', 'findstr', 'select-string', 'sls'])
89
90// words that run another command: `sudo grep`, `time rg`, `env LC_ALL=C grep`, `xargs grep`
91const PREFIX_COMMANDS = new Set(['sudo', 'time', 'env', 'nice', 'command', 'exec', 'nohup', 'xargs'])
92
93function startsSearch(segment: string): boolean {
94 const words: string[] = []
95 let isPrefixed = false
96 for (const w of segment.trim().split(/\s+/)) {
97 if (/^[A-Za-z_][\w]*=/.test(w)) continue // NAME=value
98 if (isPrefixed && w.startsWith('-')) continue // an option of the prefix: xargs -n1 grep
99 const base = w.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '')
100 if (words.length === 0 && PREFIX_COMMANDS.has(base)) {
101 isPrefixed = true
102 continue
103 }
104 words.push(w)
105 }
106 const first = (words[0] ?? '').toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '')
107 if (first === 'git') return (words[1] ?? '').toLowerCase() === 'grep'
108 return SEARCH_COMMANDS.has(first)
109}
110
111export function isBlockedSearch(e: { tool: string; command?: unknown }): boolean {
112 if (e.tool === 'Grep' || e.tool === 'Glob') return true
113 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return false
114 return typeof e.command === 'string' && e.command.split(/&&|\|\||[;|\n]/).some(startsSearch)
115}
116
117// Source code, for the notes about what changes together: a file that is neither a test nor a document nor data.
118const CODE_FILE = /\.(c|h|cc|cpp|cxx|hpp|hh|hxx|inl|m|mm|s|asm|py|pyi|pyx|js|jsx|mjs|cjs|ts|tsx|java|kt|kts|scala|go|rs|rb|php|cs|swift|lua|sh|bash|cmake|bf|pml)$/i
119const BUILD_FILE = /^(cmakelists\.txt|makefile|kconfig|kbuild|meson\.build|build\.bazel|build|.*\.mk)$/i
120const TEST_FILE = /(^test_|_test\.|\.test\.|\.spec\.|^conftest\.py$)/i
121const NOT_SOURCE_DIR = new Set([
122 'docs', 'doc', 'documentation', 'test', 'tests', '__tests__', 'spec', 'specs', // not what a fix changes
123 'vendor', 'node_modules', 'third_party', 'thirdparty', 'site-packages', // not the project's own
124])
125
126export function isSourceFile(path: string): boolean {
127 const parts = path.replace(/\\/g, '/').split('/')
128 const base = parts[parts.length - 1] ?? ''
129 if (parts.slice(0, -1).some(p => NOT_SOURCE_DIR.has(p.toLowerCase())) || TEST_FILE.test(base)) return false
130 return CODE_FILE.test(base) || BUILD_FILE.test(base)
131}
132
133export const LOCATE_TOOL = 'mcp__verinoda-live__locate'
134export const COUPLED_TOOL = 'mcp__verinoda-live__coupled'
135
136export const TOOL_SPECS = [
137 {
138 name: 'locate',
139 description:
140 'Locate where a change belongs in this project. Give the text of the bug report or task (and files you already know ' +
141 'are involved). Returns the files most likely to need a change, each with the reason: where the text matches the ' +
142 "code index, files that changed together with those files in the project's git history, same-named or mirrored " +
143 'files in sibling directories, and header/source partners. A fix often belongs in several of them: call this ' +
144 'before searching with grep or find, read what it lists, and name every file that needs the change.',
145 inputSchema: {
146 type: 'object',
147 properties: {
148 text: { type: 'string', description: 'The bug report or task as written (title and body).' },
149 files: { type: 'array', items: { type: 'string' }, description: 'Files you already know are involved (paths relative to the project); optional.' },
150 },
151 required: ['text'],
152 },
153 },
154 {
155 name: 'coupled',
156 description:
157 'List the files that usually change together with the given files: the same commits in the git history, same-named ' +
158 'or mirrored files in sibling directories, header/source partners. Use it on a file you are about to change, to see ' +
159 'where else the same change may belong.',
160 inputSchema: {
161 type: 'object',
162 properties: { files: { type: 'array', items: { type: 'string' }, description: 'Paths relative to the project.' } },
163 required: ['files'],
164 },
165 },
166] as const
167
168// The line the system prompt carries (feature `prompt`), written for what is on.
169export function assistPrompt(f: Features): string {
170 const parts = ['Verinoda, an index of this project\'s code and git history, is connected.']
171 if (f.tool) {
172 parts.push(
173 `When you are given a bug report or a change to make, call ${LOCATE_TOOL} with its text before you search with ` +
174 'grep, find or glob: it lists the files most likely to need a change, including files that usually change ' +
175 'together with them and same-named files in sibling directories.',
176 )
177 }
178 parts.push(
179 'A change often belongs in several files, not only the first one you find. When you say where a fix goes, name ' +
180 'every file that needs the change, siblings and mirrored copies included, not only the first match.',
181 )
182 if (f.tool && f.coupled) parts.push(`${COUPLED_TOOL} gives the same for any file; opening a source file may add that note by itself.`)
183 return parts.join(' ')
184}
185hooks/improve.ts 483 lines1// The improvement checklist pane (D175), the part that needs no `$`: a proposed list is validated and stored, the
2// person's decisions, what may be sent, what the first sight shows, a report of outcomes applied, the three prompts,
3// the two tools and the pane's words. ./improve-pane.tsx wires it to the engine.
4import type { ImproveDecision, ImproveGroup, ImproveItem, ImproveState, ImproveStatus } from '../types'
5
6export const IMPROVE_PANE = 'verinoda-improve'
7export const IMPROVE_PROPOSE = 'mcp__verinoda-live__improve_propose'
8export const IMPROVE_REPORT = 'mcp__verinoda-live__improve_report'
9
10const TITLE_MAX = 120
11const TEXT_MAX = 600
12const EVIDENCE_MAX = 6
13const EVIDENCE_CHARS = 160
14const ITEMS_MAX = 30
15const SUBJECT_MAX = 80
16const EXTRA_MAX = 20
17const EXTRA_CHARS = 300
18export const OWN_MAX = 10
19export const OWN_CHARS = 300
20export const FIRST_SIGHT = 7
21export const FALLBACK_SUBJECT = 'bu konuşma'
22
23const MODEL_GROUPS = ['problem', 'improvement', 'taste'] as const
24const ID_FORM = /^[A-Za-z0-9_-]{1,16}$/
25const OWN_ID_FORM = /^u\d+$/
26
27export function initialImprove(): ImproveState {
28 return {
29 phase: 'idle', subject: '', items: [], open: null, unfolded: [], extra: [], draft: '', ownSeq: 0, expect: '', queue: '', turn: null, note: '',
30 }
31}
32
33// ---- the list and its parts --------------------------------------------------------------------------------------
34
35// The list holds what has no outcome yet or is waiting for one; the results hold the rest. An item is in one of them.
36export const isInList = (i: ImproveItem): boolean => i.outcome === null || i.outcome.status === 'waiting'
37export const isResult = (i: ImproveItem): boolean => !isInList(i)
38export const isSendable = (i: ImproveItem): boolean => (i.decision === 'apply' || i.decision === 'check') && i.outcome === null
39export const isOwn = (i: ImproveItem): boolean => i.group === 'own'
40export const isWaiting = (i: ImproveItem): boolean => i.outcome?.status === 'waiting'
41export const isUnreported = (i: ImproveItem): boolean => i.outcome?.status === 'unreported'
42
43export const listItems = (s: ImproveState): ImproveItem[] => s.items.filter(isInList)
44export const resultItems = (s: ImproveState): ImproveItem[] => s.items.filter(isResult)
45export const sendableItems = (s: ImproveState): ImproveItem[] => s.items.filter(isSendable)
46export const ownItems = (s: ImproveState): ImproveItem[] => s.items.filter(isOwn)
47export const modelItems = (s: ImproveState): ImproveItem[] => s.items.filter(i => !isOwn(i))
48
49// A text cut to `max` characters, trimmed; a cut ends in an ellipsis.
50export function clip(text: string, max: number): string {
51 const t = text.trim()
52 return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`
53}
54
55function strings(value: unknown, max: number, chars: number): string[] {
56 if (!Array.isArray(value)) return []
57 const out: string[] = []
58 for (const v of value) {
59 if (typeof v !== 'string' || v.trim() === '') continue
60 out.push(clip(v, chars))
61 if (out.length === max) break
62 }
63 return out
64}
65
66// ---- a proposed list ---------------------------------------------------------------------------------------------
67
68export type Proposal = { subject?: unknown; items?: unknown }
69
70export type Replaced = { n: number; decided: number }
71
72export type Proposed =
73 | { error: string }
74 | { state: ImproveState; replaced: Replaced | null; counts: { problem: number; improvement: number; taste: number } }
75
76const refused = (what: string) => ({ error: `verinoda improve: ${what}; nothing was shown` })
77
78// Validated whole or not at all: the first failure is the answer. The person's own items that were not sent stay.
79export function proposeList(state: ImproveState, input: Proposal, what: string): Proposed {
80 const raw = input.items
81 if (!Array.isArray(raw) || raw.length < 1 || raw.length > ITEMS_MAX) return refused(`items must be a list of 1 to ${ITEMS_MAX} objects`)
82 const seen = new Set<string>()
83 const items: ImproveItem[] = []
84 for (let n = 0; n < raw.length; n++) {
85 const r = raw[n] as Record<string, unknown> | null
86 const at = `item ${n + 1}`
87 if (typeof r !== 'object' || r === null || Array.isArray(r)) return refused(`${at} is not an object`)
88 const id = typeof r.id === 'string' ? r.id.trim() : ''
89 if (id === '' || !ID_FORM.test(id) || OWN_ID_FORM.test(id)) {
90 return refused(`${at} has an id that is empty or not 1 to 16 letters, digits, - or _ (u and digits are kept for the user's own items)`)
91 }
92 if (seen.has(id)) return refused(`id ${id} is used twice`)
93 seen.add(id)
94 if (!MODEL_GROUPS.includes(r.group as never)) return refused(`item ${id} has a group that is not problem, improvement or taste`)
95 const text = (k: string, max: number): string | undefined => {
96 const v = r[k]
97 return typeof v === 'string' && v.trim() !== '' ? clip(v, max) : undefined
98 }
99 const title = text('title', TITLE_MAX)
100 const observation = text('observation', TEXT_MAX)
101 const why = text('why', TEXT_MAX)
102 const change = text('change', TEXT_MAX)
103 for (const [k, v] of [['title', title], ['observation', observation], ['why', why], ['change', change]] as const) {
104 if (v === undefined) return refused(`item ${id} needs a non-empty ${k}`)
105 }
106 items.push({
107 id, group: r.group as ImproveGroup, title: title ?? '', observation: observation ?? '', why: why ?? '', change: change ?? '',
108 cost: typeof r.cost === 'string' ? clip(r.cost, TEXT_MAX) : '', evidence: strings(r.evidence, EVIDENCE_MAX, EVIDENCE_CHARS),
109 seen: r.seen === true, verified: false, checked: '', decision: 'none', sentAs: null, outcome: null,
110 })
111 }
112 const subject = clip(typeof input.subject === 'string' ? input.subject : '', SUBJECT_MAX) || clip(what, SUBJECT_MAX) || FALLBACK_SUBJECT
113 const old = modelItems(state).filter(isInList)
114 const replaced = old.length === 0 ? null : { n: old.length, decided: old.filter(i => i.decision !== 'none').length }
115 const kept = ownItems(state).filter(isSendable)
116 const own = kept.filter(i => i.outcome === null)
117 const counts = { problem: 0, improvement: 0, taste: 0 }
118 for (const i of items) counts[i.group as keyof typeof counts]++
119 return {
120 state: { ...state, phase: 'choosing', subject, items: [...items, ...own], open: null, unfolded: [], extra: [], queue: '', note: '' },
121 replaced, counts,
122 }
123}
124
125export function proposeAnswer(p: { counts: { problem: number; improvement: number; taste: number }; replaced: Replaced | null }, isPlaced: boolean): string {
126 const n = p.counts.problem + p.counts.improvement + p.counts.taste
127 const tail = p.replaced === null ? '' : ` The earlier list (${p.replaced.n} items, ${p.replaced.decided} decisions) was replaced.`
128 return (isPlaced
129 ? `Shown to the user: ${n} items (${p.counts.problem} possible problems, ${p.counts.improvement} improvements, ${p.counts.taste} matters of taste). ` +
130 'Stop here: change nothing and wait for their selection, which arrives as a message.'
131 : `The list is stored (${n} items) but the pane is not on screen. Tell the user to run /verinoda-improve to see it. Change nothing meanwhile.`) + tail
132}
133
134// ---- decisions ---------------------------------------------------------------------------------------------------
135
136export function canCheck(i: ImproveItem): boolean {
137 return i.group === 'problem' && !i.verified
138}
139
140// Pressing the decision an item has clears it. `check` is for a suspected problem that no check confirmed yet; an own item
141// is `apply` from the start and keeps it.
142export function decide(state: ImproveState, id: string, decision: Exclude<ImproveDecision, 'none'>): ImproveState {
143 const i = state.items.find(x => x.id === id)
144 if (i === undefined || !isInList(i) || isOwn(i) || isWaiting(i)) return state
145 if (decision === 'check' && !canCheck(i)) return state
146 const next: ImproveDecision = i.decision === decision ? 'none' : decision
147 return { ...state, items: state.items.map(x => (x.id === id ? { ...x, decision: next } : x)) }
148}
149
150export function addOwn(state: ImproveState, text: string): ImproveState {
151 const t = clip(text, OWN_CHARS)
152 if (t === '' || ownItems(state).filter(isInList).length >= OWN_MAX) return { ...state, draft: '' }
153 const seq = state.ownSeq + 1
154 const item: ImproveItem = {
155 id: `u${seq}`, group: 'own', title: t, observation: '', why: '', change: '', cost: '', evidence: [], seen: true,
156 verified: false, checked: '', decision: 'apply', sentAs: null, outcome: null,
157 }
158 return { ...state, items: [...state.items, item], ownSeq: seq, draft: '' }
159}
160
161export function removeOwn(state: ImproveState, id: string): ImproveState {
162 const i = state.items.find(x => x.id === id)
163 if (i === undefined || !isOwn(i) || !isInList(i) || isWaiting(i)) return state
164 return { ...state, items: state.items.filter(x => x.id !== id), open: state.open === id ? null : state.open }
165}
166
167export const toggleOpen = (state: ImproveState, id: string): ImproveState => ({ ...state, open: state.open === id ? null : id })
168export const unfold = (state: ImproveState, group: ImproveGroup): ImproveState =>
169 state.unfolded.includes(group) ? state : { ...state, unfolded: [...state.unfolded, group] }
170
171// ---- what the first sight shows ----------------------------------------------------------------------------------
172
173// Of the list's model items the seven best ranked, and besides them any item with a decision, any verified one and the open
174// one. A group that was unfolded shows whole. The person's own items are always shown (their own group is small by design).
175export function visibleIds(state: ImproveState): Set<string> {
176 const list = listItems(state)
177 const out = new Set<string>()
178 list.filter(i => !isOwn(i)).forEach((i, rank) => {
179 if (rank < FIRST_SIGHT || i.decision !== 'none' || i.verified || i.id === state.open || state.unfolded.includes(i.group)) out.add(i.id)
180 })
181 for (const i of list) if (isOwn(i)) out.add(i.id)
182 return out
183}
184
185export type RowView = { id: string; mark: string; title: string; suffix: string; waiting: boolean }
186export type GroupView = { group: ImproveGroup; label: string; rows: RowView[]; more: number }
187
188const GROUP_LABEL: Record<ImproveGroup, string> = {
189 problem: 'Olası sorunlar', improvement: 'İşlevsel iyileştirmeler', taste: 'Estetik seçenekler', own: 'Sizin eklediğiniz',
190}
191const MARK: Record<ImproveDecision, string> = { none: '[ ]', apply: '[✓]', keep: '[–]', check: '[?]' }
192
193export function rowSuffix(i: ImproveItem): string {
194 if (i.verified) return ' (doğrulandı)'
195 if (i.group === 'problem' && i.evidence.length === 0) return ' (kanıtsız)'
196 if (i.group === 'taste' && !i.seen) return ' (tahmin)'
197 return ''
198}
199
200export function groupViews(state: ImproveState): GroupView[] {
201 const shown = visibleIds(state)
202 const list = listItems(state)
203 const views: GroupView[] = []
204 for (const group of ['problem', 'improvement', 'taste', 'own'] as const) {
205 const all = list.filter(i => i.group === group)
206 if (all.length === 0) continue
207 const rows = all.filter(i => shown.has(i.id)).map(i => ({
208 id: i.id, mark: MARK[i.decision], title: i.title, suffix: rowSuffix(i), waiting: isWaiting(i),
209 }))
210 views.push({ group, label: GROUP_LABEL[group], rows, more: all.length - rows.length })
211 }
212 return views
213}
214
215export function countsLine(state: ImproveState): string {
216 const list = listItems(state)
217 if (list.length === 0) return ''
218 const send = sendableItems(state)
219 const parts = [
220 `${list.length} madde`,
221 ...([[send.filter(i => i.decision === 'apply').length, 'uygula'], [send.filter(i => i.decision === 'check').length, 'kontrol'],
222 [list.filter(i => i.decision === 'keep').length, 'kalsın']] as const).filter(([n]) => n > 0).map(([n, w]) => `${n} ${w}`),
223 ]
224 return parts.join(' · ')
225}
226
227export const RESULT_WORDS: Record<Exclude<ImproveStatus, 'waiting'>, string> = {
228 applied: '✓ Uygulandı', partial: '◐ Kısmen uygulandı', failed: '✗ Uygulanamadı',
229 not_confirmed: '○ İncelendi: sorun doğrulanmadı', unreported: '? Sonuç bildirilmedi',
230}
231
232export const WORDS = {
233 idle: 'Liste yok. /verinoda-improve [neye bakılsın] ile başlatın.',
234 notSent: 'İstek gönderilemedi.',
235 reviewing: 'İnceleniyor… Claude listeyi hazırlıyor; hiçbir şey değiştirilmiyor.',
236 noList: 'Liste henüz gelmedi. Claude bir soru sormuş olabilir: yanıtlayın ya da /verinoda-improve ile yeniden başlatın.',
237 sent: 'Seçimleriniz gönderildi; sonuçlar burada görünecek.',
238 needOne: 'Göndermek için en az bir madde seçin.',
239 mobileOwn: 'Kendi maddenizi eklemek için terminali ya da masaüstünü kullanın.',
240 unfocused: 'Seçmek için ctrl+x tab',
241 results: 'Sonuçlar',
242 extra: 'Listede olmayan değişiklikler',
243 own: 'Kendi maddeniz',
244 noEvidence: 'Kanıt verilmedi',
245 waiting: ' … bekleniyor',
246}
247
248// ---- sending and what comes back ---------------------------------------------------------------------------------
249
250// Every sendable item gets `sentAs` (its decision) and the outcome waiting; its decision, and so its mark, stays.
251export function markSent(state: ImproveState): ImproveState {
252 return {
253 ...state, phase: 'sent', expect: 'selection', turn: null, note: '',
254 items: state.items.map(i => (isSendable(i)
255 ? { ...i, sentAs: i.decision === 'check' ? 'check' as const : 'apply' as const, outcome: { status: 'waiting' as const, note: '', evidence: [] } }
256 : i)),
257 }
258}
259
260// The person stops waiting, or the mod's own turn ended: what is still waiting is unreported, and the list can be marked again.
261export function unreport(state: ImproveState): ImproveState {
262 return {
263 ...state, phase: 'choosing',
264 items: state.items.map(i => (isWaiting(i) ? { ...i, outcome: { status: 'unreported' as const, note: '', evidence: [] } } : i)),
265 }
266}
267
268// A prompt that did not enter: the state before the press, so that the same marks can be sent again.
269export function undoSend(state: ImproveState, kind: 'review' | 'selection' | 'report'): ImproveState {
270 const base = { ...state, expect: '' as const, queue: '' as const, note: 'not-sent' as const }
271 if (kind === 'review') return { ...base, phase: 'idle' }
272 if (kind === 'selection') {
273 return { ...base, phase: 'choosing', items: state.items.map(i => (isWaiting(i) ? { ...i, sentAs: null, outcome: null } : i)) }
274 }
275 return {
276 ...base, phase: 'choosing',
277 items: state.items.map(i => (isWaiting(i) ? { ...i, outcome: { status: 'unreported' as const, note: '', evidence: [] } } : i)),
278 }
279}
280
281export function askReport(state: ImproveState): ImproveState {
282 return {
283 ...state, phase: 'sent', expect: 'report', turn: null, note: '',
284 items: state.items.map(i => (isUnreported(i) ? { ...i, outcome: { status: 'waiting' as const, note: '', evidence: [] } } : i)),
285 }
286}
287
288export function startReview(state: ImproveState, what: string): ImproveState {
289 return {
290 ...state, phase: 'reviewing', subject: what, items: ownItems(state).filter(isSendable), open: null, unfolded: [], extra: [],
291 expect: '', queue: 'review', turn: null, note: '',
292 }
293}
294
295export type Report = { outcomes?: unknown; extra_changes?: unknown }
296const REPORT_STATUSES = ['applied', 'partial', 'failed', 'confirmed', 'not_confirmed'] as const
297const FITS: Record<'apply' | 'check', readonly string[]> = { apply: ['applied', 'partial', 'failed'], check: ['confirmed', 'not_confirmed'] }
298
299export type Reported = { error: string } | { state: ImproveState; answer: string }
300
301// A call with any invalid outcome records nothing.
302export function applyReport(state: ImproveState, input: Report): Reported {
303 const fail = (what: string) => ({ error: `verinoda improve: ${what}` })
304 const raw = input.outcomes
305 if (!Array.isArray(raw) || raw.length < 1 || raw.some(o => typeof o !== 'object' || o === null || Array.isArray(o))) {
306 return fail('outcomes must list at least one item; nothing was recorded')
307 }
308 const outs = raw as Record<string, unknown>[]
309 const ids = outs.map(o => (typeof o.id === 'string' ? o.id.trim() : ''))
310 const twice = ids.find((id, n) => ids.indexOf(id) !== n)
311 if (twice !== undefined) return fail(`item ${twice} is reported twice`)
312 const byId = new Map(state.items.map(i => [i.id, i]))
313 for (const id of ids) {
314 const i = byId.get(id)
315 if (i === undefined || !(isWaiting(i) || isUnreported(i))) return fail(`no item ${id} is waiting for an outcome`)
316 }
317 for (const [n, o] of outs.entries()) {
318 const i = byId.get(ids[n] ?? '') as ImproveItem
319 const sent = i.sentAs ?? 'apply'
320 if (!REPORT_STATUSES.includes(o.status as never) || !FITS[sent].includes(o.status as string)) {
321 return fail(`item ${i.id} was sent to ${sent}; its outcome is one of ${FITS[sent].join(sent === 'apply' ? ', ' : ' or ').replace(/, ([a-z_]+)$/, ' or $1')}`)
322 }
323 }
324 for (const [n, o] of outs.entries()) {
325 if (typeof o.note !== 'string' || o.note.trim() === '') return fail(`item ${ids[n]} needs a note the user can read`)
326 }
327 const items = state.items.map(i => {
328 const n = ids.indexOf(i.id)
329 if (n < 0) return i
330 const o = outs[n] as Record<string, unknown>
331 const note = clip(o.note as string, TEXT_MAX)
332 const evidence = strings(o.evidence, EVIDENCE_MAX, EVIDENCE_CHARS)
333 if (o.status === 'confirmed') {
334 const change = typeof o.change === 'string' && o.change.trim() !== '' ? clip(o.change, TEXT_MAX) : i.change
335 return {
336 ...i, verified: true, checked: note, outcome: null, decision: 'none' as const, sentAs: null, change,
337 evidence: [...i.evidence, ...evidence].slice(0, EVIDENCE_MAX),
338 }
339 }
340 return { ...i, outcome: { status: o.status as ImproveStatus, note, evidence } }
341 })
342 const extra = [...state.extra, ...strings(input.extra_changes, EXTRA_MAX, EXTRA_CHARS)].slice(0, EXTRA_MAX)
343 const waiting = items.filter(isWaiting)
344 const next: ImproveState = { ...state, items, extra, phase: state.phase === 'sent' && waiting.length === 0 ? 'choosing' : state.phase }
345 const still = waiting.length > 0 ? ` Still waiting for: ${waiting.map(i => i.id).join(', ')}.` : ''
346 return { state: next, answer: `Recorded: ${outs.length} outcomes.${still}` }
347}
348
349// ---- the three prompts -------------------------------------------------------------------------------------------
350
351export function reviewPrompt(what: string): string {
352 const w = what.trim()
353 return [
354 'Make a list of what could be changed here so that I can choose. Do not change anything yet.',
355 `What to look at: ${w === '' ? 'what we have been working on in this conversation. If that is not clear, ask me one question first.' : w}`,
356 '',
357 'Look at it first: read the code, and if it has a rendered view you can look at, look at it. Then call',
358 `${IMPROVE_PROPOSE} with the items, ranked, the most worth doing first. Do not pad the list. Then stop and`,
359 'wait for my selection.',
360 ].join('\n')
361}
362
363export function selectionPrompt(state: ImproveState): string {
364 const send = sendableItems(state)
365 const line = (i: ImproveItem, withChange: boolean) =>
366 isOwn(i) ? `- [${i.id}] ${i.title} (my own item)` : `- [${i.id}] ${i.title}${withChange ? `: ${i.change}` : ''}`
367 const section = (head: string, items: ImproveItem[], withChange: boolean) =>
368 items.length === 0 ? [] : [head, ...items.map(i => line(i, withChange)), '']
369 const keep = listItems(state).filter(i => i.decision === 'keep' && !isOwn(i))
370 return [
371 `My decisions on the improvement list (${state.subject}):`,
372 '',
373 ...section('Apply these:', send.filter(i => i.decision === 'apply'), true),
374 ...section('Check these first (investigate only; change nothing for them):', send.filter(i => i.decision === 'check'), false),
375 ...section('Keep these as they are (do not change them, also not as a side effect):', keep, false),
376 'Everything else stays as it is. You may make the small auxiliary changes an item needs. If one would add behaviour, change',
377 'behaviour I did not choose, or cost something, tell me before you do it. When you are done or cannot go on, call',
378 `${IMPROVE_REPORT} with one outcome for each item to apply or to check, and list under extra_changes any`,
379 'change that was not on the list.',
380 ].join('\n')
381}
382
383export function reportPrompt(ids: string[]): string {
384 return [
385 `Report the outcome of the items I sent that you have not reported yet (${ids.join(', ')}) with ${IMPROVE_REPORT}.`,
386 'Change nothing else.',
387 ].join('\n')
388}
389
390// A turn belongs to the mod when its text starts with the first line of the prompt that was submitted.
391export function firstLine(prompt: string): string {
392 return (prompt.split('\n')[0] ?? '').trim()
393}
394
395export function expectedLine(state: ImproveState): string {
396 if (state.expect === 'review') return firstLine(reviewPrompt(''))
397 if (state.expect === 'selection') return firstLine(selectionPrompt(state))
398 if (state.expect === 'report') return firstLine(reportPrompt(state.items.filter(isWaiting).map(i => i.id)))
399 return ''
400}
401
402// ---- the tools ---------------------------------------------------------------------------------------------------
403
404export const IMPROVE_SPECS = [
405 {
406 name: 'improve_propose',
407 description:
408 'Show the user a list of what could be changed, in the Verinoda pane, so that they choose. Call it only when the user asked ' +
409 'for improvement options or a review list, in words or with /verinoda-improve; never on your own initiative. Before calling, ' +
410 'look at the code or the screen in question and change nothing. Rank the items, the most worth doing first, and do not pad ' +
411 'the list. Each item is one change the user can say yes or no to. `group`: `problem` is a possible bug, inconsistency or ' +
412 'unexpected behaviour (give `evidence`, the file:line you read); `improvement` is usability, readability, performance or ' +
413 'upkeep; `taste` is look, tone, density, colour or layout, a preference and not a defect (set `seen` to true only if you ' +
414 'judged it from a rendered view or a screenshot). `title` says the change in one concrete line (not "improve the hierarchy" ' +
415 'but "make Save the only primary button"); `observation` is what is there now; `why` is why it may matter; `change` is ' +
416 'exactly what you would do and what stays as it is; `cost` is what it costs or risks, if anything. After the call, stop: ' +
417 'change nothing and wait for the user\'s selection, which arrives as a message.',
418 inputSchema: {
419 type: 'object',
420 properties: {
421 subject: { type: 'string', description: 'What was looked at, in a few words: a file, a screen, a module' },
422 items: {
423 type: 'array', minItems: 1, maxItems: 30,
424 items: {
425 type: 'object',
426 properties: {
427 id: { type: 'string', description: 'Short and unique in this list: i1, i2, ...' },
428 group: { enum: ['problem', 'improvement', 'taste'] },
429 title: { type: 'string' },
430 observation: { type: 'string' },
431 why: { type: 'string' },
432 change: { type: 'string' },
433 cost: { type: 'string' },
434 evidence: { type: 'array', items: { type: 'string' } },
435 seen: { type: 'boolean' },
436 },
437 required: ['id', 'group', 'title', 'observation', 'why', 'change'],
438 },
439 },
440 },
441 required: ['subject', 'items'],
442 },
443 },
444 {
445 name: 'improve_report',
446 description:
447 'Report what happened to each item the user sent from the Verinoda pane, once you are done or cannot go on. One outcome per ' +
448 'item id you were given. For an item to apply: `applied` (done as described), `partial` (say what is missing) or `failed` ' +
449 '(say why). For an item to check first: `confirmed` (the problem is real; put the fix you propose in `change` and do not ' +
450 'apply it) or `not_confirmed` (you looked and found nothing that needs a change). `note` is one or two sentences the user ' +
451 'reads; `evidence` is what shows it: the check you ran and its result, a file:line. List under `extra_changes` every change ' +
452 'you made that was not on the list.',
453 inputSchema: {
454 type: 'object',
455 properties: {
456 outcomes: {
457 type: 'array', minItems: 1,
458 items: {
459 type: 'object',
460 properties: {
461 id: { type: 'string' },
462 status: { enum: ['applied', 'partial', 'failed', 'confirmed', 'not_confirmed'] },
463 note: { type: 'string' },
464 change: { type: 'string' },
465 evidence: { type: 'array', items: { type: 'string' } },
466 },
467 required: ['id', 'status', 'note'],
468 },
469 },
470 extra_changes: { type: 'array', items: { type: 'string' } },
471 },
472 required: ['outcomes'],
473 },
474 },
475] as const
476
477// The one line the system prompt carries when `improveOffer` is on.
478export const OFFER_SECTION =
479 'When the user asks for something to be better, nicer, cleaner or of higher quality without saying what should change, do ' +
480 'not guess and do not start editing. Offer in one line to list what could be changed so that they choose: ' +
481 '`/verinoda-improve` opens the list, and they may add what to look at. If they ask for the list in words, look first, then ' +
482 `call \`${IMPROVE_PROPOSE}\`. A request that says what to change is not vague: do it.`
483hooks/improve-pane.tsx 305 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { ImproveGroup, ImproveItem, ImproveState } from '../types'
5import {
6 addOwn, applyReport, askReport, canCheck, countsLine, decide, expectedLine, groupViews, IMPROVE_PANE, IMPROVE_PROPOSE, IMPROVE_REPORT,
7 IMPROVE_SPECS, initialImprove, isInList, isUnreported, isWaiting, listItems, markSent, modelItems, OFFER_SECTION, OWN_MAX,
8 proposeAnswer, proposeList, removeOwn, reportPrompt, resultItems, RESULT_WORDS, selectionPrompt, sendableItems,
9 startReview, toggleOpen, undoSend, unfold, unreport, WORDS, ownItems,
10} from './improve'
11import type { Proposal, Report } from './improve'
12
13// One key of the session's state holds all of it (declared in ../types); a reload keeps it. The feature is why this file
14// exists apart from register.tsx: its drawing and hooks are a module of their own that `register` calls once (D175).
15export const improve = atom({ plugin: 'verinoda-live', key: 'improve' } as const, initialImprove())
16
17export const COMMAND = {
18 name: 'verinoda-improve',
19 description: 'List what could be changed here and choose what to apply; nothing changes until you choose.',
20 argumentHint: '[what to look at]',
21}
22const TITLE = 'İyileştirme'
23const PANE_ROWS = 16
24
25// The session's interactivity, kept from `session.start` as everywhere in this mod: nobody at the keyboard gets none of it.
26export const mod = { interactive: false, offered: false }
27
28// Work left running after a hook returns: a rejection after the module unloaded is no error of the person's.
29function background(work: Promise<unknown>): void {
30 work.catch(() => undefined)
31}
32
33const patch = ($: EngineInterface, fn: (s: ImproveState) => ImproveState) => update($, improve, fn).then(() => undefined)
34
35async function registerTools($: EngineInterface): Promise<void> {
36 for (const spec of IMPROVE_SPECS) {
37 await $.tool.register({ name: spec.name, description: spec.description, inputSchema: spec.inputSchema as unknown as Record<string, unknown> })
38 }
39}
40
41const openPane = ($: EngineInterface) => $.ui.open({ id: IMPROVE_PANE, title: TITLE, focus: true, rows: PANE_ROWS })
42
43// A prompt the mod submits is the person's own decision, read bare by the model. Never awaited by a press (the turn may not be
44// able to start until the handler returns); one that did not enter puts the state back as it was. (The review prompt is
45// submitted from register.tsx, from turn.complete: the host refuses a submit from a command's hook.)
46function submit($: EngineInterface, kind: 'selection' | 'report', text: string): void {
47 const back = () => patch($, s => (s.expect === kind ? undoSend(s, kind) : s))
48 background($.prompt.submit({ text, asUser: true }).then(r => (r.drop === undefined ? undefined : back()), back))
49}
50
51// The turn the mod's own prompt started has ended, or was interrupted.
52export function endOfTurn(s: ImproveState): ImproveState {
53 const base = { ...s, turn: null }
54 if (s.phase === 'sent') return unreport(base)
55 if (s.phase === 'reviewing') return { ...base, note: 'no-list' }
56 return base
57}
58
59// The three events `register.tsx` already hooks without a matcher (a second such hook is refused) reach this feature through
60// what is exported here; `$` is never passed across an import, so the few lines that need it stay in `register.tsx`.
61export function improveSections(sections: readonly { id: string; text: string; scope: 'session' | 'shared' }[]) {
62 if (!mod.offered || !mod.interactive) return sections
63 return [...sections, { id: 'verinoda-live:improve', text: OFFER_SECTION, scope: 'session' as const }]
64}
65
66export function registerImprove(on: On): void {
67 // First-class, as the assist tools are: a tool the model must look up first is not called.
68 on('tool.describe', { tool: [IMPROVE_PROPOSE, IMPROVE_REPORT] }, ($, e) => {
69 const spec = IMPROVE_SPECS.find(s => `mcp__verinoda-live__${s.name}` === e.tool)
70 return { description: spec?.description ?? e.description, isDeferred: false }
71 })
72
73 on('command.run', { command: COMMAND.name }, async ($, e) => {
74 if (!mod.interactive) return { text: 'verinoda improve needs a person to choose: it does nothing in a scripted session' }
75 const what = e.args.trim()
76 const s = await read($, improve)
77 const size = listItems(s).length
78 if (s.phase === 'sent') {
79 await openPane($)
80 return { text: 'a selection is being applied; its outcomes will show in the pane' }
81 }
82 if (s.phase === 'choosing' && what === '' && (size > 0 || s.items.some(isUnreported))) {
83 await openPane($)
84 return { text: `improvement list opened (${size} items)` }
85 }
86 // the list this replaces: told only when the person had decided something in it
87 const old = modelItems(s).filter(isInList)
88 const replaced = old.some(i => i.decision !== 'none') ? ` (the earlier list of ${old.length} items was replaced)` : ''
89 await registerTools($)
90 await patch($, x => startReview(x, what))
91 await openPane($)
92 // the review prompt goes out when this command's own turn ends (register.tsx): the host refuses a submit from here
93 return { text: `looking at it; the list will appear in the pane, and nothing is changed until you choose${replaced}` }
94 })
95
96 on('tool.call', { tool: IMPROVE_PROPOSE }, async ($, e) => {
97 if (!mod.interactive) return { result: 'verinoda improve: nobody is at the keyboard to choose; ask in text instead' }
98 const s = await read($, improve)
99 if (s.phase === 'sent') {
100 return { result: 'verinoda improve: a selection is being applied; report its outcomes with improve_report before proposing a new list' }
101 }
102 const p = proposeList(s, e as unknown as Proposal, s.phase === 'reviewing' ? s.subject : '')
103 if ('error' in p) return { result: p.error }
104 await patch($, () => p.state)
105 const opened = await openPane($)
106 return { result: proposeAnswer(p, opened.isPlaced) }
107 })
108
109 on('tool.call', { tool: IMPROVE_REPORT }, async ($, e) => {
110 const s = await read($, improve)
111 const r = applyReport(s, e as unknown as Report)
112 if ('error' in r) return { result: r.error }
113 await patch($, () => r.state)
114 return { result: r.answer }
115 })
116
117 // Which turn is the mod's own: the one whose text starts with the first line of the prompt that was submitted.
118 on('turn.start', async ($, e, next) => {
119 const s = await read($, improve)
120 if (s.expect !== '' && e.text.trim().startsWith(expectedLine(s))) await patch($, x => ({ ...x, turn: e.turnId, expect: '' }))
121 return next(e)
122 })
123
124 // `/clear` ends the conversation without a `session.start` after it, so the list of the old one goes.
125 on('session.end', async ($, e, next) => {
126 if (e.reason === 'clear') await patch($, initialImprove)
127 return next(e)
128 })
129
130 on('ui.render', { component: 'Pane', requestId: IMPROVE_PANE }, async ($, e) => {
131 const { Box, Button, Text, Input } = $.ui.resolve(e) as ReturnType<typeof $.ui.resolve> & { Input?: unknown }
132 const InputEl = Input as ((props: Record<string, unknown>) => JSX.Element) | undefined
133 const s = await read($, improve)
134 const columns = Math.max(20, e.props.bodyColumns ?? 48)
135 const isNarrow = columns < 40
136
137 if (s.phase === 'idle') {
138 return (
139 <Box flexDirection="column">
140 <Text wrap="wrap">{WORDS.idle}</Text>
141 {s.note === 'not-sent' && <Text key="not-sent" color="yellow" wrap="wrap">{WORDS.notSent}</Text>}
142 </Box>
143 )
144 }
145 if (s.phase === 'reviewing') {
146 return (
147 <Box flexDirection="column">
148 <Text wrap="wrap">{s.note === 'no-list' ? WORDS.noList : WORDS.reviewing}</Text>
149 {s.note === 'not-sent' && <Text key="not-sent" color="yellow" wrap="wrap">{WORDS.notSent}</Text>}
150 </Box>
151 )
152 }
153
154 const isSent = s.phase === 'sent'
155 const results = resultItems(s)
156 const views = groupViews(s)
157 const sendN = sendableItems(s).length
158 const counts = countsLine(s)
159 const openItem = s.items.find(i => i.id === s.open && isInList(i))
160
161 const detail = (i: ImproveItem) => {
162 const line = (k: string, label: string, text: string) => <Text key={k} wrap="wrap">{` ${label}: ${text}`}</Text>
163 if (i.group === 'own') {
164 return (
165 <Box key={`open-${i.id}`} flexDirection="column">
166 {!isSent && !isWaiting(i) && <Button key="act-remove" label="Kaldır" variant="secondary" onPress={() => background(patch($, x => removeOwn(x, i.id)))} />}
167 </Box>
168 )
169 }
170 const evidence = i.evidence.length > 0 ? i.evidence.join(', ') : i.group === 'problem' ? WORDS.noEvidence : ''
171 const pick = (d: 'apply' | 'keep' | 'check', label: string, hotkey: string) => (
172 <Button key={`act-${d}`} label={`${i.decision === d ? '● ' : ''}${label}`} hotkey={hotkey}
173 variant={i.decision === d ? 'primary' : 'secondary'} onPress={() => background(patch($, x => decide(x, i.id, d)))} />
174 )
175 return (
176 <Box key={`open-${i.id}`} flexDirection="column">
177 {line('obs', 'Gözlem', i.observation)}
178 {line('why', 'Neden', i.why)}
179 {line('chg', 'Öneri', i.change)}
180 {i.cost !== '' && line('cost', 'Bedel', i.cost)}
181 {evidence !== '' && line('evd', 'Kanıt', evidence)}
182 {i.verified && line('chk', 'İnceleme', i.checked)}
183 {!isSent && (
184 <Box flexDirection={isNarrow ? 'column' : 'row'} columnGap={1}>
185 {pick('apply', 'Uygula', 'u')}
186 {pick('keep', 'Kalsın', 'k')}
187 {canCheck(i) && pick('check', 'Önce kontrol et', 'c')}
188 </Box>
189 )}
190 </Box>
191 )
192 }
193
194 const resultBlock = results.length === 0 ? null : (
195 <Box key="results" flexDirection="column">
196 <Text bold>{WORDS.results}</Text>
197 {results.map(i => {
198 const status = (i.outcome?.status ?? 'unreported') as keyof typeof RESULT_WORDS
199 const tone = status === 'applied' ? 'green' : status === 'failed' ? 'red' : status === 'not_confirmed' ? undefined : 'yellow'
200 return (
201 <Box key={`res-${i.id}`} flexDirection="column">
202 <Text color={tone} dimColor={status === 'not_confirmed'} wrap="wrap">{` ${RESULT_WORDS[status]} · ${i.title}`}</Text>
203 {i.outcome !== null && i.outcome.note !== '' && <Text key="note" wrap="wrap">{` ${i.outcome.note}`}</Text>}
204 {i.outcome !== null && i.outcome.evidence.length > 0 && <Text key="evd" dimColor wrap="wrap">{` ${i.outcome.evidence.join(', ')}`}</Text>}
205 </Box>
206 )
207 })}
208 {!isSent && results.some(isUnreported) && (
209 <Button key="ask-report" label="Sonucu iste" variant="secondary"
210 onPress={() => background(askFor($))} />
211 )}
212 </Box>
213 )
214
215 const extraBlock = s.extra.length === 0 ? null : (
216 <Box key="extra" flexDirection="column">
217 <Text bold>{WORDS.extra}</Text>
218 {s.extra.map((x, n) => <Text key={`extra-${n}`} wrap="wrap">{` · ${x}`}</Text>)}
219 </Box>
220 )
221
222 const groupBlock = (g: { group: ImproveGroup; label: string; rows: { id: string; mark: string; title: string; suffix: string; waiting: boolean }[]; more: number }) => (
223 <Box key={`group-${g.group}`} flexDirection="column">
224 <Text bold>{g.label}</Text>
225 {g.rows.map(r => (
226 <Box key={`row-${r.id}`} flexDirection="column">
227 {isSent
228 ? <Text key={`item-${r.id}`} wrap="wrap">{` ${r.mark} ${r.title}${r.suffix}${r.waiting ? WORDS.waiting : ''}`}</Text>
229 : <Button key={`item-${r.id}`} plain label={`${r.mark} ${r.title}${r.suffix}`} onPress={() => background(patch($, x => toggleOpen(x, r.id)))} />}
230 {openItem !== undefined && openItem.id === r.id && detail(openItem)}
231 </Box>
232 ))}
233 {!isSent && g.more > 0 && g.group !== 'own' && (
234 <Button key={`more-${g.group}`} plain label={`${g.more} madde daha`} onPress={() => background(patch($, x => unfold(x, g.group)))} />
235 )}
236 </Box>
237 )
238
239 const ownCount = ownItems(s).filter(isInList).length
240 let ownEl = null
241 if (!isSent && ownCount < OWN_MAX) {
242 ownEl = e.surface === 'mobile' || InputEl === undefined
243 ? <Text key="own-mobile" dimColor wrap="wrap">{WORDS.mobileOwn}</Text>
244 : (
245 <InputEl key="own" label={WORDS.own} submitLabel="ekle" value={s.draft}
246 onInput={(v: string) => background(patch($, x => ({ ...x, draft: v })))}
247 onSubmit={(v: string) => background(patch($, x => addOwn(x, v)))} />
248 )
249 }
250
251 const sendEl = isSent
252 ? (
253 <Box key="sent" flexDirection="column">
254 <Text wrap="wrap">{WORDS.sent}</Text>
255 <Button key="stop-waiting" label="Beklemeyi bırak" variant="secondary"
256 onPress={() => background(patch($, x => ({ ...unreport(x), expect: '', turn: null, note: '' })))} />
257 </Box>
258 )
259 : sendN > 0
260 ? <Button key="send" label={`Gönder (${sendN})`} hotkey="g" variant="primary" onPress={() => background(sendSelection($))} />
261 : <Text key="need-one" dimColor wrap="wrap">{WORDS.needOne}</Text>
262
263 const keys = isSent ? [] : [sendN > 0 ? 'g gönder' : '', views.some(v => v.rows.length > 0) ? 'tab gezin · enter aç' : ''].filter(Boolean)
264 const footer = e.props.isFocused ? keys.join(' · ') : WORDS.unfocused
265
266 return (
267 <Box flexDirection="column" rowGap={1}>
268 <Box key="head" flexDirection="column">
269 <Text bold wrap="wrap">{`İyileştirme: ${s.subject}`}</Text>
270 {counts !== '' && <Text dimColor wrap="wrap">{counts}</Text>}
271 </Box>
272 {s.note === 'not-sent' && <Text key="not-sent" color="yellow" wrap="wrap">{WORDS.notSent}</Text>}
273 {resultBlock}
274 {extraBlock}
275 {views.map(groupBlock)}
276 {ownEl}
277 {sendEl}
278 {footer !== '' && <Text key="footer" dimColor wrap="wrap">{footer}</Text>}
279 </Box>
280 )
281 })
282}
283
284// Press handlers kept beside the drawing: both read the list as it is when pressed, mark it and then raise the prompt.
285async function sendSelection($: EngineInterface): Promise<void> {
286 let text = ''
287 await patch($, s => {
288 if (s.phase !== 'choosing' || sendableItems(s).length === 0) return s
289 text = selectionPrompt(s)
290 return markSent(s)
291 })
292 if (text !== '') submit($, 'selection', text)
293}
294
295async function askFor($: EngineInterface): Promise<void> {
296 let text = ''
297 await patch($, s => {
298 const ids = s.items.filter(isUnreported).map(i => i.id)
299 if (s.phase !== 'choosing' || ids.length === 0) return s
300 text = reportPrompt(ids)
301 return askReport(s)
302 })
303 if (text !== '') submit($, 'report', text)
304}
305hooks/mascot.tsx 91 lines1import type { ClientModule } from 'claude-code'
2
3// Claude's mascot in purple, reading the code through its glasses: 20 x 8, block glyphs only (single cells in
4// Cascadia Mono), every body cell a full block so the background never shows through the face. Animated the way
5// Claude Code's mascot is: it blinks every few seconds and shuffles its feet; while Verinoda works (`busy`) it reads,
6// a glint sweeping across its lenses, its feet and arms quicker. Drawn by the surface (a `Client`), so a frame
7// repaints only this region: no render pass, no hook.
8
9export type Mood = 'idle' | 'busy' | 'warn' | 'err'
10export type MascotProps = { mood: Mood; caption: string; captionColor: string; captionDim: boolean }
11export type Segment = readonly [string, string | null] // text, colour (null: uncoloured padding)
12
13const HEAD = '#a78bfa'
14const BODY = '#8b5cf6'
15const SIDE = '#7c3aed'
16const FRAME = '#5b21b6'
17const LENS = '#1e1b4b'
18const GLINT = '#f5f3ff'
19const BLUSH = '#f0abfc'
20const MOUTH = '#4c1d95'
21const BELLY = '#6d28d9'
22
23export const MASCOT_WIDTH = 20
24export const MASCOT_HEIGHT = 8
25export const TICK_MS = 120
26
27const BLINK_EVERY_MS = 4_200 // a blink at the end of each cycle
28const BLINK_MS = 160
29const STEP_MS: Record<Mood, number> = { idle: 840, busy: 240, warn: 840, err: 840 }
30const SWEEP_MS = 360 // busy: the glint moves one cell along the lens
31
32// One lens on the glasses row: five cells, the glint at `at` (0-4), the rest dark; closed, the lid in head colour.
33function lens(at: number, isClosed: boolean): Segment[] {
34 if (isClosed) return [['█████', HEAD]]
35 return [0, 1, 2, 3, 4].map(i => (i === at ? ['█', GLINT] : ['█', LENS]) as Segment)
36}
37
38// The frame `t` ms into the animation for this mood, as rows of coloured segments, every row 20 cells.
39export function frameRows(t: number, mood: Mood): Segment[][] {
40 const isBlink = t % BLINK_EVERY_MS >= BLINK_EVERY_MS - BLINK_MS
41 const step = Math.floor(t / STEP_MS[mood]) % 2
42 const glint = mood === 'busy' ? Math.floor(t / SWEEP_MS) % 5 : 0
43 const arms: [string, string] = mood === 'busy' && step === 1 ? ['▝█', '█▘'] : ['▐█', '█▌']
44 const feet = step === 0 ? '▀▀ ▀▀ ▀▀ ▀▀' : '▀▀ ▀▀ ▀▀ ▀▀'
45 return [
46 [[' ', null], ['▄▄▄▄▄▄▄▄▄▄▄▄▄▄', HEAD], [' ', null]],
47 [[' ', null], ['████████████████', HEAD], [' ', null]],
48 [[' ', null], ['─', SIDE], ['█', FRAME], ...lens(glint, isBlink), ['████', FRAME], ...lens(glint, isBlink), ['█', FRAME], ['─', SIDE], [' ', null]],
49 [[' ', null], ['█', FRAME], ['█████', isBlink ? HEAD : LENS], ['████', HEAD], ['█████', isBlink ? HEAD : LENS], ['█', FRAME], [' ', null]],
50 [[' ', null], ['█', HEAD], ['██', BLUSH], ['████', HEAD], ['██', MOUTH], ['████', HEAD], ['██', BLUSH], ['█', HEAD], [' ', null]],
51 [[arms[0], SIDE], ['████████████████', BODY], [arms[1], SIDE]],
52 [[' ', null], ['████████████████', BELLY], [' ', null]],
53 [[' ', null], [feet, SIDE], [' ', null]],
54 ]
55}
56
57// While busy the caption's dots cycle ("Kodu okuyorum.", "..", "..."); otherwise it is shown as given.
58export function captionAt(t: number, mood: Mood, caption: string): string {
59 if (mood !== 'busy') return caption
60 const base = caption.replace(/[.…]+$/, '')
61 return base + '.'.repeat(1 + (Math.floor(t / 400) % 3))
62}
63
64type State = { t: number }
65
66const Mascot: ClientModule<MascotProps, State> = (props, surface) => {
67 const { Box, Text } = surface.elements
68 if (surface.state === undefined) {
69 // one clock for the instance's life: started on the first call, never again
70 let t = 0
71 surface.every(TICK_MS, () => {
72 t += TICK_MS
73 surface.setState({ t })
74 })
75 surface.setState({ t: 0 })
76 }
77 const t = surface.state?.t ?? 0
78 return (
79 <Box flexDirection="column" alignItems="center">
80 {frameRows(t, props.mood).map((row, i) => (
81 <Text key={`r${i}`} wrap="truncate">
82 {row.map(([text, color], j) => (color === null ? text : <Text key={`r${i}s${j}`} color={color}>{text}</Text>))}
83 </Text>
84 ))}
85 <Text color={props.captionColor} dimColor={props.captionDim} wrap="truncate">{captionAt(t, props.mood, props.caption)}</Text>
86 </Box>
87 )
88}
89
90export default Mascot
91types/index.d.ts 134 lines1// What the verinoda-live pane draws from, held by the host for the session (the two settings are also kept in the
2// mod's store across sessions).
3
4// off: nothing; nudge: ask the agent to start with Verinoda's analyze (measured: same facts, lower cost);
5// search: attach Verinoda's query results to the prompt (measured: lower cost but a few facts fewer).
6export type AutoMode = 'off' | 'nudge' | 'search'
7
8// The index as the pane shows it; every kind has a glyph and words, colour only reinforces them.
9export type IndexKind =
10 | 'checking' // the session-start check is running
11 | 'fresh' // nothing waiting, no build running
12 | 'pending' // edits waiting for the end of the turn (or for a running graph build: `waiting`)
13 | 'updating' // `verinoda update --fast` is running
14 | 'graph' // the text is in; the graph is rebuilt in the background (2-4 min on this repository)
15 | 'behind' // the graph is behind with no build coming (files changed outside Claude's edit tools)
16 | 'slow' // the watcher gave up after 10 minutes
17 | 'failed' // the last update failed; its files are kept
18 | 'unknown' // the graph state could not be read
19 | 'noindex' // no .verinoda in the project
20
21export type IndexState = {
22 kind: IndexKind
23 n: number // files the state is about (pending, updating, graph, behind, failed)
24 waiting: boolean // pending while a graph build runs: they go in when it ends
25 since: number | null // ms: when the graph build started (graph)
26 at: number | null // ms: when the index was last seen fresh in this session
27 error: string // failed: the first line of the CLI's error
28}
29
30export type Notice = { text: string; tone: 'ok' | 'warn' | 'err' }
31
32export type ContextInfo = {
33 prompt: string // the prompt's head, for the pane
34 mode: AutoMode
35 ok: boolean
36 chars: number // attached characters (0 when nothing was attached)
37 seconds: number
38 note: string // why nothing was attached, or the empty string
39 at: number // ms
40}
41
42export type ReviewInfo = {
43 ok: boolean
44 risk: string // "52/100 (high)" or the empty string, for the toast
45 score: number | null
46 of: number
47 band: string // low | medium | high, as the review names it, or the empty string
48 findings: number
49 summary: string // the review's own one-paragraph summary, cut at 600 characters
50 seconds: number
51 sha: string // the reviewed commit, short
52 at: number // ms
53}
54
55// The last check of an edit (`verinoda check`): what the model was told, for the pane.
56export type CheckInfo = {
57 file: string // the edited file, relative to the project
58 n: number // names reported absent or mismatched on the edited lines
59 ok: boolean // false: the check could not run (the edit went through all the same)
60 at: number // ms
61}
62
63// The improvement checklist pane (D175): one list the model proposed and the person marks, then what came of it.
64export type ImproveGroup = 'problem' | 'improvement' | 'taste' | 'own'
65export type ImproveDecision = 'none' | 'apply' | 'keep' | 'check'
66export type ImproveStatus = 'waiting' | 'applied' | 'partial' | 'failed' | 'not_confirmed' | 'unreported'
67
68export type ImproveItem = {
69 id: string // the model's id, or u1, u2, ... for the person's own
70 group: ImproveGroup
71 title: string // an own item: the person's text
72 observation: string
73 why: string
74 change: string
75 cost: string // '' when none was given
76 evidence: string[]
77 seen: boolean // taste: judged from a rendered view
78 verified: boolean // a check confirmed it
79 checked: string // the note of the check that confirmed it, else ''
80 decision: ImproveDecision // an own item: always 'apply'
81 sentAs: 'apply' | 'check' | null // what it was last sent as
82 outcome: { status: ImproveStatus; note: string; evidence: string[] } | null
83}
84
85export type ImproveState = {
86 phase: 'idle' | 'reviewing' | 'choosing' | 'sent'
87 subject: string
88 items: ImproveItem[] // the model's in rank order, then the person's own
89 open: string | null // the item whose details are shown
90 unfolded: ImproveGroup[] // groups whose folded items were opened
91 extra: string[] // changes the model reported that were not on the list
92 draft: string // what the own-item field holds
93 ownSeq: number // the last number given to an own item; never reused in a session
94 expect: '' | 'review' | 'selection' | 'report' // the prompt that was submitted and whose turn has not started
95 queue: '' | 'review' // the review prompt waits for the command's own turn to end: the host refuses a submit from a command's hook
96 turn: string | null // the id of the mod's own turn in flight
97 note: '' | 'no-list' | 'not-sent'
98}
99
100declare module 'claude-code' {
101 // The tools the mod registers (feature `tool`), so a `tool.call` hook can name them and read their arguments.
102 interface McpToolInputs {
103 'mcp__verinoda-live__locate': { text: string; files?: string[] }
104 'mcp__verinoda-live__coupled': { files: string[] }
105 'mcp__verinoda-live__improve_propose': {
106 subject: string
107 items: { id: string; group: 'problem' | 'improvement' | 'taste'; title: string; observation: string; why: string;
108 change: string; cost?: string; evidence?: string[]; seen?: boolean }[]
109 }
110 'mcp__verinoda-live__improve_report': {
111 outcomes: { id: string; status: 'applied' | 'partial' | 'failed' | 'confirmed' | 'not_confirmed'; note: string; change?: string; evidence?: string[] }[]
112 extra_changes?: string[]
113 }
114 }
115
116 interface PluginState {
117 'verinoda-live': {
118 status: string
119 idx: IndexState
120 notice: Notice | null
121 auto: AutoMode
122 guard: boolean
123 check: boolean // check every edit of a Python, Java or Kotlin file (on unless turned off)
124 lastCheck: CheckInfo | null
125 lastContext: ContextInfo | null
126 lastReview: ReviewInfo | null
127 reviewing: string | null // the short sha of the commit under review
128 expanded: boolean // the review summary shown whole
129 assist: string // the assist setting: a preset (off, inject, tool, full, strict) or a list of features
130 improve: ImproveState // the improvement checklist pane's whole state
131 }
132 }
133}
134