SLOPSHOPPER

doc-sync-guard

Warns above the prompt when a turn did not update COMMANDS.md, or changed code without updating CHANGES.md

newbandguardtoastpromptprocess
A shopper browsing a rack in a slop shop
README

doc-sync-guard

A band above the prompt that warns when a turn left your project's journal or changelog out of date.

What it does

  • Active only in a folder that has COMMANDS.md and/or CHANGES.md; otherwise silent.
  • At the end of each main-conversation turn (not subagents, not aborted turns) it compares file modification times with the turn's start:
  • COMMANDS.md exists but was not modified this turn: flagged.
  • CHANGES.md exists, was not modified, and code changed this turn: flagged.
  • "Code changed" means a successful Edit/Write/NotebookEdit on a non-.md file, or (for Bash) a modified/untracked non-.md file in the git repo with a modification time within the turn.
  • Shows ⚠ Not updated this turn: COMMANDS.md, CHANGES.md with a Dismiss button, plus a toast. The warning resets when you submit the next prompt.

Install

claude --plugin-dir /path/to/ModsTools/mods/doc-sync-guard

Limits

  • File names are fixed (COMMANDS.md, CHANGES.md) and looked up in the session folder only.
  • Edits to .md files never count as code changes.
  • The Bash/git check is skipped when more than 500 files are dirty, and relies on file modification times.
  • It only warns; it does not edit anything.

Develop

claude plugin validate mods/doc-sync-guard
claude plugin test mods/doc-sync-guard   # 4 tests
Source 2 files
hooks/register.tsx 88 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MissingDocs } from '../types'
5
6// The journal updated on every message, and the changelog updated on every real change.
7const JOURNAL = 'COMMANDS.md'
8const CHANGELOG = 'CHANGES.md'
9// Above this many dirty files, the git check for code changed through Bash is skipped.
10const DIRTY_LIMIT = 500
11
12const missing = atom({ plugin: 'doc-sync-guard', key: 'missing' } as const, [])
13
14const isCode = (path: string) => !path.endsWith('.md')
15
16async function mtime($: EngineInterface, path: string): Promise<number | undefined> {
17  const stat = await $.fs.stat(path).catch(() => undefined)
18
19  return stat?.kind === 'file' ? stat.mtimeMs : undefined
20}
21
22// Whether a non-Markdown file the repo sees as modified or new was written since `since`.
23async function codeChangedInGit($: EngineInterface, cwd: string, since: number): Promise<boolean> {
24  const listed = await $.process.run(['git', 'ls-files', '-z', '-m', '-o', '--exclude-standard'], { cwd })
25  if (listed.exitCode !== 0) return false
26  const paths = listed.stdout.split('\0').filter(path => path !== '' && isCode(path))
27  if (paths.length > DIRTY_LIMIT) return false
28  for (const path of paths) {
29    const changed = await mtime($, `${cwd}/${path}`)
30    if (changed !== undefined && changed >= since) return true
31  }
32
33  return false
34}
35
36export const register: Register = on => {
37  let startedAt = 0
38  let isCodeEdited = false
39
40  on('prompt.submit', async ($, e, next) => {
41    startedAt = await $.clock.now()
42    isCodeEdited = false
43    await update($, missing, () => [])
44
45    return next(e)
46  })
47
48  on('tool.call', async ($, e, next) => {
49    const ran = await next(e)
50    const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
51    if (path !== undefined && isCode(path) && ran.deny === undefined && ran.isError !== true) isCodeEdited = true
52
53    return ran
54  })
55
56  on('turn.complete', async ($, e, next) => {
57    if (e.agentId !== undefined || e.isAborted || startedAt === 0) return next(e)
58    const cwd = await $.session.cwd()
59    const journal = await mtime($, `${cwd}/${JOURNAL}`)
60    const changelog = await mtime($, `${cwd}/${CHANGELOG}`)
61    if (journal === undefined && changelog === undefined) return next(e)
62
63    const late: MissingDocs = []
64    if (journal !== undefined && journal < startedAt) late.push(JOURNAL)
65    if (changelog !== undefined && changelog < startedAt) {
66      const isCodeChanged = isCodeEdited || (await codeChangedInGit($, cwd, startedAt))
67      if (isCodeChanged) late.push(CHANGELOG)
68    }
69    await update($, missing, () => late)
70    if (late.length > 0) $.ui.toast(`Docs not updated: ${late.join(', ')}`)
71
72    return next(e)
73  })
74
75  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
76    const late = await read($, missing)
77    if (late.length === 0 || e.props.hasSurvey) return next(e)
78    const { Box, Button, Text } = $.ui.resolve(e)
79
80    return (
81      <Box>
82        <Text color="yellow">⚠ Not updated this turn: {late.join(', ')} </Text>
83        <Button key="dismiss" label="Dismiss" onPress={() => update($, missing, () => [])} />
84      </Box>
85    )
86  })
87}
88
types/index.d.ts 8 lines
1export type MissingDocs = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'doc-sync-guard': { missing: MissingDocs }
6  }
7}
8