Answers trivial questions (what branch, what changed, did the last test pass) from git and the last test run on your machine, with no model tokens.

Answers trivial questions (what branch, what changed, did the last test pass) from git and the last test run on your machine, with no model tokens.
Part of Naren's Claude Toolkit.
Some questions get asked all day and have a deterministic answer on your machine. When you type exactly one of them, this mod answers it itself, instantly, and the prompt never reaches the model:
> what branch am i on
[local, no tokens] On branch main
> what changed
[local, no tokens] main (ahead 1): 2 modified, 1 untracked
M src/app.py
M README.md
?? notes.txt
> did the tests pass
[local, no tokens] `pytest -q` failed, 4 minutes ago. The model ran it; I did not re-run it, and files may have changed since.
Every local answer starts with [local, no tokens]. To send a question to the model anyway, start it with ask:: ask: what branch am i on goes through as what branch am i on.
The whole message must be one of these (case, a trailing ? and extra spaces do not matter). Anything longer or different goes to the model as usual.
| Question | Phrases | Answer comes from |
|---|---|---|
| branch | what branch am i on, which branch, current branch, ... | git branch --show-current |
| what changed | what changed, what did i change, git status, any uncommitted changes, ... | git status --porcelain |
| last commit | last commit, what was the last commit, ... | git log -1 |
| diff summary | diff stat, how big is the diff, how many lines changed | git diff --stat (unstaged and staged) |
| last test | did the tests pass, did the last test pass, are the tests passing, ... | the last test run the model made in this session |
The full list is the PHRASES table at the top of hooks/match.ts; adding a phrasing is one line plus a test.
The mod watches the Bash commands the model runs. When one is a test run (pytest, python -m unittest, npm test, cargo test, go test, jest, vitest, make test and the like, on their own or after a cd), it remembers the command, whether it passed or failed, and when. It records only when it is certain, and otherwise it forgets: a test run that pipes its output (pytest | tail), chains with ;, || or &, goes through a wrapper like uv run, was started in the background, or was interrupted or auto-backgrounded after a timeout clears the saved record instead of leaving an older run to answer for it, because a wrong "the tests passed" is worse than no answer. (A failing run is recorded as failed whatever its output says.) If there is no record, the question goes to the model, which can run the tests itself.
ask: when you want the question in the conversation.git commands (with --no-optional-locks, so even git status does not touch the index lock while Claude is running its own git commands), never anything taken from your message, and changes nothing.This is a mod, so it loads from a folder rather than from the skills directory:
claude --plugin-dir plugins/local-answer-router
Then ask what branch am i on, have Claude run your tests, and ask did the tests pass. Tests for the mod run with claude plugin test plugins/local-answer-router.
hooks/register.ts 110 lines1import { atom, read, update } from 'claude-code'
2import type { PromptSubmitInput, PromptSubmitResult, Register } from 'claude-code'
3
4import type { LastTest } from '../types'
5import {
6 answerBranch,
7 answerDiffSummary,
8 answerLastCommit,
9 answerLastTest,
10 answerStatus,
11 type GitResult,
12} from './answer'
13import { matchIntent, stripAskPrefix, type Intent } from './match'
14import { observe } from './testrun'
15
16const GIT_TIMEOUT_MS = 5000
17
18export const lastTestAtom = atom({ plugin: 'local-answer-router', key: 'lastTest' } as const, null as LastTest | null)
19
20/** What the handler needs from the host, so it can be tested without a session. */
21export type Deps = {
22 run: (argv: readonly string[]) => Promise<GitResult>
23 lastTest: () => Promise<LastTest | null>
24 now: () => number
25}
26
27type Next = (e: PromptSubmitInput) => Promise<PromptSubmitResult>
28
29// --no-optional-locks: even `git status` and `git diff` may refresh the index and take index.lock,
30// which would make a git command the model runs at the same moment fail. Answering must not write.
31const git = (...args: string[]): string[] => ['git', '--no-optional-locks', ...args]
32
33const answerFor = async (intent: Intent, deps: Deps): Promise<string | null> => {
34 switch (intent) {
35 case 'branch': {
36 const branch = await deps.run(git('branch', '--show-current'))
37 const head =
38 branch.exitCode === 0 && branch.stdout.trim() === ''
39 ? await deps.run(git('rev-parse', '--short', 'HEAD'))
40 : null
41 return answerBranch(branch, head)
42 }
43 case 'status':
44 return answerStatus(await deps.run(git('status', '--porcelain=v1', '-b')))
45 case 'lastCommit':
46 return answerLastCommit(await deps.run(git('log', '-1', '--format=%h%x09%s%x09%cr%x09%an')))
47 case 'diffSummary':
48 return answerDiffSummary(
49 await deps.run(git('diff', '--stat')),
50 await deps.run(git('diff', '--cached', '--stat')),
51 )
52 case 'lastTest':
53 return answerLastTest(await deps.lastTest(), deps.now())
54 }
55}
56
57/** Only the user's own typed prompt, without attachments, is ever answered or rewritten. */
58const isPlainUserPrompt = (e: PromptSubmitInput): boolean =>
59 e.origin.kind === 'composer' && (e.attachments === undefined || e.attachments.length === 0)
60
61export const makeHandler =
62 (deps: Deps) =>
63 async (e: PromptSubmitInput, next: Next): Promise<PromptSubmitResult> => {
64 if (!isPlainUserPrompt(e)) {
65 return next(e)
66 }
67 const stripped = stripAskPrefix(e.text)
68 if (stripped !== null) {
69 return next({ ...e, text: stripped })
70 }
71 const intent = matchIntent(e.text)
72 if (intent === null) {
73 return next(e)
74 }
75 let answer: string | null = null
76 try {
77 answer = await answerFor(intent, deps)
78 } catch {
79 answer = null // fail open: any trouble means the model gets the prompt
80 }
81 return answer === null ? next(e) : { drop: answer }
82 }
83
84export const register: Register = on => {
85 on('prompt.submit', ($, e, next) =>
86 makeHandler({
87 run: argv => $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS }),
88 lastTest: async () => (await read($, lastTestAtom)) ?? null,
89 now: () => Date.now(),
90 })(e, next),
91 )
92
93 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
94 const ran = await next(e)
95
96 try {
97 const seen = observe(e.command, e.run_in_background === true, ran, Date.now())
98 if (seen.kind === 'record') {
99 await update($, lastTestAtom, () => seen.record)
100 } else if (seen.kind === 'clear') {
101 await update($, lastTestAtom, () => null)
102 }
103 } catch {
104 // The recorder only watches; a failure here must never reach the tool call.
105 }
106
107 return ran
108 })
109}
110hooks/answer.ts 148 lines1import type { LastTest } from '../types'
2
3export type GitResult = { exitCode: number; stdout: string; stderr: string }
4
5export const LABEL = '[local, no tokens]'
6
7const NOT_A_REPO = /not a git repository/i
8const NO_COMMITS = /does not have any commits yet/i
9const NOT_A_REPO_ANSWER = `${LABEL} This folder is not inside a git repository.`
10const MAX_LISTED = 10
11
12/** Control characters become visible escapes (repository text is untrusted); long text is cut. */
13export const escapeText = (text: string, max = 200): string => {
14 const escaped = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, char => {
15 if (char === '\n') return '\\n'
16 if (char === '\r') return '\\r'
17 if (char === '\t') return '\\t'
18 return `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`
19 })
20 return escaped.length <= max ? escaped : escaped.slice(0, max - 1) + '…'
21}
22
23const plural = (count: number, unit: string): string => `${count} ${unit}${count === 1 ? '' : 's'} ago`
24
25export const formatAge = (ms: number): string => {
26 const seconds = Math.floor(ms / 1000)
27 if (seconds < 10) return 'just now'
28 if (seconds < 60) return plural(seconds, 'second')
29 const minutes = Math.floor(seconds / 60)
30 if (minutes < 60) return plural(minutes, 'minute')
31 const hours = Math.floor(minutes / 60)
32 if (hours < 24) return plural(hours, 'hour')
33 return plural(Math.floor(hours / 24), 'day')
34}
35
36/** The answer for a git result that failed because there is no repository, else null. */
37const notARepo = (...results: GitResult[]): string | null =>
38 results.some(result => result.exitCode !== 0 && NOT_A_REPO.test(result.stderr)) ? NOT_A_REPO_ANSWER : null
39
40export const answerBranch = (branch: GitResult, head: GitResult | null): string | null => {
41 if (branch.exitCode !== 0) {
42 return notARepo(branch)
43 }
44 const name = branch.stdout.trim()
45 if (name !== '') {
46 return `${LABEL} On branch ${escapeText(name)}`
47 }
48 if (head !== null && head.exitCode === 0 && head.stdout.trim() !== '') {
49 return `${LABEL} Detached HEAD at ${escapeText(head.stdout.trim())}`
50 }
51 return null
52}
53
54const parseHeader = (header: string): { branch: string; tracking: string } => {
55 const tracking = /\[([^\]]*)\]\s*$/.exec(header)?.[1] ?? ''
56 if (header.startsWith('No commits yet on ')) {
57 return { branch: `${header.slice('No commits yet on '.length).split(' ')[0]} (no commits yet)`, tracking }
58 }
59 if (header.startsWith('HEAD (no branch)')) {
60 return { branch: 'detached HEAD', tracking }
61 }
62 return { branch: header.split('...')[0].split(' ')[0], tracking }
63}
64
65export const answerStatus = (res: GitResult): string | null => {
66 if (res.exitCode !== 0) {
67 return notARepo(res)
68 }
69 const lines = res.stdout.split('\n').filter(line => line !== '')
70 if (lines.length === 0 || !lines[0].startsWith('## ')) {
71 return null
72 }
73 const { branch, tracking } = parseHeader(lines[0].slice(3))
74 const entries = lines.slice(1)
75 let staged = 0
76 let modified = 0
77 let untracked = 0
78 let conflicted = 0
79 for (const entry of entries) {
80 const x = entry[0]
81 const y = entry[1]
82 if (x === '?' && y === '?') {
83 untracked += 1
84 } else if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) {
85 conflicted += 1
86 } else {
87 if (x !== ' ' && x !== undefined) staged += 1
88 if (y !== ' ' && y !== undefined) modified += 1
89 }
90 }
91 const counts = [
92 staged > 0 ? `${staged} staged` : '',
93 modified > 0 ? `${modified} modified` : '',
94 untracked > 0 ? `${untracked} untracked` : '',
95 conflicted > 0 ? `${conflicted} conflicted` : '',
96 ].filter(part => part !== '')
97 const where = tracking !== '' ? ` (${escapeText(tracking)})` : ''
98 const summary = `${LABEL} ${escapeText(branch)}${where}: ${counts.length > 0 ? counts.join(', ') : 'clean'}`
99 const shown = entries.slice(0, MAX_LISTED).map(entry => ` ${escapeText(entry)}`)
100 const more = entries.length > MAX_LISTED ? [` +${entries.length - MAX_LISTED} more`] : []
101 return [summary, ...shown, ...more].join('\n')
102}
103
104export const answerLastCommit = (res: GitResult): string | null => {
105 if (res.exitCode !== 0) {
106 if (NO_COMMITS.test(res.stderr)) {
107 return `${LABEL} This repository has no commits yet.`
108 }
109 return notARepo(res)
110 }
111 const parts = res.stdout.trim().split('\t')
112 if (parts.length < 4) {
113 return null
114 }
115 const hash = parts[0]
116 const when = parts[parts.length - 2]
117 const author = parts[parts.length - 1]
118 const subject = parts.slice(1, -2).join(' ')
119 return `${LABEL} ${escapeText(hash)} ${escapeText(subject)} (${escapeText(when)}, ${escapeText(author)})`
120}
121
122const lastLine = (text: string): string => {
123 const lines = text.split('\n').filter(line => line.trim() !== '')
124 return lines.length === 0 ? 'none' : escapeText(lines[lines.length - 1].trim())
125}
126
127export const answerDiffSummary = (unstaged: GitResult, staged: GitResult): string | null => {
128 const missing = notARepo(unstaged, staged)
129 if (missing !== null) {
130 return missing
131 }
132 if (unstaged.exitCode !== 0 || staged.exitCode !== 0) {
133 return null
134 }
135 return `${LABEL} unstaged: ${lastLine(unstaged.stdout)}; staged: ${lastLine(staged.stdout)}`
136}
137
138export const answerLastTest = (record: LastTest | null, now: number): string | null => {
139 if (record === null) {
140 return null
141 }
142 const outcome = record.failed ? 'failed' : 'passed'
143 return (
144 `${LABEL} \`${escapeText(record.command, 120)}\` ${outcome}, ${formatAge(now - record.at)}. ` +
145 'The model ran it; I did not re-run it, and files may have changed since.'
146 )
147}
148hooks/match.ts 79 lines1export type Intent = 'branch' | 'status' | 'lastCommit' | 'diffSummary' | 'lastTest'
2
3/**
4 * The questions answered locally, as whole normalized messages. To answer another phrasing, add it
5 * here and add it to match.test.ts; a phrase must not appear under two intents.
6 */
7export const PHRASES: Record<Intent, readonly string[]> = {
8 branch: [
9 'what branch am i on',
10 'which branch am i on',
11 'what branch is this',
12 "what's the current branch",
13 'current branch',
14 'which branch',
15 'what branch',
16 ],
17 status: [
18 'what changed',
19 "what's changed",
20 'what did i change',
21 'what have i changed',
22 'what files changed',
23 'which files changed',
24 'git status',
25 'any uncommitted changes',
26 ],
27 lastCommit: [
28 'last commit',
29 'what was the last commit',
30 "what's the latest commit",
31 'what was my last commit',
32 ],
33 diffSummary: ['diff stat', 'how big is the diff', 'how many lines changed'],
34 lastTest: [
35 'did the last test pass',
36 'did the tests pass',
37 'did the last test run pass',
38 'do the tests pass',
39 'are the tests passing',
40 'what was the last test result',
41 ],
42}
43
44const MAX_LENGTH = 60
45
46export const normalize = (text: string): string =>
47 text
48 .replace(/[‘’ʼ]/g, "'")
49 .toLowerCase()
50 .replace(/\s+/g, ' ')
51 .trim()
52 .replace(/[?!.]+$/, '')
53 .trim()
54
55const INTENT_OF = new Map<string, Intent>()
56for (const [intent, phrases] of Object.entries(PHRASES)) {
57 for (const phrase of phrases) {
58 INTENT_OF.set(phrase, intent as Intent)
59 }
60}
61
62/** The intent when the whole message is one of the known phrases; null for anything else. */
63export const matchIntent = (text: string): Intent | null => {
64 const trimmed = text.trim()
65 if (trimmed.length === 0 || trimmed.length > MAX_LENGTH || /[\r\n]/.test(trimmed)) {
66 return null
67 }
68 return INTENT_OF.get(normalize(trimmed)) ?? null
69}
70
71/** The message without a leading `ask:`, or null when it does not start with one (or is only that). */
72export const stripAskPrefix = (text: string): string | null => {
73 const match = /^\s*ask:\s*([\s\S]*)$/i.exec(text)
74 if (match === null || match[1].trim().length === 0) {
75 return null
76 }
77 return match[1]
78}
79hooks/testrun.ts 86 lines1import type { LastTest } from '../types'
2
3const RUNNERS: readonly RegExp[] = [
4 /^(?:pytest|py\.test)(?: |$)/,
5 /^python3?(?:\.\d+)? -m (?:pytest|unittest)(?: |$)/,
6 /^(?:npm|pnpm|yarn) (?:run )?test(?: |$)/,
7 /^npm t(?: |$)/,
8 /^cargo(?: \+\S+)? test(?: |$)/,
9 /^go test(?: |$)/,
10 /^dotnet test(?: |$)/,
11 /^(?:mvn|\.\/mvnw) test(?: |$)/,
12 /^(?:gradle|\.\/gradlew) test(?: |$)/,
13 /^(?:bundle exec )?rspec(?: |$)/,
14 /^(?:npx )?jest(?: |$)/,
15 /^(?:npx )?vitest(?: |$)/,
16 /^make test(?: |$)/,
17]
18
19// A runner named anywhere in a command, however it is wrapped: used only to notice that a test run
20// happened which isTestCommand cannot read cleanly, so the older record must not keep answering.
21const MENTIONS_RUNNER =
22 /(?:^|[^A-Za-z0-9_.-])(?:pytest|py\.test|unittest|jest|vitest|rspec)(?![A-Za-z0-9_-])|(?:^|[^A-Za-z0-9_-])(?:npm|pnpm|yarn)\s+(?:run\s+)?t(?:est)?(?![A-Za-z0-9_-])|(?:cargo(?:\s+\+\S+)?|go|dotnet|mvn|\.\/mvnw|gradle|\.\/gradlew|make)\s+test(?![A-Za-z0-9_-])/
23
24const ASSIGNMENTS = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/
25const CD = /^cd(?: |$)/
26const MAX_COMMAND = 200
27
28// Fields of a Bash result that say the command did not run to its end in the foreground.
29const NOT_FINISHED = ['backgroundTaskId', 'backgroundedByUser', 'timedOutAfterMs', 'interrupted'] as const
30
31/**
32 * True when the command's exit status is the test run's: runner last, only `cd` before it, and no
33 * `;`, `|`, `&` or newline that would let another command decide the status. A wrong "passed"
34 * answer is worse than none, so anything doubtful is not a test command.
35 */
36export const isTestCommand = (command: string): boolean => {
37 const cleaned = command.replace(/\d*>&\d+/g, ' ').replace(/&&/g, '\u0000')
38 if (/[;|&\r\n]/.test(cleaned)) {
39 return false
40 }
41 const segments = cleaned.split('\u0000').map(segment => segment.trim())
42 const last = segments[segments.length - 1].replace(ASSIGNMENTS, '')
43 if (!RUNNERS.some(runner => runner.test(last))) {
44 return false
45 }
46 return segments.slice(0, -1).every(segment => CD.test(segment))
47}
48
49const finishedInForeground = (result: unknown): boolean => {
50 if (typeof result !== 'object' || result === null) {
51 return true
52 }
53 const fields = result as Record<string, unknown>
54 return !NOT_FINISHED.some(key => Boolean(fields[key]))
55}
56
57/** What a finished Bash call means for the kept record of the last test run. */
58export type Observation = { kind: 'record'; record: LastTest } | { kind: 'clear' } | { kind: 'ignore' }
59
60/**
61 * The rule is: record only when certain, otherwise clear. A clean foreground test command is
62 * recorded (failed or not, whatever its output says). A test run that cannot be read cleanly
63 * (piped, chained, backgrounded, interrupted) clears the record, so the question goes to the model
64 * instead of an older run answering for it. Anything unrelated to tests changes nothing.
65 */
66export const observe = (
67 command: string,
68 background: boolean,
69 ran: { deny?: string; isError?: boolean; result?: unknown },
70 now: number,
71): Observation => {
72 if (ran.deny !== undefined) {
73 return { kind: 'ignore' }
74 }
75 if (!isTestCommand(command)) {
76 return MENTIONS_RUNNER.test(command) ? { kind: 'clear' } : { kind: 'ignore' }
77 }
78 if (background || !finishedInForeground(ran.result)) {
79 return { kind: 'clear' }
80 }
81 return {
82 kind: 'record',
83 record: { command: command.trim().slice(0, MAX_COMMAND), failed: ran.isError === true, at: now },
84 }
85}
86types/index.d.ts 8 lines1export type LastTest = { command: string; failed: boolean; at: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'local-answer-router': { lastTest: LastTest | null }
6 }
7}
8