Names the uncommitted files to you at the end of each turn, and tells the model at the next prompt to commit each finished piece as it lands.

A long session easily ends with twenty changed files and one giant commit, where the fix, the refactor and the experiment are all mixed up. This mod looks at the working tree at the end of every turn and tells you, and the model, what is still uncommitted, so finished work gets committed as it lands instead of piling up for the end.
git status --porcelain=v1 -z in the directory the session started in and reads the paths it names, staged or not. Ignored files and untracked files under the generated .claude/ directory are left out.commit-cadence: 2 uncommitted file(s): src/app.ts, src/new.ts
the working tree is clean again. The red entries written since the tree was last clean are cleared first, so they do not come back when the pane restores its stream.$.store per repository: its paths and the keys of its red entries. A module loaded again (/reload-plugins, an update, a restart) picks it up at session start. It can then still clear the red entries written before it, without reporting the same paths or sending the note again./commit-cadence measures on the spot and prints the setting and what the tree holds.It stops nothing. You decide what is worth a commit, and the model reads the note as a reminder, not as a gate.
/commit-cadence the setting and what the tree holds right now (also /commit-cadence status) /commit-cadence on | off on by default
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install commit-cadence@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=commit-cadence}, turn.complete, prompt.submit ❯ ./register.ts calls: $.command.register, $.process.run (via readTree), $.session.cwd, $.sidebar.clear (via dropEntries), $.sidebar.set (via toPerson), $.store.delete (via saveOpen), $.store.get (via loadOpen, readSettings), $.store.set (via saveOpen, setEnabled), $.ui.log (via toPerson)
Reach L2: it runs a process.
.claude/, because that directory is generated.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 171 lines1import type { EngineInterface, Register } from 'claude-code'
2import { doneLines, doneText, logText, noteText, openKey, openOf, pathsOf, sectionKey, sidebarLines, statusText } from './cadence.ts'
3
4const ENABLED_KEY = 'enabled'
5
6const USAGE = 'expects nothing (the status), on or off'
7
8/** The consumer this mod writes into the shared sidebar as. */
9const CONSUMER = 'commit-cadence'
10
11/**
12 * The on/off setting, the directory the session started in, the paths last reported, whether the model
13 * still owes a note for them, and the sidebar keys of the red entries written since the tree was last clean.
14 */
15type State = { enabled: boolean; root: string; open: string[]; owed: boolean; keys: Set<string> }
16
17/** Forgets the reported paths and the note they owe, so a tree measured after `on` is reported again. */
18function forgetOpen(state: State): void {
19 state.open = []
20 state.owed = false
21}
22
23/**
24 * Reads the on/off setting from the store, which every window shares, so a change made in another
25 * window applies here at the next hook that acts on it. A mod turned off there forgets its finding here
26 * too, as `off` does.
27 */
28async function readSettings($: EngineInterface, state: State): Promise<void> {
29 const was = state.enabled
30 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
31 if (was && !state.enabled) forgetOpen(state)
32}
33
34/** The finding the person reads: the sidebar while it is open, else one transcript line. */
35async function toPerson($: EngineInterface, key: string, lines: { text: string; kind: 'error' | 'ok' }[], line: string): Promise<void> {
36 try {
37 if (await $.sidebar.set({ consumer: CONSUMER, key, title: 'uncommitted work', lines, until: 'stream' })) return
38 } catch {
39 // The sidebar mod is not installed.
40 }
41 $.ui.log(line)
42}
43
44/** Drops the red entries of a tree that went clean, so a pane restore does not bring them back. */
45async function dropEntries($: EngineInterface, state: State): Promise<void> {
46 for (const key of state.keys) {
47 try {
48 await $.sidebar.clear({ consumer: CONSUMER, key })
49 } catch {
50 // The sidebar mod is not installed.
51 }
52 }
53 state.keys.clear()
54}
55
56/**
57 * Keeps the open finding in `$.store`, so a module loaded again (`/reload-plugins`, an update, a restart)
58 * still closes the red entries the one before it wrote.
59 */
60async function saveOpen($: EngineInterface, state: State): Promise<void> {
61 if (state.open.length === 0) await $.store.delete(openKey(state.root))
62 else await $.store.set(openKey(state.root), { paths: state.open, keys: [...state.keys] })
63}
64
65/** Takes back the open finding an earlier module or session of this repository left. The note was already owed once. */
66async function loadOpen($: EngineInterface, state: State): Promise<void> {
67 const open = openOf(await $.store.get(openKey(state.root)))
68 if (open === undefined) return
69 state.open = open.paths
70 for (const key of open.keys) state.keys.add(key)
71}
72
73/** The uncommitted paths of the session's repository, or undefined when it is not one. */
74async function readTree($: EngineInterface, state: State): Promise<string[] | undefined> {
75 try {
76 const r = await $.process.run(['git', 'status', '--porcelain=v1', '-z'], { cwd: state.root })
77 return r.exitCode === 0 ? pathsOf(r.stdout) : undefined
78 } catch {
79 // git is missing, or the directory is not a repository.
80 return undefined
81 }
82}
83
84/** Whether two lists of paths name the same files, so an unchanged tree is reported once. */
85function isSame(a: readonly string[], b: readonly string[]): boolean {
86 return a.length === b.length && a.every((p, i) => p === b[i])
87}
88
89/**
90 * Measures the tree at the turn's end. A tree that is dirty is reported once per set of paths, so a
91 * long stretch of edits does not repeat the line; a tree that went clean closes the finding.
92 */
93async function afterTurn($: EngineInterface, state: State): Promise<void> {
94 const paths = await readTree($, state)
95 if (paths === undefined) return
96 if (paths.length === 0) {
97 if (state.open.length === 0) return
98 state.open = []
99 state.owed = false
100 await dropEntries($, state)
101 await saveOpen($, state)
102 await toPerson($, sectionKey('clean'), doneLines(), doneText())
103 return
104 }
105 if (isSame(state.open, paths)) return
106 state.open = paths
107 state.owed = true
108 const key = sectionKey(`dirty-${paths.length}`)
109 state.keys.add(key)
110 await saveOpen($, state)
111 await toPerson($, key, sidebarLines(paths), logText(paths))
112}
113
114async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
115 state.enabled = on
116 await $.store.set(ENABLED_KEY, on)
117 if (!on) {
118 forgetOpen(state)
119 await saveOpen($, state)
120 }
121 return on ? 'on: the tree is measured at the end of each turn' : 'off: the tree is not measured'
122}
123
124async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
125 const arg = args.trim()
126 if (arg === 'on' || arg === 'off') return setEnabled($, state, arg === 'on')
127 if (arg !== '' && arg !== 'status') return USAGE
128 await readSettings($, state)
129 return statusText(state.enabled, await readTree($, state))
130}
131
132export const register: Register = on => {
133 const state: State = { enabled: true, root: '', open: [], owed: false, keys: new Set() }
134
135 on('session.start', async ($, e, next) => {
136 const r = await next(e)
137 await readSettings($, state)
138 // The session's own directory, because a Bash cd moves what $.session.cwd() answers.
139 state.root = await $.session.cwd()
140 await loadOpen($, state)
141 await $.command.register({
142 name: 'commit-cadence',
143 description: 'What the working tree holds uncommitted: status, on, off (commit-cadence)',
144 argumentHint: '[on | off]',
145 immediate: true,
146 })
147 return r
148 })
149
150 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
151 on('command.run', { command: 'commit-cadence' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
152
153 on('turn.complete', async ($, e, next) => {
154 const r = await next(e)
155 // A subagent's turn is its own loop's; only the main loop's end is the person's moment to commit.
156 if (e.agentId !== undefined) return r
157 await readSettings($, state)
158 if (state.enabled) await afterTurn($, state)
159 return r
160 })
161
162 // The note rides the next prompt, so the model reads it before it starts the next piece of work.
163 on('prompt.submit', async ($, e, next) => {
164 if (!state.owed || state.open.length === 0) return next(e)
165 await readSettings($, state)
166 if (!state.enabled) return next(e)
167 state.owed = false
168 return next({ ...e, context: [...(e.context ?? []), noteText(state.open)] })
169 })
170}
171hooks/cadence.ts 89 lines1/** What the working tree holds at the end of a turn, and how the two texts read. */
2
3/** Paths the texts name; the rest are counted. */
4export const NAMED = 6
5
6/** The statuses `git status --porcelain` gives a path that is only ignored or unreadable. */
7const SKIP = /^!!|^\?\? \.claude\//
8
9/**
10 * The paths `git status --porcelain=v1 -z` names, staged or not. The records are NUL separated, and a
11 * rename carries its old path as a second record, which is read and dropped.
12 */
13export function pathsOf(out: string): string[] {
14 const records = out.split('\0')
15 const paths: string[] = []
16 for (let i = 0; i < records.length; i += 1) {
17 const record = records[i] ?? ''
18 // A rename or a copy spends the next record on the path it came from, however short that path is,
19 // so the skip comes before any record is dropped.
20 if (/^[RC]|^.[RC]/.test(record)) i += 1
21 if (record.length <= 3 || SKIP.test(record)) continue
22 paths.push(record.slice(3))
23 }
24 return [...new Set(paths)].sort()
25}
26
27/** The paths the texts name, and how many were left out. */
28function shown(paths: readonly string[]): string {
29 const first = paths.slice(0, NAMED).join(', ')
30 const rest = paths.length - NAMED
31 return rest > 0 ? `${first} and ${rest} more` : first
32}
33
34/** The note the model reads: what is uncommitted, and what to do with it. */
35export function noteText(paths: readonly string[]): string {
36 return `commit-cadence: the working tree holds ${paths.length} uncommitted file(s): ${shown(paths)}. Commit each piece of work that is finished and verified now, one commit per change, instead of leaving it for the end of the session. Leave out only a step that would break the tree on its own.`
37}
38
39/** The transcript line the person reads: the finding alone. The engine adds the mod name. */
40export function logText(paths: readonly string[]): string {
41 return `${paths.length} uncommitted file(s): ${shown(paths)}`
42}
43
44/** The line that closes the finding, once the tree holds nothing uncommitted. */
45export function doneText(): string {
46 return 'the working tree is clean again'
47}
48
49/** A sidebar line, as the sidebar mod's contract names it. */
50type Line = { text: string; kind: 'error' | 'ok' }
51
52export function sidebarLines(paths: readonly string[]): Line[] {
53 return [{ text: logText(paths), kind: 'error' }]
54}
55
56export function doneLines(): Line[] {
57 return [{ text: doneText(), kind: 'ok' }]
58}
59
60/** The paths last reported for one repository, and the sidebar keys of the red entries written for them. */
61export type Open = { paths: string[]; keys: string[] }
62
63/** The `$.store` key of a repository's open finding; the store is one file for every project. */
64export function openKey(root: string): string {
65 return `open:${root}`
66}
67
68function isStrings(v: unknown): v is string[] {
69 return Array.isArray(v) && v.every(x => typeof x === 'string')
70}
71
72/** A stored open finding, or undefined for a value of any other shape. */
73export function openOf(value: unknown): Open | undefined {
74 const v = value as Partial<Open> | undefined
75 return v !== undefined && v !== null && isStrings(v.paths) && isStrings(v.keys) ? { paths: v.paths, keys: v.keys } : undefined
76}
77
78/** A sidebar section key: the subject cut to what the sidebar takes. */
79export function sectionKey(text: string): string {
80 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'tree'
81}
82
83/** The `/commit-cadence` answer: the setting and what the tree holds right now. */
84export function statusText(enabled: boolean, paths: readonly string[] | undefined): string {
85 if (paths === undefined) return `${enabled ? 'on' : 'off'} · no git repository was read here`
86 const tree = paths.length === 0 ? 'the working tree is clean' : logText(paths)
87 return `${enabled ? 'on' : 'off'} · ${tree}`
88}
89