SLOPSHOPPER

code-critic-th

รีวิวโค้ดที่ Claude เพิ่งแก้ด้วย AI ตัวที่สอง (Haiku) ภาษาไทย แบบตรงๆ หรือแบบน่ารัก และเตือนเมื่อไฟล์ถูกแก้วนหลายรอบ

newguardcommandmodelprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-critic-th
› 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 › /critic ⎿ code-critic-th: 🧐 Code Critic: src/auth.ts, src/audit.ts, src/cache.ts ⎿ code-critic-th: ⎿ code-critic-th: OK ⎿ code-critic-th: ⎿ code-critic-th: ให้ Claude แก้ตาม: /critic fix ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 51: Code Critic TH

AI ตัวที่สอง (Haiku) รีวิวโค้ดที่ Claude เพิ่งแก้: อ่าน git diff ของไฟล์ที่แก้ในรอบนั้น หาเฉพาะปัญหาจริง (บั๊ก ช่องโหว่ ข้อมูลลับ การจัดการ error ที่หาย) ไม่ติเรื่องรสนิยม เลือกโทนตรงไปตรงมาหรือแบบน่ารักได้ ส่งผลรีวิวให้ Claude แก้ต่อได้ด้วยคำสั่งเดียว และเตือนเมื่อไฟล์เดียวถูกแก้วนเกิน 4 รอบใน 1 ชั่วโมง

คำสั่ง

คำสั่งทำอะไร
/criticรีวิวไฟล์ที่ Claude แก้ในรอบล่าสุด
/critic file <path>รีวิวไฟล์ที่ระบุ
/critic fixส่งผลรีวิวให้ Claude ตรวจและแก้
`/critic auto on\off`รีวิวเองทุกครั้งที่ Claude แก้ไฟล์เสร็จ (ค่าเริ่มต้น: ปิด)
`/critic tone plain\cute`โทนการรีวิว
/critic churnไฟล์ที่ถูกแก้วนบ่อย

ติดตั้ง

/plugin install code-critic-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. จำไฟล์ที่ถูกแก้และนับการแก้วน
  2. รวบรวม git diff (หรือเนื้อไฟล์ถ้าไม่อยู่ใน git)
  3. สั่ง Haiku ให้ชี้เฉพาะปัญหาจริง
  4. ดัก Edit/Write ที่สำเร็จ
  5. คำสั่ง /critic และส่งต่อด้วย $.prompt.submit

หมายเหตุ

รีวิวอัตโนมัติใช้ token เพิ่มทุกครั้งที่ Claude แก้ไฟล์ จึงปิดไว้เป็นค่าเริ่มต้น ผลรีวิวโมเดลหลักไม่เห็นจนกว่าคุณสั่ง /critic fix และ Claude จะตรวจก่อนว่าแต่ละข้อจริงไหม

