SLOPSHOPPER

vibe-guard

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.

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vibe-guard
› fix the failing auth test and add an audit log call ⏺ 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 › /learning ⎿ vibe-guard: No VibeWise notes in app. Run /vibe-wise:learn to start learning here. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

vibe-guard

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.

  • Asks once per project. Your first prompt in a project without vibe-wise notes asks Start vibe-wise learning here? Start learning runs /vibe-wise:learn with your prompt; Not in this project is remembered; Ask me later asks again next session. Paused projects are left alone.
  • Holds edits at checkpoints. While learning is active and 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.
  • Shows where you are on the status line: learning on, paused, or which checkpoint is waiting on you.
  • /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.

Install

/plugin install vibe-guard@session-health

Development

claude plugin validate vibe-guard
claude plugin test vibe-guard
Source 2 files
hooks/register.ts 143 lines
1import 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}
143
hooks/notes.ts 74 lines
1// 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