SLOPSHOPPER

verinoda-live

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…

newpaneguardcommandtoaststatus
★ 1v0.6.0Apache-2.0updated 2026-10-08Verinoda-Labs/verinoda-symbiosis/claude-mods/verinoda-live
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verinoda-live
│ ┃ İyileştirme ✕ › fix the failing auth test and add an audit log call │ ┃ İnceleniyor… Claude listeyi hazırlıyor; │ ┃ hiçbir şey değiştirilmiyor. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /verinoda-improve │ ⎿ verinoda-live: looking at it; the list will appear in the pane, │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · İyileştirme
İnceleniyor… Claude listeyi hazırlıyor; hiçbir şey değiştirilmiyor.
Pane · Verinoda
? Bu projede Verinoda index'i yok Proje klasöründe çalıştır: verinoda init, sonra verinoda update
README

verinoda-live: a Claude Code mod for Verinoda

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).

What it does

featurehowdefault
Re-index after editsEdit / 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 stateAfter --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] paneA 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 paneIn 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).-

Nudge, search or off: what was measured

Two pre-registered studies with a model in the loop:

  • on 57 questions over corpora of 37-226 files (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;
  • on 51 questions over the ten real-world repositories (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.

Developing it

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.
  • Type-check: after the engine has loaded the mod once it writes .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.

Source 6 files
hooks/register.tsx 1269 lines
1import { 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 lines
1// 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}
185
hooks/improve.ts 483 lines
1// 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.`
483
hooks/improve-pane.tsx 305 lines
1import { 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}
305
hooks/mascot.tsx 91 lines
1import 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
91
types/index.d.ts 134 lines
1// 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