Adds git branch, dirty count, last commit and next TODOS to the session's system prompt

Gives Claude a short, always-fresh picture of the repository state without having to ask for it.
ahead/behind counts when non-zero- [ ]) items of TODOS.md: those under a ## Next heading, otherwise the first open items anywhere in the fileTODOS.md containing open items.| Command | Effect |
|---|---|
/auto-context | Shows the text currently injected |
/auto-context off | Stops injecting (kept across sessions) |
/auto-context on | Resumes injecting and refreshes |
claude --plugin-dir /path/to/ModsTools/mods/auto-context
TODOS.md are read from the session folder only.claude plugin validate mods/auto-context
claude plugin test mods/auto-context # 9 testshooks/register.ts 76 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { formatContext, nextTodos, parseStatus } from './context'
5import type { GitInfo } from './context'
6
7const SECTION = 'auto-context:session'
8const text = atom({ plugin: 'auto-context', key: 'text' } as const, null)
9
10async function isEnabled($: EngineInterface): Promise<boolean> {
11 return (await $.store.get('enabled')) !== false
12}
13
14async function git($: EngineInterface): Promise<GitInfo | null> {
15 try {
16 const status = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: await $.session.cwd() })
17 if (status.exitCode !== 0) return null
18 const log = await $.process.run(['git', 'log', '-1', '--format=%s'], { cwd: await $.session.cwd() })
19
20 return { ...parseStatus(status.stdout), subject: log.exitCode === 0 ? log.stdout.trim() : '' }
21 } catch {
22 return null
23 }
24}
25
26async function todos($: EngineInterface): Promise<string[]> {
27 try {
28 return nextTodos(await $.fs.read(`${await $.session.cwd()}/TODOS.md`))
29 } catch {
30 return []
31 }
32}
33
34async function refresh($: EngineInterface) {
35 const value = formatContext(await git($), await todos($))
36 await update($, text, () => value)
37 $.ui.invalidate('prompt.section')
38}
39
40export const register: Register = on => {
41 on('session.start', async ($, e, next) => {
42 await $.command.register({ name: 'auto-context', description: 'Show the injected session context; "off" or "on" toggles it' })
43 await refresh($)
44
45 return next(e)
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 if (e.agentId === undefined) await refresh($)
50
51 return next(e)
52 })
53
54 on('prompt.compose', async ($, e, next) => {
55 const composed = await next(e)
56 const value = await read($, text)
57 if (value === null || !(await isEnabled($))) return composed
58
59 return { sections: [...composed.sections, { id: SECTION, text: value, scope: 'session' as const }] }
60 })
61
62 on('command.run', { command: 'auto-context' }, async ($, e) => {
63 const arg = e.args.trim()
64 if (arg === 'on' || arg === 'off') {
65 await $.store.set('enabled', arg === 'on')
66 $.ui.invalidate('prompt.section')
67 if (arg === 'on') await refresh($)
68
69 return { text: `auto-context ${arg}.` }
70 }
71 if (!(await isEnabled($))) return { text: 'auto-context is off.' }
72
73 return { text: (await read($, text)) ?? 'Nothing to inject (no git repository, no TODOS.md items).' }
74 })
75}
76hooks/context.ts 56 lines1export type GitInfo = { branch: string; ahead: number; behind: number; dirty: number; subject: string }
2
3export const MAX_TODOS = 5
4
5// Parses `git status --porcelain=v2 --branch` output.
6export function parseStatus(out: string): Omit<GitInfo, 'subject'> {
7 let branch = ''
8 let ahead = 0
9 let behind = 0
10 let dirty = 0
11 for (const line of out.split('\n')) {
12 if (line.startsWith('# branch.head ')) branch = line.slice(14).trim()
13 else if (line.startsWith('# branch.ab ')) {
14 const m = /\+(\d+) -(\d+)/.exec(line)
15 if (m) {
16 ahead = Number(m[1])
17 behind = Number(m[2])
18 }
19 } else if (line !== '' && !line.startsWith('#')) dirty++
20 }
21 return { branch, ahead, behind, dirty }
22}
23
24// First 5 unchecked items under a `## Next` heading, else the first 5 anywhere.
25export function nextTodos(text: string): string[] {
26 const all: string[] = []
27 const next: string[] = []
28 let inNext = false
29 for (const line of text.split('\n')) {
30 const heading = /^#{1,6}\s+(.*)$/.exec(line)
31 if (heading) inNext = /^next\b/i.test(heading[1]?.trim() ?? '')
32 const item = /^\s*[-*]\s+\[ \]\s+(.*\S)\s*$/.exec(line)
33 if (item?.[1]) {
34 all.push(item[1])
35 if (inNext) next.push(item[1])
36 }
37 }
38 return (next.length > 0 ? next : all).slice(0, MAX_TODOS)
39}
40
41export function formatContext(git: GitInfo | null, todos: readonly string[]): string | null {
42 const lines: string[] = []
43 if (git) {
44 const sync = [git.ahead > 0 ? `ahead ${git.ahead}` : '', git.behind > 0 ? `behind ${git.behind}` : '']
45 .filter(Boolean)
46 .join(', ')
47 lines.push(`Git branch: ${git.branch}${sync ? ` (${sync})` : ''}`)
48 lines.push(`Uncommitted files: ${git.dirty}`)
49 if (git.subject) lines.push(`Last commit: ${git.subject}`)
50 }
51 if (todos.length > 0) lines.push('Next TODOS:', ...todos.map(t => `- [ ] ${t}`))
52 if (lines.length === 0) return null
53
54 return ['Session context (auto-context, as of the last turn):', ...lines].join('\n')
55}
56types/index.d.ts 8 lines1export type ContextText = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'auto-context': { text: ContextText }
6 }
7}
8