Companion to vibe-wise: offers learning mode once per project, holds Claude's edits while a checkpoint waits on you, and shows where you are.

A companion mod for the vibe-wise learning plugin. vibe-wise teaches; vibe-guard makes sure it's on when you want it and that autonomous runs don't skip past your checkpoints. It reads vibe-wise's notes in .vibe-wise/ and never changes vibe-wise itself.
/vibe-wise:learn with your prompt; Not in this project is remembered; Ask me later asks again next session. Paused projects are left alone.progress.md has a ## Pending decision, Claude's Edit, Write and NotebookEdit calls are refused with a reminder to ask you first. Writes to .vibe-wise/ still go through, so vibe-wise can record your answer and clear the hold. Writes Claude makes through Bash are not caught./learning shows the same status; /learning pause and /learning resume flip the profile's Learning mode: line.Needs vibe-wise installed; without it vibe-guard does nothing.
/plugin install vibe-guard@session-health
claude plugin validate vibe-guard
claude plugin test vibe-guardhooks/register.ts 143 lines1import type { EngineInterface, Register } from 'claude-code'
2import { absolute, isInside, locate, parsePending, parseProfile, setMode, type Located, type Mode, type Pending } from './notes.ts'
3
4type $ = EngineInterface
5type Look = Located & { mode: Mode; pending?: Pending }
6
7const LEARN = 'vibe-wise:learn'
8const START = 'Start learning'
9const NEVER = 'Not in this project'
10const LATER = 'Ask me later'
11
12// Asked at most once a session; "Ask me later" waits for the next one.
13let asked = false
14
15async function readOr($: $, path: string): Promise<string | undefined> {
16 try {
17 return await $.fs.read(path)
18 } catch {
19 return undefined
20 }
21}
22
23// What vibe-wise's notes say right now: read fresh each time, since vibe-wise edits them mid-turn.
24async function look($: $): Promise<Look> {
25 const loc = await locate(await $.session.root(), p => $.fs.exists(p))
26 if (!loc.dir) return { ...loc, mode: 'none' }
27 const mode = parseProfile((await readOr($, `${loc.dir}/profile.md`)) ?? '')
28 if (mode !== 'active') return { ...loc, mode }
29 const pending = parsePending((await readOr($, `${loc.dir}/progress.md`)) ?? '')
30 return pending ? { ...loc, mode, pending } : { ...loc, mode }
31}
32
33function describe(s: Look): string | undefined {
34 if (s.mode === 'paused') return '✦ VibeWise · paused · /learning resume'
35 if (s.mode !== 'active') return undefined
36 if (!s.pending) return '✦ VibeWise · learning on'
37 const stage = s.pending.stage ? ` (${s.pending.stage})` : ''
38 return `✦ VibeWise · waiting on you: ${s.pending.name}${stage}`
39}
40
41async function refresh($: $): Promise<Look> {
42 const s = await look($)
43 $.ui.status(describe(s))
44 return s
45}
46
47const project = (root: string) => root.split('/').filter(Boolean).pop() ?? root
48const neverKey = (root: string) => `never:${root}`
49
50function held(s: Look & { pending: Pending }): string {
51 const stage = s.pending.stage ? ` (${s.pending.stage})` : ''
52 return (
53 `vibe-guard: the VibeWise checkpoint "${s.pending.name}" is still waiting on the learner${stage}. ` +
54 `Don't change project files yet. Present the checkpoint, ask the learner, and wait for their reply. ` +
55 `Only after they approve, clear the "## Pending decision" section in ${s.dir}/progress.md as the VibeWise guide says; ` +
56 `edits are allowed again once it is gone. Never clear it without the learner's answer.`
57 )
58}
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 asked = false
63 await $.command.register({
64 name: 'learning',
65 description: 'VibeWise status, or /learning pause | resume',
66 argumentHint: '[pause|resume]',
67 immediate: true,
68 })
69 try {
70 await refresh($)
71 } catch {}
72 return next(e)
73 })
74
75 on('turn.complete', async ($, e, next) => {
76 try {
77 await refresh($)
78 } catch {}
79 return next(e)
80 })
81
82 on('prompt.submit', async ($, e, next) => {
83 const isUsers = e.origin === undefined || e.origin.kind === 'composer'
84 if (asked || !isUsers || e.text.trimStart().startsWith('/') || e.attachments?.length) return next(e)
85 if (!(await $.command.list()).some(c => c.name === LEARN)) return next(e)
86 const s = await look($)
87 if (s.mode !== 'none' || (await $.store.get(neverKey(s.root)))) return next(e)
88
89 asked = true
90 let answer: string
91 try {
92 answer = await $.ui.ask(`Start vibe-wise learning in ${project(s.root)}?`, {
93 options: [START, NEVER, LATER],
94 header: 'VibeWise',
95 })
96 } catch {
97 return next(e)
98 }
99 if (answer === NEVER) await $.store.set(neverKey(s.root), true)
100 if (answer !== START) return next(e)
101 // A prompt.submit hook may not run a command (it would wait on the turn it holds): start it just after.
102 const text = e.text
103 $.clock.after(1, () => {
104 void $.command.run({ command: LEARN, args: text }).catch((err: unknown) =>
105 $.ui.toast(`VibeWise didn't start: ${err instanceof Error ? err.message : String(err)}`),
106 )
107 })
108 return { drop: 'Starting VibeWise learning; your prompt goes with it.' }
109 }).catch(($, e, next) => next(e))
110
111 for (const tool of ['Edit', 'Write', 'NotebookEdit'] as const) {
112 on('tool.call', { tool }, async ($, e, next) => {
113 const raw = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
114 const path = absolute(raw, await $.session.cwd())
115 const s = await look($)
116 const ownNotes = s.dir !== undefined && isInside(path, s.dir)
117 if (s.mode === 'active' && s.pending && !ownNotes) return { deny: held({ ...s, pending: s.pending }) }
118 const r = await next(e)
119 if (ownNotes) await refresh($)
120 return r
121 }).catch(($, e, next) => next(e))
122 }
123
124 on('command.run', { command: 'learning' }, async ($, e) => {
125 const s = await look($)
126 if (!s.dir || s.mode === 'none') {
127 return { text: `No VibeWise notes in ${project(s.root)}. Run /${LEARN} to start learning here.` }
128 }
129 const arg = e.args.trim().toLowerCase()
130 if (arg === 'pause' || arg === 'resume') {
131 const path = `${s.dir}/profile.md`
132 await $.fs.write(path, setMode((await readOr($, path)) ?? '', arg === 'pause' ? 'paused' : 'active'))
133 await refresh($)
134 return {
135 text: arg === 'pause'
136 ? 'VibeWise paused: edits are no longer held. /learning resume turns it back on.'
137 : 'VibeWise learning resumed.',
138 }
139 }
140 return { text: describe(await refresh($)) ?? '' }
141 })
142}
143hooks/notes.ts 74 lines1// Reading and writing the notes vibe-wise keeps in a project's `.vibe-wise/`
2// (or legacy `.sensible-vibes/`), the way vibe-wise itself reads them.
3
4export type Mode = 'none' | 'active' | 'paused'
5export type Pending = { name: string; stage?: string }
6export type Located = { root: string; dir?: string }
7
8const NAMES = ['.vibe-wise', '.sensible-vibes'] as const
9const MODE_LINE = /^Learning mode:\s*(\S.*?)\s*$/im
10const STAGES = ['reasoning', 'choice confirmation', 'implementation approval'] as const
11
12// vibe-wise's own rule: a non-empty profile is active unless its mode line says paused.
13export function parseProfile(text: string): Mode {
14 if (!text.trim()) return 'none'
15 const m = MODE_LINE.exec(text)
16 return m && m[1]!.toLowerCase() === 'paused' ? 'paused' : 'active'
17}
18
19// The `## Pending decision` section vibe-wise keeps while a checkpoint waits on the learner.
20export function parsePending(progress: string): Pending | undefined {
21 const lines = progress.split('\n')
22 const at = lines.findIndex(l => /^##\s+Pending decision\s*$/i.test(l.trim()))
23 if (at < 0) return undefined
24 const body: string[] = []
25 for (const line of lines.slice(at + 1)) {
26 if (/^#{1,2}\s/.test(line)) break
27 const text = line.replace(/^\s*[-*]\s*/, '').replace(/\*\*/g, '').trim()
28 if (text) body.push(text)
29 }
30 const all = body.join('\n').toLowerCase()
31 const stage = STAGES.find(s => all.includes(s))
32 const named = body.map(l => /^(?:decision|checkpoint|name)\s*:\s*(.+)$/i.exec(l)?.[1]).find(Boolean)
33 const name = named ?? body.find(l => !/^stage\s*:/i.test(l)) ?? 'a decision'
34 return stage === undefined ? { name } : { name, stage }
35}
36
37export function setMode(profile: string, mode: 'active' | 'paused'): string {
38 const line = `Learning mode: ${mode}`
39 if (MODE_LINE.test(profile)) return profile.replace(MODE_LINE, line)
40 const lines = profile.split('\n')
41 const title = lines.findIndex(l => l.startsWith('# '))
42 lines.splice(title + 1, 0, '', line)
43 return lines.join('\n')
44}
45
46export function isInside(path: string, dir: string): boolean {
47 return path === dir || path.startsWith(dir + '/')
48}
49
50export function absolute(path: string, cwd: string): string {
51 const joined = path.startsWith('/') ? path : `${cwd}/${path}`
52 const out: string[] = []
53 for (const part of joined.split('/')) {
54 if (part === '' || part === '.') continue
55 if (part === '..') out.pop()
56 else out.push(part)
57 }
58 return '/' + out.join('/')
59}
60
61// Walks up from `start` to the nearest notes directory, never past a repository boundary.
62export async function locate(start: string, exists: (path: string) => Promise<boolean>): Promise<Located> {
63 let dir = start
64 for (;;) {
65 for (const name of NAMES) {
66 if (await exists(`${dir}/${name}`)) return { root: dir, dir: `${dir}/${name}` }
67 }
68 if (await exists(`${dir}/.git`)) return { root: dir }
69 const parent = dir.slice(0, dir.lastIndexOf('/')) || '/'
70 if (parent === dir) return { root: start }
71 dir = parent
72 }
73}
74