See the decisions Claude made in each turn, why it made them, and steer future turns with keep and avoid rules.

See the decisions Claude made in each turn, why it made them, and what it chose not to do. Then steer the next turns: keep a behavior you like, or stop one you do not.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install decision-lens@claude-code-mods
Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.
The lens records each turn as Claude works: what Claude wrote, any thinking the model shares, and every tool call with its result. When the turn ends, a helper model reads that trace and names up to five real decisions. The status line then shows ◆ 3 decisions · /why.
Each decision is a card with:
◆ 3 decisions · /why./why. The lens opens on the latest turn and takes the keys.Commands work at any time, also while the pane does not have the keys:
/why: open the lens on the latest turn./why keep 2: save a rule from card 2 that tells Claude to repeat the behavior./why avoid 2: save a rule from card 2 that tells Claude to stop./why ask 2: ask Claude, through the session's own model and cached conversation, to explain card 2 in its own words. The answer appears on the card./why rules: open the Rules tab.Rules go into Claude's system prompt from the next request and in every later session. They are saved for this project. No repository files change.
The hint row under the title always names the keys that work now.
| Key | What it does |
|---|---|
1 to 5 | Pick a card. The picked card has a bright frame, and its buttons show their keys. |
k | Keep doing this: save the picked card's keep rule. |
a | Don't do this: save the picked card's avoid rule. |
w | Ask why: Claude explains the picked card in its own words. |
p, n | Show the earlier or later turn (the last 20 turns). |
d, r | Show the Decisions tab or the Rules tab. |
x | Close the lens. |
| Tab, Enter | Move to the next button, and press it. |
| Esc | Give the keys back to the prompt. The pane stays open. |
| ctrl+x tab | Give the keys to the pane again. |
On the Rules tab, each rule is numbered and has two buttons: "Make global" (or "This project only") and "Remove". Use Tab and Enter to press them.
Change these in /plugin:
explainTurns (on): read each turn with the helper model. This uses extra tokens.helperModel (claude-sonnet-5-5): the model that reads each turn. Ask why always uses the session's model.helperEffort (medium): how hard the helper model thinks (low, medium, or high).Some models share no thinking text. The lens then works from what Claude wrote and the tool calls it made.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.ts 80 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { installCapture } from './capture'
4import { ANALYSIS_SYSTEM, analysisPrompt, parseDecisions } from './lens/analysis'
5import { helperFrom } from './lens/helper'
6import { rulesFromStore, rulesSection, RULES_SECTION_ID, storeKeyFor } from './lens/rules'
7import { configureLens, finishAnalysis, lensView, nextAnalysis, setRules } from './lens/state'
8import { summaryLine } from './lens/view'
9import { installPane } from './pane'
10import { noop } from './shared/noop'
11
12const ANALYSIS_POLL_MS = 1500
13
14let isAnalyzing = false
15
16function projectKey(cwd: string): string {
17 return cwd.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
18}
19
20async function loadRules($: EngineInterface, options: { cwd: string }): Promise<void> {
21 const project = projectKey(options.cwd)
22 const projectRules = rulesFromStore(await $.store.get(storeKeyFor({ scope: 'project', project })))
23 const globalRules = rulesFromStore(await $.store.get(storeKeyFor({ scope: 'global', project })))
24 setRules({ project, rules: [...globalRules, ...projectRules] })
25}
26
27async function analyzeNext($: EngineInterface): Promise<void> {
28 if (isAnalyzing) {
29 return
30 }
31 const turn = nextAnalysis()
32 if (!turn) {
33 return
34 }
35 isAnalyzing = true
36 const result = await $.model
37 .complete({
38 model: lensView().helper.helperModel,
39 effort: lensView().helper.helperEffort,
40 system: ANALYSIS_SYSTEM,
41 prompt: analysisPrompt(turn),
42 maxTokens: 1800,
43 timeoutMs: 45000,
44 })
45 .catch(() => undefined)
46 finishAnalysis({ id: turn.id, decisions: result?.isAnswered ? parseDecisions(result.text) : null })
47 isAnalyzing = false
48 $.ui.status(summaryLine(turn))
49 $.ui.invalidate('ui.render')
50}
51
52export const register: Register = (on, options) => {
53 configureLens({ helper: helperFrom(options), isAnalysisOn: options.explainTurns !== false })
54 installCapture(on)
55 installPane(on)
56
57 on('session.start', async ($, e, next) => {
58 await $.command.register({
59 name: 'why',
60 description: 'Decision Lens: why Claude made its last decisions. keep N or avoid N saves a rule from card N, ask N asks Claude why, rules lists your rules.',
61 argumentHint: '[keep N|avoid N|ask N|rules]',
62 immediate: true,
63 })
64 await loadRules($, { cwd: e.cwd })
65 $.clock.every(ANALYSIS_POLL_MS, () => {
66 analyzeNext($).catch(noop)
67 })
68 return next(e)
69 })
70
71 on('prompt.compose', async ($, e, next) => {
72 const composed = await next(e)
73 const text = rulesSection(lensView().rules)
74 if (text === undefined) {
75 return composed
76 }
77 return { sections: [...composed.sections, { id: RULES_SECTION_ID, text, scope: 'session' }] }
78 })
79}
80hooks/capture.ts 70 lines1import type { On, TurnStepChunk } from 'claude-code'
2
3import { completeLensTurn, noteNarration, noteToolEnd, noteToolStart, startLensTurn } from './lens/state'
4import { toolSummary } from './lens/trace'
5
6type PendingBlock = { kind: 'said' | 'thought'; index: number; text: string }
7
8function flush(pending: PendingBlock[]): PendingBlock[] {
9 pending.forEach(block => noteNarration({ kind: block.kind, text: block.text }))
10 return []
11}
12
13function absorb(options: { chunk: TurnStepChunk; pending: PendingBlock[] }): PendingBlock[] {
14 const { chunk, pending } = options
15 if (chunk.kind === 'tool') {
16 return flush(pending)
17 }
18 if (chunk.kind !== 'text' && chunk.kind !== 'thinking') {
19 return pending
20 }
21 const kind = chunk.kind === 'text' ? 'said' : 'thought'
22 const last = pending.at(-1)
23 if (last && last.kind === kind && last.index === chunk.index) {
24 last.text += chunk.text
25 return pending
26 }
27 return [...pending, { kind, index: chunk.index, text: chunk.text }]
28}
29
30export function installCapture(on: On): void {
31 on('turn.start', ($, e, next) => {
32 startLensTurn({ turnId: e.turnId, prompt: e.text })
33 return next(e)
34 })
35
36 on('turn.step', async function* ($, e, next) {
37 const stream = next(e)
38 let pending: PendingBlock[] = []
39 for await (const chunk of stream) {
40 if (e.agentId === undefined) {
41 pending = absorb({ chunk, pending })
42 }
43 yield chunk
44 }
45 flush(pending)
46 return await stream.result
47 })
48
49 on('tool.call', async ($, e, next) => {
50 if (e.agentId !== undefined) {
51 return next(e)
52 }
53 noteToolStart({ toolUseId: e.tool_use_id, summary: toolSummary({ tool: String(e.tool), input: e }) })
54 const ran = await next(e)
55 const outcome = ran.deny !== undefined ? 'denied' : ran.isError === true ? 'error' : 'ok'
56 noteToolEnd({ toolUseId: e.tool_use_id, outcome })
57 return ran
58 })
59
60 on('turn.complete', ($, e, next) => {
61 if (e.agentId === undefined) {
62 const turn = completeLensTurn({ answer: e.answer })
63 if (turn?.status === 'waiting') {
64 $.ui.status('◆ reading the decisions in this turn')
65 }
66 }
67 return next(e)
68 })
69}
70hooks/lens/analysis.ts 88 lines1import { traceText } from './trace'
2import type { Confidence, Decision, LensTurn } from './types'
3
4export const ANALYSIS_SYSTEM =
5 'You review the work trace of an AI coding agent and name the real decisions it made. You are precise, fair, and brief. Reply with one JSON object and nothing else.'
6
7export function analysisPrompt(turn: LensTurn): string {
8 return [
9 `The user asked: ${turn.prompt}`,
10 '',
11 'Trace of the agent turn, in order:',
12 traceText(turn.trace) || '- (no tool calls or narration were recorded)',
13 '',
14 `Final answer (excerpt): ${turn.answer.slice(0, 1500) || '(none)'}`,
15 '',
16 'Name up to 5 decisions that shaped the result: approach, scope, which files or tools, assumptions, and trade-offs.',
17 'Skip routine steps such as reading a file to understand it. If there were no real decisions, return {"decisions": []}.',
18 'Return JSON with this shape:',
19 '{"decisions": [{',
20 ' "title": "the decision in at most 8 words",',
21 ' "choice": "what the agent did, one sentence",',
22 ' "why": "the reason the trace shows, one or two sentences; say so if the trace shows no reason",',
23 ' "alternatives": ["a real option it did not take", "another"],',
24 ' "evidence": "a short quote or fact from the trace",',
25 ' "confidence": "high, medium, or low: how sure you are that this reading is right",',
26 ' "keep": "an imperative rule that would make the agent do this again, at most 20 words",',
27 ' "avoid": "an imperative rule that would stop the agent doing this, at most 20 words"',
28 '}]}',
29 ].join('\n')
30}
31
32function isRecord(value: unknown): value is Record<string, unknown> {
33 return typeof value === 'object' && value !== null && !Array.isArray(value)
34}
35
36function text(options: { value: unknown; limit: number }): string {
37 return typeof options.value === 'string' ? options.value.replace(/\s+/g, ' ').trim().slice(0, options.limit) : ''
38}
39
40function confidenceOf(value: unknown): Confidence {
41 return value === 'high' || value === 'low' ? value : 'medium'
42}
43
44export function parseDecisions(reply: string): Decision[] | null {
45 const start = reply.indexOf('{')
46 const end = reply.lastIndexOf('}')
47 if (start < 0 || end <= start) {
48 return null
49 }
50 let parsed: unknown
51 try {
52 parsed = JSON.parse(reply.slice(start, end + 1))
53 } catch {
54 return null
55 }
56 if (!isRecord(parsed) || !Array.isArray(parsed.decisions)) {
57 return null
58 }
59 return parsed.decisions
60 .filter(isRecord)
61 .map((decision, index) => ({
62 id: index + 1,
63 title: text({ value: decision.title, limit: 80 }),
64 choice: text({ value: decision.choice, limit: 300 }),
65 why: text({ value: decision.why, limit: 400 }),
66 alternatives: Array.isArray(decision.alternatives)
67 ? decision.alternatives.map(alternative => text({ value: alternative, limit: 160 })).filter(Boolean).slice(0, 3)
68 : [],
69 evidence: text({ value: decision.evidence, limit: 240 }),
70 confidence: confidenceOf(decision.confidence),
71 keep: text({ value: decision.keep, limit: 200 }),
72 avoid: text({ value: decision.avoid, limit: 200 }),
73 explanation: '',
74 isAsking: false,
75 }))
76 .filter(decision => decision.title !== '' && decision.choice !== '')
77 .slice(0, 5)
78}
79
80export function askWhyPrompt(decision: Decision): string {
81 const alternatives = decision.alternatives.length > 0 ? ` instead of ${decision.alternatives.join(', or ')}` : ''
82 return [
83 `Earlier in this conversation you made this decision: ${decision.choice}${alternatives}.`,
84 'In three to five sentences, explain why. Name what you saw in the code or the conversation that led you there,',
85 'what you were optimizing for, and what would have made you choose differently. Plain prose, no lists, no headings.',
86 ].join(' ')
87}
88hooks/lens/helper.ts 16 lines1import type { ModelEffort, PluginOptions } from 'claude-code'
2
3export const DEFAULT_HELPER_MODEL = 'claude-sonnet-5-5'
4
5const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high']
6
7export type Helper = { helperModel: string; helperEffort: ModelEffort }
8
9export function helperFrom(options: PluginOptions): Helper {
10 const model = options.helperModel
11 return {
12 helperModel: typeof model === 'string' && model.trim() !== '' ? model.trim() : DEFAULT_HELPER_MODEL,
13 helperEffort: EFFORTS.find(effort => effort === options.helperEffort) ?? 'medium',
14 }
15}
16hooks/lens/rules.ts 55 lines1import type { Rule, RuleKind, RuleScope } from './types'
2
3export const RULES_SECTION_ID = 'decision-lens:rules'
4
5function isRule(value: unknown): value is Rule {
6 return (
7 typeof value === 'object' &&
8 value !== null &&
9 'id' in value &&
10 typeof value.id === 'string' &&
11 'text' in value &&
12 typeof value.text === 'string' &&
13 'kind' in value &&
14 (value.kind === 'keep' || value.kind === 'avoid') &&
15 'scope' in value &&
16 (value.scope === 'project' || value.scope === 'global') &&
17 'createdAt' in value &&
18 typeof value.createdAt === 'number'
19 )
20}
21
22export function rulesFromStore(value: unknown): Rule[] {
23 return Array.isArray(value) ? value.filter(isRule) : []
24}
25
26export function storeKeyFor(options: { scope: RuleScope; project: string }): string {
27 return options.scope === 'global' ? 'rules:global' : `rules:project:${options.project}`
28}
29
30export function newRule(options: { kind: RuleKind; text: string; scope: RuleScope; at: number; existing: readonly Rule[] }): Rule {
31 return {
32 id: `${options.at.toString(36)}-${options.existing.length}`,
33 kind: options.kind,
34 text: options.text.replace(/\s+/g, ' ').trim().slice(0, 240),
35 scope: options.scope,
36 createdAt: options.at,
37 }
38}
39
40export function rulesSection(rules: readonly Rule[]): string | undefined {
41 if (rules.length === 0) {
42 return undefined
43 }
44 const keep = rules.filter(rule => rule.kind === 'keep').map(rule => `- ${rule.text}`)
45 const avoid = rules.filter(rule => rule.kind === 'avoid').map(rule => `- ${rule.text}`)
46 return [
47 '# Steering rules from the user',
48 'The user set these rules with the Decision Lens after reviewing your past decisions. Follow them unless the user asks for something else in this conversation.',
49 keep.length > 0 ? `\nKeep doing:\n${keep.join('\n')}` : '',
50 avoid.length > 0 ? `\nAvoid:\n${avoid.join('\n')}` : '',
51 ]
52 .filter(Boolean)
53 .join('\n')
54}
55hooks/lens/state.ts 181 lines1import { DEFAULT_HELPER_MODEL } from './helper'
2import type { Helper } from './helper'
3import { compact } from './trace'
4import type { Decision, LensTab, LensTurn, Rule, ToolOutcome } from './types'
5
6const MAX_TURNS = 20
7const MIN_NARRATION_FOR_ANALYSIS = 160
8
9type Lens = {
10 turns: LensTurn[]
11 currentId: number | undefined
12 selectedId: number | undefined
13 isFollowing: boolean
14 selectedNumber: number
15 tab: LensTab
16 rules: Rule[]
17 project: string
18 helper: Helper
19 isAnalysisOn: boolean
20 nextId: number
21}
22
23const lens: Lens = {
24 turns: [],
25 currentId: undefined,
26 selectedId: undefined,
27 isFollowing: true,
28 selectedNumber: 1,
29 tab: 'decisions',
30 rules: [],
31 project: '',
32 helper: { helperModel: DEFAULT_HELPER_MODEL, helperEffort: 'medium' },
33 isAnalysisOn: true,
34 nextId: 1,
35}
36
37export function configureLens(options: { helper: Helper; isAnalysisOn: boolean }): void {
38 lens.helper = options.helper
39 lens.isAnalysisOn = options.isAnalysisOn
40}
41
42export function lensView(): Readonly<Lens> {
43 return lens
44}
45
46export function currentTurn(): LensTurn | undefined {
47 return lens.turns.find(turn => turn.id === lens.currentId)
48}
49
50export function selectedTurn(): LensTurn | undefined {
51 return lens.turns.find(turn => turn.id === lens.selectedId) ?? lens.turns.at(-1)
52}
53
54export function startLensTurn(options: { turnId: string; prompt: string }): void {
55 const turn: LensTurn = {
56 id: lens.nextId,
57 turnId: options.turnId,
58 prompt: compact(options.prompt),
59 trace: [],
60 answer: '',
61 status: 'recording',
62 decisions: [],
63 }
64 lens.nextId += 1
65 lens.turns = [...lens.turns, turn].slice(-MAX_TURNS)
66 lens.currentId = turn.id
67}
68
69export function noteNarration(options: { kind: 'said' | 'thought'; text: string }): void {
70 const text = compact(options.text)
71 if (text !== '') {
72 currentTurn()?.trace.push({ kind: options.kind, text, toolUseId: '', outcome: 'ok' })
73 }
74}
75
76export function noteToolStart(options: { toolUseId: string; summary: string }): void {
77 currentTurn()?.trace.push({ kind: 'tool', text: compact(options.summary), toolUseId: options.toolUseId, outcome: 'running' })
78}
79
80export function noteToolEnd(options: { toolUseId: string; outcome: ToolOutcome }): void {
81 const entry = currentTurn()?.trace.find(candidate => candidate.toolUseId === options.toolUseId)
82 if (entry) {
83 entry.outcome = options.outcome
84 }
85}
86
87export function completeLensTurn(options: { answer: string }): LensTurn | undefined {
88 const turn = currentTurn()
89 if (!turn) {
90 return undefined
91 }
92 turn.answer = compact(options.answer)
93 const narration = turn.trace.filter(entry => entry.kind !== 'tool').reduce((sum, entry) => sum + entry.text.length, 0)
94 const hasTools = turn.trace.some(entry => entry.kind === 'tool')
95 const isWorthAnalyzing = hasTools || narration + turn.answer.length >= MIN_NARRATION_FOR_ANALYSIS
96 turn.status = lens.isAnalysisOn && isWorthAnalyzing ? 'waiting' : 'skipped'
97 lens.currentId = undefined
98 if (lens.isFollowing) {
99 lens.selectedId = turn.id
100 lens.selectedNumber = 1
101 }
102 return turn
103}
104
105export function nextAnalysis(): LensTurn | undefined {
106 const turn = lens.turns.find(candidate => candidate.status === 'waiting')
107 if (turn) {
108 turn.status = 'running'
109 }
110 return turn
111}
112
113export function finishAnalysis(options: { id: number; decisions: Decision[] | null }): void {
114 const turn = lens.turns.find(candidate => candidate.id === options.id)
115 if (turn) {
116 turn.decisions = options.decisions ?? []
117 turn.status = options.decisions ? 'ready' : 'failed'
118 }
119}
120
121export function moveSelection(step: number): void {
122 const current = selectedTurn()
123 const index = current ? lens.turns.indexOf(current) : lens.turns.length - 1
124 const target = lens.turns[Math.min(lens.turns.length - 1, Math.max(0, index + step))]
125 if (target) {
126 lens.selectedId = target.id
127 lens.isFollowing = target === lens.turns.at(-1)
128 lens.selectedNumber = 1
129 }
130}
131
132export function followLatest(): void {
133 lens.isFollowing = true
134 lens.selectedId = lens.turns.at(-1)?.id
135 lens.selectedNumber = 1
136}
137
138export function selectCard(number: number): void {
139 lens.selectedNumber = number
140}
141
142export function decisionNumbered(number: number): { turn: LensTurn; decision: Decision } | undefined {
143 const turn = selectedTurn()
144 const decision = turn?.status === 'ready' ? turn.decisions[number - 1] : undefined
145 return turn && decision ? { turn, decision } : undefined
146}
147
148export function showTab(tab: LensTab): void {
149 lens.tab = tab
150}
151
152export function findDecision(options: { turnId: number; decisionId: number }): Decision | undefined {
153 return lens.turns.find(turn => turn.id === options.turnId)?.decisions.find(decision => decision.id === options.decisionId)
154}
155
156export function markAsking(options: { turnId: number; decisionId: number }): Decision | undefined {
157 const decision = findDecision(options)
158 if (decision) {
159 decision.isAsking = true
160 decision.explanation = ''
161 }
162 return decision
163}
164
165export function setExplanation(options: { turnId: number; decisionId: number; text: string }): void {
166 const decision = findDecision(options)
167 if (decision) {
168 decision.isAsking = false
169 decision.explanation = options.text
170 }
171}
172
173export function setRules(options: { project: string; rules: Rule[] }): void {
174 lens.project = options.project
175 lens.rules = options.rules
176}
177
178export function replaceRules(rules: Rule[]): void {
179 lens.rules = rules
180}
181hooks/lens/view.ts 57 lines1import type { Confidence, Decision, LensTurn, Rule } from './types'
2
3export const COLORS = {
4 accent: '#a78bfa',
5 keep: '#34d399',
6 avoid: '#fb7185',
7 muted: '#8b93a1',
8 frame: '#4b5563',
9} as const
10
11const CONFIDENCE_GLYPHS: Record<Confidence, string> = { high: '●●●', medium: '●●○', low: '●○○' }
12
13export function confidenceGlyph(confidence: Confidence): string {
14 return CONFIDENCE_GLYPHS[confidence]
15}
16
17export function turnMessage(options: { turn: LensTurn | undefined; isAnalysisOn: boolean }): string | undefined {
18 const { turn } = options
19 if (!turn) {
20 return 'No turns yet. After Claude finishes a turn, its decisions appear here.'
21 }
22 if (turn.status === 'recording') {
23 return 'Claude is still working on this turn.'
24 }
25 if (turn.status === 'waiting' || turn.status === 'running') {
26 return 'Reading the decisions in this turn.'
27 }
28 if (turn.status === 'failed') {
29 return 'The helper model did not answer, so decisions for this turn are not available.'
30 }
31 if (turn.status === 'skipped') {
32 return options.isAnalysisOn
33 ? 'This turn had no tool calls or real choices to explain.'
34 : 'Turn analysis is off. Turn on "Explain each turn" in /plugin.'
35 }
36 return turn.decisions.length === 0 ? 'No real decisions in this turn. Claude took the obvious path.' : undefined
37}
38
39export function hasRule(options: { rules: readonly Rule[]; text: string }): boolean {
40 return options.rules.some(rule => rule.text === options.text)
41}
42
43export function decisionBody(decision: Decision): { label: string; text: string }[] {
44 return [
45 { label: 'Did', text: decision.choice },
46 { label: 'Why', text: decision.why },
47 ].filter(part => part.text !== '')
48}
49
50export function summaryLine(turn: LensTurn | undefined): string | undefined {
51 if (!turn || turn.status !== 'ready') {
52 return undefined
53 }
54 const count = turn.decisions.length
55 return count === 0 ? undefined : `◆ ${count} decision${count === 1 ? '' : 's'} · /why`
56}
57hooks/pane.tsx 177 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { askWhyPrompt } from './lens/analysis'
4import { parseWhy, WHY_USAGE } from './lens/command'
5import type { SteerKind } from './lens/command'
6import { cardTree, headerTree, hintText, ruleTree } from './lens/panel'
7import type { PaneAction } from './lens/panel'
8import { newRule, storeKeyFor } from './lens/rules'
9import {
10 decisionNumbered,
11 findDecision,
12 followLatest,
13 lensView,
14 markAsking,
15 moveSelection,
16 replaceRules,
17 selectCard,
18 selectedTurn,
19 setExplanation,
20 showTab,
21} from './lens/state'
22import type { Rule, RuleKind } from './lens/types'
23import { hasRule, turnMessage } from './lens/view'
24import { noop } from './shared/noop'
25
26export const PANE_ID = 'decision-lens'
27
28async function persistRules($: EngineInterface): Promise<void> {
29 const { rules, project } = lensView()
30 await $.store.set(storeKeyFor({ scope: 'project', project }), rules.filter(rule => rule.scope === 'project'))
31 await $.store.set(storeKeyFor({ scope: 'global', project }), rules.filter(rule => rule.scope === 'global'))
32}
33
34async function addRule($: EngineInterface, options: { kind: RuleKind; text: string }): Promise<boolean> {
35 const rules = lensView().rules
36 if (options.text === '' || hasRule({ rules, text: options.text })) {
37 return false
38 }
39 const rule = newRule({ kind: options.kind, text: options.text, scope: 'project', at: await $.clock.now(), existing: rules })
40 replaceRules([...rules, rule])
41 await persistRules($)
42 $.ui.toast(`${options.kind === 'keep' ? 'Keep' : 'Avoid'} rule saved. Claude reads it from the next request.`)
43 return true
44}
45
46async function changeRules($: EngineInterface, options: { rules: Rule[] }): Promise<void> {
47 replaceRules(options.rules)
48 await persistRules($)
49}
50
51async function askWhy($: EngineInterface, options: { turnId: number; decisionId: number }): Promise<void> {
52 const decision = markAsking(options)
53 if (!decision) {
54 return
55 }
56 $.ui.invalidate('ui.render')
57 const result = await $.model.fork({ prompt: askWhyPrompt(decision) }).catch(() => undefined)
58 const text = result?.isAnswered ? result.text.trim() : 'Claude could not answer right now. Try again after the current turn.'
59 setExplanation({ ...options, text })
60 $.ui.invalidate('ui.render')
61}
62
63function ruleChange(action: { kind: 'scope' | 'remove'; ruleId: string }): Rule[] {
64 const { rules } = lensView()
65 if (action.kind === 'remove') {
66 return rules.filter(rule => rule.id !== action.ruleId)
67 }
68 return rules.map(rule =>
69 rule.id === action.ruleId ? { ...rule, scope: rule.scope === 'project' ? 'global' : 'project' } : rule,
70 )
71}
72
73async function perform($: EngineInterface, options: { action: PaneAction }): Promise<void> {
74 const { action } = options
75 if (action.kind === 'select') {
76 selectCard(action.number)
77 } else if (action.kind === 'turn') {
78 moveSelection(action.step)
79 } else if (action.kind === 'tab') {
80 showTab(action.tab)
81 } else if (action.kind === 'close') {
82 await $.ui.close({ id: PANE_ID })
83 return
84 } else if (action.kind === 'scope' || action.kind === 'remove') {
85 await changeRules($, { rules: ruleChange(action) })
86 } else if (action.kind === 'ask') {
87 await askWhy($, { turnId: action.turnId, decisionId: action.decisionId })
88 } else if (action.kind === 'keep' || action.kind === 'avoid') {
89 const decision = findDecision({ turnId: action.turnId, decisionId: action.decisionId })
90 if (decision) {
91 await addRule($, { kind: action.kind, text: action.kind === 'keep' ? decision.keep : decision.avoid })
92 }
93 }
94 $.ui.invalidate('ui.render')
95}
96
97async function steer($: EngineInterface, options: { steer: SteerKind; number: number }): Promise<string> {
98 const numbered = decisionNumbered(options.number)
99 if (!numbered) {
100 return `There is no card ${options.number} on the turn shown in /why. ${WHY_USAGE}`
101 }
102 const { turn, decision } = numbered
103 selectCard(options.number)
104 if (options.steer === 'ask') {
105 askWhy($, { turnId: turn.id, decisionId: decision.id }).catch(noop)
106 await $.ui.open({ id: PANE_ID, title: 'Decision Lens', focus: true, rows: 30 })
107 return `Asking Claude why it chose "${decision.title}". The answer appears on card ${options.number} in /why.`
108 }
109 const text = options.steer === 'keep' ? decision.keep : decision.avoid
110 const isAdded = await addRule($, { kind: options.steer, text })
111 $.ui.invalidate('ui.render')
112 return isAdded ? `Saved ${options.steer} rule: ${text}` : `That rule is already saved: ${text}`
113}
114
115export function installPane(on: On): void {
116 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
117 const elements = $.ui.resolve(e)
118 const { Box, Text } = elements
119 const view = lensView()
120 const turn = selectedTurn()
121 const act = (action: PaneAction) => {
122 perform($, { action }).catch(noop)
123 }
124 const hasCards = view.tab === 'decisions' && turn?.status === 'ready' && turn.decisions.length > 0
125 const position = view.tab === 'decisions' && turn ? ` turn ${view.turns.indexOf(turn) + 1} of ${view.turns.length}` : ''
126 const message = view.tab === 'decisions' ? turnMessage({ turn, isAnalysisOn: view.isAnalysisOn }) : undefined
127 const header = headerTree({
128 elements,
129 act,
130 tab: view.tab,
131 position,
132 ruleCount: view.rules.length,
133 hint: hintText({ isFocused: e.props.isFocused, tab: view.tab }),
134 })
135 if (view.tab === 'rules') {
136 return (
137 <Box flexDirection="column">
138 {header}
139 <Text dimColor>Claude reads these rules in every session: project rules here, global rules everywhere.</Text>
140 {view.rules.length === 0 && <Text dimColor>No rules yet. Press k or a on a decision card, or type /why keep 1.</Text>}
141 {view.rules.map((rule, index) => ruleTree({ elements, act, rule, number: index + 1 }))}
142 </Box>
143 )
144 }
145 return (
146 <Box flexDirection="column">
147 {header}
148 {turn && <Text italic dimColor>{`“${turn.prompt}”`}</Text>}
149 {message !== undefined && <Text dimColor>{message}</Text>}
150 {hasCards &&
151 turn.decisions.map((decision, index) =>
152 cardTree({ elements, act, turn, decision, number: index + 1, isSelected: index + 1 === view.selectedNumber, rules: view.rules }),
153 )}
154 </Box>
155 )
156 })
157
158 on('command.run', { command: 'why' }, async ($, e) => {
159 const command = parseWhy(e.args)
160 if (command.kind === 'unknown') {
161 return { text: WHY_USAGE }
162 }
163 if (command.kind === 'steer') {
164 return { text: await steer($, { steer: command.steer, number: command.number }) }
165 }
166 showTab(command.tab)
167 followLatest()
168 await $.ui.open({ id: PANE_ID, title: 'Decision Lens', focus: true, rows: 30 })
169 $.ui.invalidate('ui.render')
170 const turn = selectedTurn()
171 const count = turn?.status === 'ready' ? turn.decisions.length : 0
172 return {
173 text: `Decision Lens: ${count} decisions in the latest turn, ${lensView().rules.length} steering rules. The pane has the keys now; Esc gives them back.`,
174 }
175 })
176}
177hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/lens/trace.ts 40 lines1import { sanitize } from '../shared/privacy'
2import type { TraceEntry } from './types'
3
4const MAX_ENTRY = 600
5const MAX_TRACE = 14000
6
7function firstString(input: Readonly<Record<string, unknown>>): string {
8 const preferred = ['file_path', 'notebook_path', 'command', 'pattern', 'description', 'url', 'query', 'prompt']
9 for (const key of preferred) {
10 const value = input[key]
11 if (typeof value === 'string' && value !== '') {
12 return value
13 }
14 }
15 const fallback = Object.values(input).find((value): value is string => typeof value === 'string')
16 return fallback ?? ''
17}
18
19export function toolSummary(options: { tool: string; input: Readonly<Record<string, unknown>> }): string {
20 const detail = firstString(options.input).split('\n')[0] ?? ''
21 const short = detail.length > 120 ? detail.slice(0, 119) + '…' : detail
22 return short === '' ? options.tool : `${options.tool} ${short}`
23}
24
25export function compact(text: string): string {
26 const cleaned = sanitize(text).replace(/\s+/g, ' ').trim()
27 return cleaned.length > MAX_ENTRY ? cleaned.slice(0, MAX_ENTRY - 1) + '…' : cleaned
28}
29
30export function traceText(trace: readonly TraceEntry[]): string {
31 const lines = trace.map(entry => {
32 if (entry.kind === 'tool') {
33 return `- Tool call: ${entry.text} -> ${entry.outcome}`
34 }
35 return entry.kind === 'thought' ? `- Claude thought: ${entry.text}` : `- Claude said: ${entry.text}`
36 })
37 const joined = lines.join('\n')
38 return joined.length > MAX_TRACE ? '…\n' + joined.slice(-MAX_TRACE) : joined
39}
40hooks/lens/types.ts 53 lines1export type TraceKind = 'said' | 'thought' | 'tool'
2
3export type ToolOutcome = 'running' | 'ok' | 'error' | 'denied'
4
5export type TraceEntry = {
6 kind: TraceKind
7 text: string
8 toolUseId: string
9 outcome: ToolOutcome
10}
11
12export type Confidence = 'high' | 'medium' | 'low'
13
14export type Decision = {
15 id: number
16 title: string
17 choice: string
18 why: string
19 alternatives: string[]
20 evidence: string
21 confidence: Confidence
22 keep: string
23 avoid: string
24 explanation: string
25 isAsking: boolean
26}
27
28export type AnalysisStatus = 'recording' | 'waiting' | 'running' | 'ready' | 'failed' | 'skipped'
29
30export type LensTurn = {
31 id: number
32 turnId: string
33 prompt: string
34 trace: TraceEntry[]
35 answer: string
36 status: AnalysisStatus
37 decisions: Decision[]
38}
39
40export type RuleKind = 'keep' | 'avoid'
41
42export type RuleScope = 'project' | 'global'
43
44export type Rule = {
45 id: string
46 kind: RuleKind
47 text: string
48 scope: RuleScope
49 createdAt: number
50}
51
52export type LensTab = 'decisions' | 'rules'
53hooks/lens/command.ts 30 lines1import type { LensTab, RuleKind } from './types'
2
3export type SteerKind = RuleKind | 'ask'
4
5export type WhyCommand =
6 | { kind: 'open'; tab: LensTab }
7 | { kind: 'steer'; steer: SteerKind; number: number }
8 | { kind: 'unknown' }
9
10const STEERS: readonly SteerKind[] = ['keep', 'avoid', 'ask']
11
12export const WHY_USAGE =
13 'Use /why to open the lens, /why rules for your rules, or /why keep N, /why avoid N, /why ask N, where N is a card number.'
14
15export function parseWhy(args: string): WhyCommand {
16 const [word = '', count = '1'] = args.trim().toLowerCase().split(/\s+/)
17 if (word === '') {
18 return { kind: 'open', tab: 'decisions' }
19 }
20 if (word === 'rules') {
21 return { kind: 'open', tab: 'rules' }
22 }
23 const steer = STEERS.find(candidate => candidate === word)
24 const number = Number(count)
25 if (steer === undefined || !Number.isInteger(number) || number < 1) {
26 return { kind: 'unknown' }
27 }
28 return { kind: 'steer', steer, number }
29}
30