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

A band above the prompt that warns when a turn left your project's journal or changelog out of date.
COMMANDS.md and/or CHANGES.md; otherwise silent.COMMANDS.md exists but was not modified this turn: flagged.CHANGES.md exists, was not modified, and code changed this turn: flagged.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.⚠ Not updated this turn: COMMANDS.md, CHANGES.md with a Dismiss button, plus a toast. The warning resets when you submit the next prompt.claude --plugin-dir /path/to/ModsTools/mods/doc-sync-guard
COMMANDS.md, CHANGES.md) and looked up in the session folder only..md files never count as code changes.claude plugin validate mods/doc-sync-guard
claude plugin test mods/doc-sync-guard # 4 testshooks/register.tsx 88 lines1import { 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}
88types/index.d.ts 8 lines1export type MissingDocs = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'doc-sync-guard': { missing: MissingDocs }
6 }
7}
8