End-of-session ritual: typing 'je coupe' asks Claude to update the doc files and shows a band of the ones still untouched

End-of-session ritual: typing "je coupe" asks Claude to bring every doc file of the project up to date, and tracks which ones are still untouched.
COMMANDS.md, CHANGES.md, MEMORY.md, TODOS.md, PLAN.md, README.md, ARCHITECTURE.md, ARCHITECTURE_EN.md.(NOT modified this session).Je coupe: N docs not updated yet: …) with a Dismiss button. The band is re-evaluated after each main turn and disappears once every doc is newer than the session start.claude --plugin-dir /path/to/ModsTools/mods/je-coupe
claude plugin validate mods/je-coupe
claude plugin test mods/je-coupe # 10 testshooks/register.tsx 59 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending } from '../types'
5import { DOCS, bandLabel, instruction, isJeCoupe, staleDocs } from './ritual'
6import type { DocStat } from './ritual'
7
8const pending = atom({ plugin: 'je-coupe', key: 'pending' } as const, null)
9
10async function scan($: EngineInterface): Promise<DocStat[]> {
11 const cwd = await $.session.cwd()
12 const found: DocStat[] = []
13 for (const name of DOCS) {
14 try {
15 const stat = await $.fs.stat(`${cwd}/${name}`)
16 if (stat.kind === 'file') found.push({ name, mtimeMs: stat.mtimeMs })
17 } catch {
18 // missing doc: not part of this folder's ritual
19 }
20 }
21
22 return found
23}
24
25export const register: Register = on => {
26 on('prompt.submit', async ($, e, next) => {
27 if (!isJeCoupe(e.text)) return next(e)
28 const docs = await scan($)
29 if (docs.length === 0) return next(e)
30 const { startedAt } = await $.session.usage()
31 await update($, pending, () => ({ stale: staleDocs(docs, startedAt) }) satisfies Pending)
32
33 return next({ ...e, text: `${e.text}\n\n${instruction(docs, startedAt)}` })
34 })
35
36 on('turn.complete', async ($, e, next) => {
37 if (e.agentId === undefined && (await read($, pending)) !== null) {
38 const { startedAt } = await $.session.usage()
39 const stale = staleDocs(await scan($), startedAt)
40 await update($, pending, () => (stale.length === 0 ? null : { stale }))
41 }
42
43 return next(e)
44 })
45
46 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
47 const state = await read($, pending)
48 if (e.props.hasSurvey || state === null) return next(e)
49 const { Box, Button, Text } = $.ui.resolve(e)
50
51 return (
52 <Box>
53 <Text>{bandLabel(state.stale)}</Text>
54 <Button key="dismiss" label="Dismiss" onPress={() => update($, pending, () => null)} />
55 </Box>
56 )
57 })
58}
59hooks/ritual.ts 38 lines1export const DOCS = [
2 'COMMANDS.md',
3 'CHANGES.md',
4 'MEMORY.md',
5 'TODOS.md',
6 'PLAN.md',
7 'README.md',
8 'ARCHITECTURE.md',
9 'ARCHITECTURE_EN.md',
10] as const
11
12export type DocStat = { name: string; mtimeMs: number }
13
14export function isJeCoupe(text: string): boolean {
15 const norm = text
16 .normalize('NFD')
17 .replace(/\p{M}/gu, '')
18 .toLowerCase()
19 .replace(/[^a-z0-9]+/g, ' ')
20 .trim()
21
22 return norm === 'je coupe' || norm.startsWith('je coupe ')
23}
24
25export const staleDocs = (docs: DocStat[], startedAt: number) =>
26 docs.filter(d => d.mtimeMs < startedAt).map(d => d.name)
27
28export function instruction(docs: DocStat[], startedAt: number): string {
29 const stale = new Set(staleDocs(docs, startedAt))
30 const list = docs.map(d => (stale.has(d.name) ? `${d.name} (NOT modified this session)` : d.name)).join(', ')
31
32 return `End of session: before stopping, bring all these doc files up to date with the work done: ${list}.`
33}
34
35export function bandLabel(stale: string[]): string {
36 return `Je coupe: ${stale.length} doc${stale.length === 1 ? '' : 's'} not updated yet: ${stale.join(', ')}`
37}
38types/index.d.ts 8 lines1export type Pending = { stale: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'je-coupe': { pending: Pending | null }
6 }
7}
8