Source 1 files
hooks/register.tsx 213 lines
1/**
2 * ============================================================================
3 * MOD 51: Code Critic TH - AI ตัวที่สองคอยรีวิวโค้ดที่ Claude เพิ่งเขียน (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: การรีวิวโค้ดแบบ pair programming - คนเขียนกับคนตรวจไม่ใช่คนเดียวกัน
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. จำว่าในรอบนี้ Claude แก้ไฟล์ไหนบ้าง (Edit / Write / MultiEdit / NotebookEdit)
9 * 2. /critic - ส่ง diff ของไฟล์เหล่านั้นให้ Haiku รีวิว หาบั๊ก ช่องโหว่ ท่าที่ไม่ควรทำ
10 *    ผลรีวิวขึ้นในแชตให้คุณอ่าน (โมเดลหลักไม่เห็น จนกว่าคุณสั่ง /critic fix)
11 * 3. /critic auto on - รีวิวเองทุกครั้งที่ Claude ทำงานเสร็จและมีการแก้ไฟล์
12 * 4. เตือนเมื่อไฟล์เดียวถูกแก้วนเกิน 4 รอบใน 1 ชั่วโมง (สัญญาณว่ากำลังแก้ปลายเหตุ)
13 * 5. เลือกโทนได้: ตรงไปตรงมา หรือแบบน่ารัก 🌸
14 */
15
16import type { EngineInterface, Register } from 'claude-code'
17
18const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
19const CHURN_LIMIT = 4
20const CHURN_WINDOW = 60 * 60_000
21const MAX_PROMPT = 24_000
22
23let touched = new Set<string>() // ไฟล์ที่แก้ในรอบนี้
24let lastTouched: string[] = [] // ไฟล์ของรอบล่าสุดที่จบแล้ว
25let edits = new Map<string, number[]>() // เวลาที่แก้แต่ละไฟล์
26let warnedAt = new Map<string, number>()
27let lastReview = ''
28let settings = { auto: false, tone: 'plain' as 'plain' | 'cute' }
29let reviewing = false
30
31// ==========================================================================
32// บทที่ 1: เก็บรายชื่อไฟล์ที่ถูกแก้ + นับการแก้วน
33// ==========================================================================
34function fileOf(e: Record<string, unknown>): string | undefined {
35  const p = e.file_path ?? e.notebook_path
36  return typeof p === 'string' ? p : undefined
37}
38
39function churnCount(file: string, now: number): number {
40  return (edits.get(file) ?? []).filter(t => now - t < CHURN_WINDOW).length
41}
42
43function shortName(file: string): string {
44  return file.split(/[\\/]/).slice(-2).join('/')
45}
46
47// ==========================================================================
48// บทที่ 2: รวบรวมสิ่งที่จะให้รีวิว - git diff ถ้ามี ไม่งั้นอ่านไฟล์ตรงๆ
49// ==========================================================================
50async function collect($: EngineInterface, files: string[]): Promise<string> {
51  const parts: string[] = []
52  let size = 0
53  for (const file of files) {
54    if (size > MAX_PROMPT) break
55    const diff = await $.process.run(['git', 'diff', '--no-color', '-U4', '--', file], { timeoutMs: 15_000 }).catch(() => undefined)
56    let body = diff && diff.exitCode === 0 ? diff.stdout.trim() : ''
57    if (!body) {
58      // ไฟล์ใหม่ / ไม่ได้อยู่ใน git: ส่งเนื้อไฟล์ (ตัดหัว 300 บรรทัด)
59      const text = await $.fs.read(file).catch(() => '')
60      body = text ? `(ไฟล์ทั้งไฟล์)\n${text.split('\n').slice(0, 300).join('\n')}` : ''
61    }
62    if (!body) continue
63    const chunk = `### ${file}\n${body.slice(0, MAX_PROMPT - size)}`
64    parts.push(chunk)
65    size += chunk.length
66  }
67  return parts.join('\n\n')
68}
69
70// ==========================================================================
71// บทที่ 3: ให้ Haiku รีวิว - สั่งให้ชี้เฉพาะปัญหาจริง ไม่บ่นเรื่องรสนิยม
72// ==========================================================================
73async function review($: EngineInterface, files: string[]): Promise<string> {
74  const material = await collect($, files)
75  if (!material) return '🤷 ไม่พบการเปลี่ยนแปลงให้รีวิว (ไฟล์อาจถูก commit ไปแล้ว)'
76  const tone =
77    settings.tone === 'cute'
78      ? 'ใช้น้ำเสียงอ่อนโยน น่ารัก ให้กำลังใจ ลงท้ายด้วย "นะคะ" ได้ ใส่ emoji ดอกไม้หรือหัวใจได้เล็กน้อย แต่เนื้อหาต้องตรงประเด็น'
79      : 'ใช้น้ำเสียงตรงไปตรงมาแบบเพื่อนร่วมทีมอาวุโส'
80  const system = `คุณเป็นผู้รีวิวโค้ด ตอบเป็นภาษาไทย ${tone}
81หาเฉพาะปัญหาที่มีผลจริง: บั๊ก, กรณีขอบที่พัง, ช่องโหว่ความปลอดภัย, ข้อมูลลับในโค้ด, การจัดการ error ที่หายไป, ท่าที่อันตราย (เช่น any ใน TypeScript ที่ซ่อนบั๊ก, var, SQL ต่อ string)
82ห้ามติเรื่องรสนิยม การตั้งชื่อ หรือ format
83รูปแบบ: ไม่เกิน 5 ข้อ แต่ละข้อขึ้นต้นด้วย "ไฟล์:บรรทัดโดยประมาณ" ตามด้วยปัญหาและวิธีแก้สั้นๆ
84ถ้าไม่พบปัญหาสำคัญ ตอบบรรทัดเดียวว่า LGTM พร้อมเหตุผลสั้นๆ`
85  for (const model of ['haiku', await $.session.model()]) {
86    const r = await $.model.complete({ model, system, prompt: material, maxTokens: 900 }).catch(() => undefined)
87    if (r?.isAnswered) return r.text.trim()
88  }
89  return '❌ รีวิวไม่สำเร็จ (เรียกโมเดลไม่ได้)'
90}
91
92async function runReview($: EngineInterface, files: string[]): Promise<string> {
93  if (reviewing) return '⏳ กำลังรีวิวอยู่'
94  reviewing = true
95  try {
96    lastReview = await review($, files)
97    return lastReview
98  } finally {
99    reviewing = false
100  }
101}
102
103function header(files: string[]): string {
104  return `${settings.tone === 'cute' ? '🌸 รีวิวน้อยๆ' : '🧐 Code Critic'}: ${files.map(shortName).join(', ')}`
105}
106
107function log($: EngineInterface, text: string): void {
108  try {
109    $.ui.log(text)
110  } catch {}
111}
112
113const HELP = `🧐 Code Critic TH (Mod 51)
114========================================
115/critic                 รีวิวไฟล์ที่ Claude แก้ในรอบล่าสุด
116/critic file <path>     รีวิวไฟล์ที่ระบุ
117/critic fix             ส่งผลรีวิวล่าสุดให้ Claude แก้ต่อ
118/critic auto on|off     รีวิวเองทุกครั้งที่ Claude แก้ไฟล์เสร็จ (ตอนนี้: AUTO) *ใช้ token เพิ่ม
119/critic tone plain|cute โทนการรีวิว (ตอนนี้: TONE)
120/critic churn           ไฟล์ที่ถูกแก้วนบ่อยใน 1 ชั่วโมง`
121
122export const register: Register = on => {
123  on('session.start', async ($, e, next) => {
124    settings = { ...settings, ...((await $.store.get('settings')) as typeof settings | undefined) }
125    touched = new Set()
126    edits = new Map()
127    await $.command.register({ name: 'critic', description: 'ให้ AI ตัวที่สองรีวิวโค้ดที่ Claude เพิ่งแก้ (/critic help)', argumentHint: 'file <path>|fix|auto on|off|tone plain|cute|churn' })
128    return next(e)
129  })
130
131  on('turn.start', async ($, e, next) => {
132    if (!e.agentId) touched = new Set()
133    return next(e)
134  })
135
136  // ==========================================================================
137  // บทที่ 4: ดักการแก้ไฟล์ - สำเร็จแล้วค่อยนับ
138  // ==========================================================================
139  on('tool.call', async ($, e, next) => {
140    const r = await next(e)
141    if (!EDIT_TOOLS.includes(e.tool) || r.deny !== undefined || r.isError) return r
142    const file = fileOf(e as unknown as Record<string, unknown>)
143    if (!file) return r
144    touched.add(file)
145    const now = await $.clock.now()
146    edits.set(file, [...(edits.get(file) ?? []).filter(t => now - t < CHURN_WINDOW), now])
147    const n = churnCount(file, now)
148    if (n >= CHURN_LIMIT && now - (warnedAt.get(file) ?? 0) > CHURN_WINDOW) {
149      warnedAt.set(file, now)
150      log($, `${settings.tone === 'cute' ? '🥺' : '🔁'} ${shortName(file)} ถูกแก้ ${n} รอบใน 1 ชั่วโมงแล้ว อาจกำลังแก้ปลายเหตุ ลองให้รีวิวทั้งไฟล์: /critic file ${file}`)
151    }
152    return r
153  })
154
155  on('turn.complete', async ($, e, next) => {
156    const r = await next(e)
157    if (e.agentId || touched.size === 0) return r
158    lastTouched = [...touched]
159    if (settings.auto && e.reason !== 'aborted' && e.reason !== 'error') {
160      const files = lastTouched
161      // ไม่ await: ไม่ให้รอบถัดไปต้องรอรีวิว
162      void runReview($, files).then(text => log($, `${header(files)}\n${text}\n(ให้ Claude แก้ตาม: /critic fix)`))
163    }
164    return r
165  })
166
167  on('command.run', { command: 'critic' }, async ($, e) => {
168    const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
169    switch (sub) {
170      case '': {
171        const files = touched.size ? [...touched] : lastTouched
172        if (files.length === 0) return { text: 'ยังไม่มีไฟล์ที่ Claude แก้ใน session นี้ (หรือใช้ /critic file <path>)' }
173        return { text: `${header(files)}\n\n${await runReview($, files)}\n\nให้ Claude แก้ตาม: /critic fix` }
174      }
175      case 'file': {
176        const path = rest.join(' ').replace(/^["']|["']$/g, '')
177        if (!path) return { text: 'วิธีใช้: /critic file src/app.ts' }
178        if (!(await $.fs.exists(path))) return { text: `❌ ไม่พบไฟล์ ${path}` }
179        return { text: `${header([path])}\n\n${await runReview($, [path])}\n\nให้ Claude แก้ตาม: /critic fix` }
180      }
181      case 'fix':
182        if (!lastReview || lastReview.startsWith('LGTM')) return { text: lastReview ? '✅ รีวิวล่าสุดผ่านแล้ว ไม่มีอะไรต้องแก้' : 'ยังไม่มีผลรีวิว: /critic ก่อน' }
183        $.clock.after(50, () => void $.prompt.submit({ text: `ผู้รีวิวโค้ดอีกคนให้ความเห็นมาแบบนี้ ช่วยตรวจว่าแต่ละข้อจริงไหม แล้วแก้เฉพาะข้อที่ถูกต้อง (ข้อไหนไม่เห็นด้วยให้บอกเหตุผล):\n\n${lastReview}` }).catch(() => undefined)) // ส่งหลังคำสั่งตอบกลับแล้ว (ส่งระหว่าง command.run ไม่ได้)
184        return { text: '📨 ส่งผลรีวิวให้ Claude แล้ว' }
185      case 'auto':
186      case 'tone': {
187        const value = rest[0] ?? ''
188        const ok = sub === 'auto' ? ['on', 'off'].includes(value) : ['plain', 'cute'].includes(value)
189        if (!ok) return { text: sub === 'auto' ? 'วิธีใช้: /critic auto on|off' : 'วิธีใช้: /critic tone plain|cute' }
190        settings = sub === 'auto' ? { ...settings, auto: value === 'on' } : { ...settings, tone: value as 'plain' | 'cute' }
191        await $.store.set('settings', settings)
192        return { text: sub === 'auto' ? `🤖 รีวิวอัตโนมัติ: ${settings.auto ? 'เปิด (ใช้ token เพิ่มทุกครั้งที่แก้ไฟล์)' : 'ปิด'}` : `🎨 โทน: ${settings.tone === 'cute' ? 'น่ารัก 🌸' : 'ตรงไปตรงมา'}` }
193      }
194      case 'churn': {
195        const now = await $.clock.now()
196        const rows = [...edits.keys()].map(f => [f, churnCount(f, now)] as const).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1])
197        return { text: rows.length ? ['🔁 การแก้ไฟล์ใน 1 ชั่วโมงล่าสุด', ...rows.slice(0, 15).map(([f, n]) => `  ${String(n).padStart(3)}×  ${shortName(f)}${n >= CHURN_LIMIT ? '  ⚠️' : ''}`)].join('\n') : 'ยังไม่มีการแก้ไฟล์ใน 1 ชั่วโมงล่าสุด' }
198      }
199      default:
200        return { text: HELP.replace('AUTO', settings.auto ? 'เปิด' : 'ปิด').replace('TONE', settings.tone) }
201    }
202  })
203}
204
205/**
206 * ========================================================================
207 * การบ้าน / ต่อยอด
208 * ========================================================================
209 * 1. รีวิวเฉพาะไฟล์ภาษาที่สนใจ (เช่น .ts .py) ข้ามไฟล์ markdown
210 * 2. เก็บประวัติรีวิวแล้วดูว่าปัญหาแบบไหนเจอบ่อยที่สุด
211 * 3. ให้ผู้รีวิวใช้กฎของทีมจากไฟล์ .critic.md ในโปรเจกต์
212 */
213