SLOPSHOPPER

commit-cadence

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.

newcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-cadence
› fix the failing auth test and add an audit log call ● commit-cadence: 1 uncommitted file(s): src/auth.ts ?? src/auth.test.ts ⏺ 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 › /commit-cadence ⎿ commit-cadence: on · 1 uncommitted file(s): src/auth.ts ⎿ commit-cadence: ?? src/auth.test.ts ⎿ commit-cadence: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

commit-cadence

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.

What it does

  1. At the end of each main-loop turn it runs 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.
  2. A dirty tree is reported once per set of paths. A later turn that changed nothing stays quiet, and a new or removed path reports again, so a long stretch of edits does not repeat the same line.
  3. You read the finding as one red line in the sidebar stream, or as a transcript line while the sidebar is closed:

commit-cadence: 2 uncommitted file(s): src/app.ts, src/new.ts

  1. Your next prompt carries a note only the model reads: what is uncommitted, and that every finished and verified piece belongs in its own commit now. The note goes out once per report, so one prompt carries it and the next one does not.
  2. When the tree is clean again, a green line closes the finding: 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.
  3. While a finding stands, it is kept in $.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.
  4. /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.

Command

/commit-cadence the setting and what the tree holds right now (also /commit-cadence status) /commit-cadence on | off on by default

Install

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.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod if you want the findings there. Without it the lines go to the transcript.

What it can reach

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.

  1. Reads: the paths git status names in the session's own directory. It reads no file content, no prompt and no answer.
  2. Runs: git status --porcelain=v1 -z, once per turn that ends and once per /commit-cadence
  3. Sends: to the model, the count and the first six paths of the uncommitted files, with one sentence about committing them
  4. Persists: in $.store, the on/off setting, and per repository the open finding (the uncommitted paths and the keys of their red entries) until the tree is clean
  5. Hostile input: the only text drawn and sent is the paths git itself printed, cut to six names and a count

Limits

  • It measures the tree, not who changed it. A file you edited by hand counts exactly like one the model edited.
  • It measures the session's own directory. Another repository you work in during the same session is not read.
  • Only the main loop's turn end is measured, not a subagent's.
  • Ignored paths are left out, and so are untracked files under .claude/, because that directory is generated.
  • It names files, never hunks. A file holding two unrelated changes shows up as one path.
  • It commits nothing and stops nothing.

Development

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

Source 2 files
hooks/register.ts 171 lines
1import 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}
171
hooks/cadence.ts 89 lines
1/** 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