After every turn, a separate model reviews what changed against your repo's .claude/principles.md — intent, principle violations, band-aid fixes — and shows it…

A Claude Code mod that reviews intent, not lines. Each time a turn ends, a model other than the one that wrote the code checks what that turn changed against your repository's .claude/principles.md.
<img src="docs/panel-ko.png" width="480" alt="The review panel after a turn: a suspected violation of principle P05 with its file, line and code, where a person should look, the files the turn touched, and buttons to send the findings to Claude, review again or approve the finding as an exception">
The panel after a turn, shown with language: ko.
Inspired by Anthropic's Redesigning source control (Scion · Antique) — people review intent rather than lines, and the recurring job is an agent saying "no" when a change breaks a core principle.
Cost: every turn that changes code makes one extra model call on your account (default
sonnet, medium effort; the principles file is prompt-cached). Turns that touch only comments or docs are skipped, and in a repository without a principles file nothing runs at all.
git stash create (the tree itself is untouched)..git/motive-mem/decisions.jsonl and are not raised again.It turns on only when the session folder is inside a git repository that has .claude/principles.md. Install it at user scope; it wakes up only where a principles file exists.
Requires Claude Code with mods (plugin hooks modules). Built and tested on Claude Code 2.1.299 — the mod API is early access and may change between releases.
claude plugin marketplace add wjsekrud/motive-mem
claude plugin install motive-mem@motive-mem
Then, in a repository you want reviewed, create its principles file with /motive-mem:principles.
| Command | |
|---|---|
/motive-mem | Open the panel |
/motive-mem all | Review all uncommitted changes |
/motive-mem pause · resume | Stop · restart automatic review |
/motive-mem:principles | Create or revise the principles file (skill) |
/plugin configure motive-mem@motive-mem
| Setting | Default | |
|---|---|---|
language | en | Panel, status line and review text — en or ko. Principles files can be in any language. |
model | sonnet | The review model. opus judges hard cases better and costs more. |
effort | medium | How hard the review model thinks. |
maxDiffChars | 60000 | Past this, code files go in first and the rest are listed by name only. |
openOnIssue | true | Open the panel when there is a violation, a suspect, or a change missing from the report. |
Principles are dug out, not invented. The skill finds what people already decided — in CLAUDE.md, design docs, docstrings of code and tests, reverted history — shows it as a table for you to confirm, and only then writes the file. A growing principles file is itself the drift this mode exists to stop, so adding a principle needs evidence and removing one needs only that the evidence was wrong.
## P02 Order status changes only in orders/transitions.py
- Scope: orders/**, api/handlers/**, jobs/**
- Rule: only `transitions.apply(order, event)` changes `Order.status` — the audit log and webhooks hang off it.
- Signal: `order.status =` outside `transitions.py`; loosening `test_status_only_changes_through_apply`.
- Evidence: ARCHITECTURE.md "State transitions"; the 2026-03 hotfix that refunded twice (reverted).
Criteria and procedure: skills/principles/SKILL.md. Format: skills/principles/references/format.md.
See CLAUDE.md. To run from a clone, add the folder itself as a marketplace — the install is then the folder, so edits apply after /reload-plugins:
claude plugin marketplace add /path/to/Motive-mem
claude plugin install motive-mem@motive-mem
hooks/register.tsx 436 lines1// Intent review: when a turn ends, a separate model checks what that turn changed against the repository's
2// .claude/principles.md. It gets the request, the report, the diff and the principles — never the author's
3// (Claude's) reasoning. Results go to the status line, a toast and a panel.
4// The start and end of a turn are snapshotted with `git stash create` (the working tree is never touched),
5// and only the diff between them is reviewed.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Decision, FileChange, Intent, Review } from '../types'
11import {
12 BINARY,
13 PRINCIPLES_PATH,
14 buildPrompt,
15 fixPrompt,
16 hasIssues,
17 levelOf,
18 openFindings,
19 parseDiff,
20 parsePrinciples,
21 parseVerdict,
22 principleLabel,
23 reviewMarkdown,
24 scopeHits,
25 statusLine,
26 systemPrompt,
27 untrackedChunk,
28} from './core'
29import { WORDS, langOf, type Lang } from './text'
30
31type Engine = EngineInterface
32type Repo = { top: string; gitDir: string }
33type Snap = { rev: string; untracked: string[] }
34
35const PANE = 'motive-mem'
36const KEEP = 40
37const FRESH_LIMIT = 30
38const FRESH_MAX_CHARS = 200_000
39
40const reviews = atom({ plugin: 'motive-mem', key: 'reviews' } as const, [])
41const cursor = atom({ plugin: 'motive-mem', key: 'cursor' } as const, -1)
42const isPaused = atom({ plugin: 'motive-mem', key: 'isPaused' } as const, false)
43
44// ---- git
45
46async function git($: Engine, repo: string, args: string[], timeoutMs = 30_000) {
47 return $.process.run(['git', '-c', 'core.quotepath=off', '-C', repo, ...args], { timeoutMs })
48}
49
50async function gitRepo($: Engine): Promise<Repo | undefined> {
51 const cwd = await $.session.cwd()
52 const found = await $.process
53 .run(['git', '-C', cwd, 'rev-parse', '--show-toplevel', '--absolute-git-dir'])
54 .catch(() => undefined)
55 if (found === undefined || found.exitCode !== 0) return undefined
56 const [top, gitDir] = found.stdout.trim().split(/\r?\n/)
57 return top && gitDir ? { top, gitDir } : undefined
58}
59
60// When the mode is on: a git repository that has a principles file
61async function repoOf($: Engine): Promise<Repo | undefined> {
62 const repo = await gitRepo($)
63 if (repo === undefined) return undefined
64 return (await $.fs.exists(`${repo.top}/${PRINCIPLES_PATH}`)) ? repo : undefined
65}
66
67// Why it is idle — the answer when a command is run
68async function whyOff($: Engine): Promise<string> {
69 const c = words().command
70 return (await gitRepo($)) === undefined ? c.notRepo : c.noPrinciples(PRINCIPLES_PATH)
71}
72
73async function snapshot($: Engine, repo: Repo): Promise<Snap | undefined> {
74 const stash = await git($, repo.top, ['stash', 'create'])
75 let rev = stash.exitCode === 0 ? stash.stdout.trim() : ''
76 if (rev === '') {
77 const head = await git($, repo.top, ['rev-parse', 'HEAD'])
78 rev = head.exitCode === 0 ? head.stdout.trim() : ''
79 }
80 if (rev === '') return undefined
81 const listed = await git($, repo.top, ['ls-files', '--others', '--exclude-standard', '-z'])
82 return { rev, untracked: listed.stdout.split('\0').filter(Boolean) }
83}
84
85async function changes(
86 $: Engine,
87 repo: Repo,
88 from: string,
89 to: string,
90 fresh: string[],
91): Promise<{ files: FileChange[]; diff: string }> {
92 const out = await git($, repo.top, ['diff', '--no-color', '--no-ext-diff', '-M', '-U3', from, to])
93 let diff = out.exitCode === 0 ? out.stdout : ''
94 const files = parseDiff(diff)
95 for (const path of fresh.slice(0, FRESH_LIMIT)) {
96 const text = BINARY.test(path)
97 ? undefined
98 : await $.fs.read(`${repo.top}/${path}`).catch(() => undefined)
99 if (text === undefined || text.length > FRESH_MAX_CHARS || text.includes('\u0000')) {
100 files.push({ path, status: 'untracked', added: 0, removed: 0, kind: 'binary', lines: [] })
101 continue
102 }
103 const chunk = untrackedChunk(path, text)
104 diff += chunk
105 files.push(...parseDiff(chunk).map(file => ({ ...file, status: 'untracked' as const })))
106 }
107 return { files, diff }
108}
109
110// ---- logs (.git/motive-mem — outside the work tree, so they never show up in the next turn's diff)
111
112async function readLines<T>($: Engine, path: string, limit: number): Promise<T[]> {
113 const text = await $.fs.read(path).catch(() => '')
114 return text
115 .split('\n')
116 .filter(Boolean)
117 .slice(-limit)
118 .flatMap(line => {
119 try {
120 return [JSON.parse(line) as T]
121 } catch {
122 return []
123 }
124 })
125}
126
127async function appendLine($: Engine, path: string, value: unknown, keep: number) {
128 const text = await $.fs.read(path).catch(() => '')
129 const lines = [...text.split('\n').filter(Boolean), JSON.stringify(value)].slice(-keep)
130 await $.fs.write(path, `${lines.join('\n')}\n`)
131}
132
133const reviewLog = (repo: Repo) => `${repo.gitDir}/motive-mem/reviews.jsonl`
134const decisionLog = (repo: Repo) => `${repo.gitDir}/motive-mem/decisions.jsonl`
135
136// ---- state
137
138async function put($: Engine, review: Review) {
139 await update($, reviews, list => [...list.filter(r => r.id !== review.id), review].slice(-KEEP))
140 await update($, cursor, () => -1)
141}
142
143async function replace($: Engine, review: Review) {
144 await update($, reviews, list => list.map(r => (r.id === review.id ? review : r)))
145}
146
147// ---- review (module variables start over on reload — the review list lives in $.state)
148
149type Config = {
150 model: string
151 effort: 'low' | 'medium' | 'high'
152 maxDiffChars: number
153 openOnIssue: boolean
154 lang: Lang
155}
156
157let config: Config = { model: 'sonnet', effort: 'medium', maxDiffChars: 60_000, openOnIssue: true, lang: 'en' }
158let pending: { repo: Repo; snap: Snap } | undefined
159let prompts: string[] = []
160let serial = 0
161
162function words() {
163 return WORDS[config.lang]
164}
165
166async function review(
167 $: Engine,
168 repo: Repo,
169 range: Review['range'],
170 intent: Intent,
171 origin: Review['origin'],
172) {
173 const { files, diff } = await changes($, repo, range.from, range.to, range.fresh)
174 if (files.length === 0) {
175 if (origin !== 'turn') $.ui.toast(words().command.nothing)
176 return
177 }
178 const principlesText = await $.fs.read(`${repo.top}/${PRINCIPLES_PATH}`)
179 const hits = scopeHits(
180 parsePrinciples(principlesText),
181 files.filter(f => f.kind === 'code' || f.kind === 'test').map(f => f.path),
182 )
183 const level = levelOf(files)
184 const at = await $.clock.now()
185 const draft: Review = {
186 id: `${at}-${serial++}`,
187 at,
188 origin,
189 status: level === 'trivial' ? 'skipped' : 'running',
190 level,
191 range,
192 files: files.map(({ lines: _lines, ...summary }) => summary),
193 principleDocs: files.filter(f => f.kind === 'principles').map(f => f.path),
194 hits,
195 intent,
196 model: config.model,
197 excepted: [],
198 }
199 await put($, draft)
200 $.ui.status(statusLine(draft, config.lang))
201 if (draft.principleDocs.length > 0) {
202 $.ui.toast(words().docsChangedToast(draft.principleDocs.join(', ')))
203 }
204 if (draft.status === 'skipped') {
205 await appendLine($, reviewLog(repo), draft, 500)
206 return
207 }
208
209 const decisions = await readLines<Decision>($, decisionLog(repo), 30)
210 const answer = await $.model.complete({
211 model: config.model,
212 effort: config.effort,
213 system: systemPrompt(config.lang),
214 prompt: buildPrompt({
215 principles: principlesText,
216 decisions,
217 intent,
218 files,
219 diff,
220 hits,
221 maxDiffChars: config.maxDiffChars,
222 lang: config.lang,
223 }),
224 maxTokens: 4000,
225 timeoutMs: 240_000,
226 })
227 let done: Review
228 if (!answer.isAnswered) {
229 done = { ...draft, status: 'error', error: answer.reason }
230 } else {
231 const verdict = parseVerdict(answer.text)
232 done =
233 verdict === undefined
234 ? { ...draft, status: 'error', error: words().unreadable(answer.text.slice(0, 120)) }
235 : { ...draft, status: 'done', verdict }
236 }
237 await replace($, done)
238 $.ui.status(statusLine(done, config.lang))
239 await appendLine($, reviewLog(repo), done, 500)
240 if (hasIssues(done)) {
241 const first = openFindings(done)[0]?.finding
242 $.ui.toast(
243 first ? `${principleLabel(first.principle, config.lang)}: ${first.why}` : statusLine(done, config.lang),
244 { timeoutMs: 8000 },
245 )
246 if (config.openOnIssue) void $.ui.open({ id: PANE, title: words().title })
247 }
248}
249
250function later($: Engine, work: () => Promise<void>) {
251 $.clock.after(0, () => {
252 work().catch(err => $.ui.log(`motive-mem: ${String(err)}`, { to: 'debug' }))
253 })
254}
255
256async function reviewAll($: Engine) {
257 const repo = await repoOf($)
258 if (repo === undefined) return whyOff($)
259 const head = await git($, repo.top, ['rev-parse', 'HEAD'])
260 const now = await snapshot($, repo)
261 if (head.exitCode !== 0 || now === undefined) return words().command.noHead
262 const range = { from: head.stdout.trim(), to: now.rev, fresh: now.untracked }
263 later($, () => review($, repo, range, { prompts: [...prompts], answer: '' }, 'all'))
264 return words().command.reviewingAll
265}
266
267export const register: Register = (on, options) => {
268 config = {
269 model: String(options.model ?? 'sonnet'),
270 effort: String(options.effort ?? 'medium') as Config['effort'],
271 maxDiffChars: Number(options.maxDiffChars ?? 60_000),
272 openOnIssue: options.openOnIssue !== false,
273 lang: langOf(options.language),
274 }
275
276 on('session.start', async ($, e, next) => {
277 const started = await next(e)
278 await $.command.register({ name: 'motive-mem', description: words().command.description })
279 const repo = await repoOf($)
280 if (repo !== undefined && (await read($, reviews)).length === 0) {
281 const saved = await readLines<Review>($, reviewLog(repo), KEEP)
282 if (saved.length > 0) await update($, reviews, () => saved)
283 }
284 const last = (await read($, reviews)).at(-1)
285 if (repo !== undefined) {
286 $.ui.status(last ? statusLine(last, config.lang) : `${words().title} · ${words().status.on}`)
287 }
288 return started
289 })
290
291 on('turn.start', async ($, e, next) => {
292 if (!(await read($, isPaused))) {
293 const repo = await repoOf($)
294 if (repo === undefined) {
295 pending = undefined
296 } else {
297 if (e.text.trim() !== '') prompts = [...prompts, e.text.trim()].slice(-3)
298 // Keep the start of a stopped (aborted) turn — the next turn's review covers both
299 if (pending === undefined || pending.repo.top !== repo.top) {
300 const snap = await snapshot($, repo).catch(() => undefined)
301 pending = snap === undefined ? undefined : { repo, snap }
302 }
303 }
304 }
305 return next(e)
306 })
307
308 on('turn.complete', async ($, e, next) => {
309 const done = await next(e)
310 if (e.agentId !== undefined || e.isAborted || pending === undefined) return done
311 const { repo, snap } = pending
312 pending = undefined
313 const intent: Intent = { prompts: [...prompts], answer: e.answer }
314 later($, async () => {
315 const end = await snapshot($, repo)
316 if (end === undefined) return
317 const before = new Set(snap.untracked)
318 const fresh = end.untracked.filter(path => !before.has(path))
319 if (end.rev === snap.rev && fresh.length === 0) return
320 await review($, repo, { from: snap.rev, to: end.rev, fresh }, intent, 'turn')
321 })
322 return done
323 })
324
325 on('command.run', { command: 'motive-mem' }, async ($, e) => {
326 const w = words()
327 const arg = e.args.trim()
328 if (arg === 'pause') {
329 await update($, isPaused, () => true)
330 pending = undefined
331 $.ui.status(`${w.title} · ${w.status.paused}`)
332 return { text: w.command.paused }
333 }
334 if (arg === 'resume') {
335 await update($, isPaused, () => false)
336 $.ui.status(`${w.title} · ${w.status.on}`)
337 return { text: w.command.resumed }
338 }
339 if (arg === 'all') return { text: await reviewAll($) }
340 if ((await repoOf($)) === undefined) return { text: await whyOff($) }
341 await $.ui.open({ id: PANE, title: w.title })
342 return { text: w.command.opened }
343 })
344
345 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
346 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
347 const w = words()
348 const p = w.panel
349 const list = await read($, reviews)
350 const paused = await read($, isPaused)
351 if (list.length === 0) {
352 return (
353 <Box flexDirection="column">
354 <Text dimColor>{p.empty}</Text>
355 {paused && <Text color="warning">{p.pausedBanner}</Text>}
356 <Button key="all" label={p.reviewAll} hotkey="a" onPress={() => void reviewAll($)} />
357 </Box>
358 )
359 }
360 const at = await read($, cursor)
361 const index = at < 0 || at >= list.length ? list.length - 1 : at
362 const shown = list[index]
363 if (shown === undefined) return <Text dimColor>{p.notFound}</Text>
364 const open = openFindings(shown)
365 const move = (step: number) =>
366 update($, cursor, () => Math.min(list.length - 1, Math.max(0, index + step)))
367
368 return (
369 <Box flexDirection="column" gap={1}>
370 {paused && <Text color="warning">{p.pausedBanner}</Text>}
371 <Markdown text={reviewMarkdown(shown, index, list.length, config.lang)} />
372 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
373 <Button key="prev" label={p.prev} hotkey="p" dimColor={index === 0} onPress={() => move(-1)} />
374 <Button
375 key="next"
376 label={p.next}
377 hotkey="n"
378 dimColor={index === list.length - 1}
379 onPress={() => move(1)}
380 />
381 {hasIssues(shown) && (
382 <Button
383 key="send"
384 label={p.send}
385 hotkey="c"
386 variant="primary"
387 onPress={() => void $.prompt.fill({ text: fixPrompt(shown, config.lang) })}
388 />
389 )}
390 {shown.status !== 'running' && shown.level !== 'trivial' && (
391 <Button
392 key="retry"
393 label={p.retry}
394 hotkey="r"
395 onPress={() => {
396 later($, async () => {
397 const repo = await repoOf($)
398 if (repo) await review($, repo, shown.range, shown.intent, 'retry')
399 })
400 }}
401 />
402 )}
403 <Button key="all" label={p.uncommitted} hotkey="a" onPress={() => void reviewAll($)} />
404 </Box>
405 {open.length > 0 && (
406 <Box flexDirection="column">
407 <Text dimColor>{p.exceptHint}</Text>
408 {open.slice(0, 9).map(({ finding, index: i }) => (
409 <Button
410 key={`except:${shown.id}:${i}`}
411 label={p.except(i + 1, principleLabel(finding.principle, config.lang), finding.file.split('/').pop() ?? '')}
412 hotkey={String(i + 1)}
413 dimColor
414 onPress={async () => {
415 const repo = await repoOf($)
416 if (repo === undefined) return
417 const decision: Decision = {
418 at: await $.clock.now(),
419 principle: finding.principle,
420 file: finding.file,
421 why: finding.why,
422 }
423 await appendLine($, decisionLog(repo), decision, 200)
424 const updated = { ...shown, excepted: [...shown.excepted, i] }
425 await replace($, updated)
426 $.ui.status(statusLine(updated, config.lang))
427 }}
428 />
429 ))}
430 </Box>
431 )}
432 </Box>
433 )
434 })
435}
436hooks/core.ts 496 lines1// The pure part of intent review: reading the principles doc, splitting the diff, building the review
2// request, reading the answer, and the panel text. No `$` here, so `claude plugin test` checks it as is.
3
4import type {
5 Decision,
6 FileChange,
7 FileKind,
8 Finding,
9 Intent,
10 Level,
11 Principle,
12 Review,
13 Verdict,
14} from '../types'
15import { WORDS, type Lang } from './text'
16
17export const PRINCIPLES_PATH = '.claude/principles.md'
18
19const HASH_COMMENT = /\.(py|ps1|psm1|sh|toml|ya?ml|cfg|ini|spec)$/i
20const SLASH_COMMENT = /\.(m?[jt]sx?|c[jt]s|css|scss|c|h|cpp|hpp|cs|java|go|rs|swift|kt)$/i
21const DOCS = /\.(md|txt|rst)$/i
22export const BINARY =
23 /\.(png|jpe?g|gif|webp|bmp|ico|pdf|ttf|otf|woff2?|pbf|mvt|zip|7z|gz|exe|dll|pyd|so|sqlite|db|bin|npy|npz)$/i
24const TEST = /(^|\/)(tests?\/|test_[^/]*$|[^/]*_test\.[^/]+$|[^/]*\.test\.[^/]+$)/i
25
26export function isPrincipleDoc(path: string): boolean {
27 return path === PRINCIPLES_PATH || path === 'CLAUDE.md' || path.endsWith('/CLAUDE.md') || path === 'AGENTS.md'
28}
29
30// ---- principles doc
31
32// The scope field, in either language the skill writes: `- Scope:` or `- 범위:`
33const SCOPE = /^\s*[-*]\s*(?:scope|범위)\s*:\s*(.+)$/i
34
35export function parsePrinciples(text: string): Principle[] {
36 const out: Principle[] = []
37 let current: Principle | undefined
38 for (const line of text.split(/\r?\n/)) {
39 const head = /^##\s+([A-Z]\d{1,3})\s+(.+?)\s*$/.exec(line)
40 if (head) {
41 current = { id: head[1] ?? '', title: head[2] ?? '', scope: [] }
42 out.push(current)
43 continue
44 }
45 if (/^#{1,2}\s/.test(line)) {
46 current = undefined
47 continue
48 }
49 const scope = SCOPE.exec(line)
50 if (scope && current) {
51 current.scope.push(
52 ...(scope[1] ?? '')
53 .split(',')
54 .map(part => part.replace(/`/g, '').trim())
55 .filter(Boolean),
56 )
57 }
58 }
59 return out
60}
61
62// Sections for people to settle among themselves — kept from the reviewer so an unanswered question
63// never becomes a criterion
64const NOT_FOR_REVIEWER = /^##\s+(open questions|needs confirmation|열린 질문|확인 필요)/i
65
66export function forReviewer(text: string): string {
67 const out: string[] = []
68 let skipping = false
69 for (const line of text.split(/\r?\n/)) {
70 if (/^#{1,2}\s/.test(line)) skipping = NOT_FOR_REVIEWER.test(line)
71 if (!skipping) out.push(line)
72 }
73 return out.join('\n')
74}
75
76export function globToRegExp(glob: string): RegExp {
77 let source = ''
78 for (let i = 0; i < glob.length; i++) {
79 const c = glob.charAt(i)
80 if (c === '*' && glob[i + 1] === '*') {
81 source += '.*'
82 i += glob[i + 2] === '/' ? 2 : 1
83 } else if (c === '*') {
84 source += '[^/]*'
85 } else if (c === '?') {
86 source += '[^/]'
87 } else {
88 source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
89 }
90 }
91 return new RegExp(`^${source}$`)
92}
93
94export function scopeHits(principles: Principle[], paths: string[]): Record<string, string[]> {
95 const hits: Record<string, string[]> = {}
96 for (const principle of principles) {
97 const patterns = principle.scope.map(globToRegExp)
98 const matched = paths.filter(path => patterns.some(re => re.test(path)))
99 if (matched.length > 0) hits[principle.id] = matched
100 }
101 return hits
102}
103
104// ---- diff
105
106function headerPath(raw: string, prefix: string): string {
107 let path = raw.replace(/\t$/, '')
108 if (path === '/dev/null') return ''
109 if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1)
110 return path.startsWith(prefix) ? path.slice(prefix.length) : path
111}
112
113export function kindOf(path: string, changed: string[], isBinary: boolean): FileKind {
114 if (isPrincipleDoc(path)) return 'principles'
115 if (isBinary || BINARY.test(path)) return 'binary'
116 if (DOCS.test(path)) return 'docs'
117 if (TEST.test(path)) return 'test'
118 const markers = HASH_COMMENT.test(path)
119 ? ['#']
120 : SLASH_COMMENT.test(path)
121 ? ['//', '/*', '*']
122 : undefined
123 if (markers) {
124 const body = changed.map(line => line.slice(1).trim()).filter(line => line !== '')
125 if (body.every(line => markers.some(marker => line.startsWith(marker)))) return 'comment'
126 }
127 return 'code'
128}
129
130export function parseDiff(diff: string): FileChange[] {
131 const out: FileChange[] = []
132 for (const chunk of diff.split(/^diff --git /m).slice(1)) {
133 const lines = chunk.split('\n')
134 const pair = /^a\/(.*) b\/(.*)$/.exec(lines[0] ?? '')
135 let from = pair?.[1] ?? ''
136 let to = pair?.[2] ?? ''
137 let status: FileChange['status'] = 'modified'
138 let isBinary = false
139 let isInHunk = false
140 let added = 0
141 let removed = 0
142 const changed: string[] = []
143 for (const line of lines.slice(1)) {
144 if (!isInHunk) {
145 if (line.startsWith('--- ')) from = headerPath(line.slice(4), 'a/')
146 else if (line.startsWith('+++ ')) to = headerPath(line.slice(4), 'b/')
147 else if (line.startsWith('new file mode')) status = 'added'
148 else if (line.startsWith('deleted file mode')) status = 'deleted'
149 else if (line.startsWith('rename from ')) {
150 status = 'renamed'
151 from = line.slice('rename from '.length)
152 } else if (line.startsWith('rename to ')) to = line.slice('rename to '.length)
153 else if (line.startsWith('Binary files ')) isBinary = true
154 else if (line.startsWith('@@')) isInHunk = true
155 continue
156 }
157 if (line.startsWith('@@')) continue
158 if (line.startsWith('+')) {
159 added++
160 changed.push(line)
161 } else if (line.startsWith('-')) {
162 removed++
163 changed.push(line)
164 }
165 }
166 const path = status === 'deleted' ? from : to || from
167 if (path === '') continue
168 out.push({ path, status, added, removed, kind: kindOf(path, changed, isBinary), lines: changed })
169 }
170 return out
171}
172
173export function untrackedChunk(path: string, text: string): string {
174 const body = text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n')
175 return [
176 `diff --git a/${path} b/${path}`,
177 'new file mode 100644',
178 '--- /dev/null',
179 `+++ b/${path}`,
180 `@@ -0,0 +1,${body.length} @@`,
181 ...body.map(line => `+${line}`),
182 '',
183 ].join('\n')
184}
185
186export function levelOf(files: { kind: FileKind }[]): Level {
187 if (files.some(file => file.kind === 'code')) return 'logic'
188 if (files.some(file => file.kind === 'test')) return 'tests'
189 return 'trivial'
190}
191
192// ---- review request (in English whatever the language; only the answer's sentences follow `lang`)
193
194const ANSWER_LANGUAGE: Record<Lang, string> = { en: 'English', ko: 'Korean' }
195
196// The `principle` a band-aid gets when no principle covers it. Older logs hold the Korean '땜질'.
197export const WORKAROUND = 'workaround'
198
199export function systemPrompt(lang: Lang): string {
200 return `You are this repository's "intent reviewer". This is not a line-by-line code review. You do not decide in the human's place — you narrow down where the human should look.
201
202Judge only three things.
2031. Intent match — does the diff match <request> and <author_report>? Of the changes the report does not mention, put in "unstated" only those outside the request's scope or that change other behavior — not details inside the requested change (choice of values, aliases, wording, names). Any entry in "unstated" opens a panel in front of the human — if you would add "not a big deal, but", leave it out. Check whether the report claims more than happened (passing checks presented as user confirmation, partial presented as done).
2042. Principle violations — only the principles written in <principles> count. Do not invent criteria the principles document does not state. Anything covered by the allowed exceptions in <principles> or by <approved_exceptions> is not a violation. A principle's scope is a hint of where it usually bites, not a boundary: a change outside it that does what the principle forbids still breaks it.
2053. Band-aid fixes — signs of routing around the structure to make a local problem go away: a write or sync that skips the single entry point, a second copy of a rule, formula or table that already lives in one place, a branch that fixes only one specific case, swallowing an exception to hide a symptom, changing test expectations or snapshots to make them pass, skipping checks (skip, xfail), lengthening waits, a flag for one particular task.
206
207What you do not do:
208- Style, naming or performance taste; proposals for a better design; suggestions to add defensive code or validation.
209- Hypothetical concerns ("this might … later"). Say only what the diff gives grounds for.
210- When unsure, lower "violation" to "suspect". If you cannot point to the diff line it rests on, do not say it.
211- When nothing is wrong, leave findings and unstated empty. An empty result is normal.
212- "attention" picks, from findings and unstated, only the places a human must open themselves. If both are empty, leave attention empty too.
213
214Answer with a single JSON object only (no code fence):
215{
216 "summary": "one sentence on what this change actually did",
217 "intent": "match" | "partial" | "mismatch",
218 "intent_note": "one or two sentences on how it differs from the intent and the report (empty string if it matches)",
219 "findings": [
220 {"principle": "P03", "severity": "violation" | "suspect", "file": "path", "line": 123, "why": "one sentence: which principle it breaks and how", "evidence": "the diff line it rests on, verbatim, short"}
221 ],
222 "unstated": ["a change not in the report — the file and a one-line description"],
223 "attention": ["where the human must look — file:line and why (at most 3)"]
224}
225"line" is the line number in the file after the change (the + side of @@); omit it if unknown. "principle" is the ID of the principle the finding breaks — use it whenever one applies, band-aids included; write "${WORKAROUND}" only for a band-aid that no principle covers. ${answerIn(lang)} Quote evidence as it appears in the diff.`
226}
227
228function answerIn(lang: Lang): string {
229 return `Write every sentence of the answer in ${ANSWER_LANGUAGE[lang]}, whatever language the request, report and principles are in.`
230}
231
232const KIND_ORDER: Record<FileKind, number> = {
233 code: 0,
234 test: 1,
235 principles: 2,
236 comment: 3,
237 docs: 4,
238 binary: 5,
239}
240
241const KIND_NAME: Record<FileKind, string> = {
242 code: 'code',
243 test: 'test',
244 principles: 'principles doc',
245 comment: 'comments only',
246 docs: 'docs',
247 binary: 'binary',
248}
249
250function clip(text: string, max: number): string {
251 return text.length <= max ? text : `${text.slice(0, max)}\n…(${text.length - max} chars cut)`
252}
253
254function chunksByPath(diff: string): Map<string, string> {
255 const map = new Map<string, string>()
256 for (const chunk of diff.split(/^(?=diff --git )/m)) {
257 const file = parseDiff(chunk)[0]
258 if (file) map.set(file.path, chunk)
259 }
260 return map
261}
262
263export function budgetDiff(files: FileChange[], diff: string, maxChars: number): string {
264 const chunks = chunksByPath(diff)
265 const order = [...files].sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind])
266 const kept: string[] = []
267 const left: string[] = []
268 let room = maxChars
269 for (const file of order) {
270 const chunk = chunks.get(file.path)
271 if (chunk === undefined || file.kind === 'binary') continue
272 if (chunk.length <= room) {
273 kept.push(chunk)
274 room -= chunk.length
275 } else if (kept.length === 0) {
276 kept.push(clip(chunk, room))
277 room = 0
278 } else {
279 left.push(`${file.path} (+${file.added} -${file.removed})`)
280 }
281 }
282 const tail = left.length > 0 ? `\n(diff left out for length: ${left.join(', ')})` : ''
283 return kept.join('') + tail
284}
285
286export type ReviewInput = {
287 principles: string
288 decisions: Decision[]
289 intent: Intent
290 files: FileChange[]
291 diff: string
292 hits: Record<string, string[]>
293 maxDiffChars: number
294 lang: Lang
295}
296
297export function buildPrompt(input: ReviewInput): { text: string; cache?: true }[] {
298 const prompts = input.intent.prompts.length
299 ? input.intent.prompts.map((p, i) => `${i + 1}. ${clip(p, 2000)}`).join('\n')
300 : '(none)'
301 const decisions = input.decisions.length
302 ? input.decisions.map(d => `- ${d.principle} ${d.file}: ${d.why}`).join('\n')
303 : '(none)'
304 const byFile = new Map<string, string[]>()
305 for (const [id, paths] of Object.entries(input.hits)) {
306 for (const path of paths) byFile.set(path, [...(byFile.get(path) ?? []), id])
307 }
308 const files = input.files
309 .map(f => {
310 const ids = byFile.get(f.path)
311 const scope = ids ? ` [in scope of: ${ids.join(', ')}]` : ''
312 return `- ${f.path} (${KIND_NAME[f.kind]}, ${f.status}, +${f.added} -${f.removed})${scope}`
313 })
314 .join('\n')
315 const rest = [
316 `<request> (oldest first; the last one is this task)\n${prompts}\n</request>`,
317 `<author_report> (what Claude told the human when it finished)\n${clip(input.intent.answer, 6000) || '(none — reviewing all uncommitted changes)'}\n</author_report>`,
318 `<approved_exceptions> (the human already approved these as exceptions — do not raise the same thing again)\n${decisions}\n</approved_exceptions>`,
319 `<changed_files>\n${files}\n</changed_files>`,
320 `<diff>\n${budgetDiff(input.files, input.diff, input.maxDiffChars)}\n</diff>`,
321 answerIn(input.lang),
322 ].join('\n\n')
323 return [
324 { text: `<principles>\n${forReviewer(input.principles)}\n</principles>`, cache: true },
325 { text: rest },
326 ]
327}
328
329// ---- reading the answer
330
331export function parseVerdict(text: string): Verdict | undefined {
332 const start = text.indexOf('{')
333 const end = text.lastIndexOf('}')
334 if (start < 0 || end <= start) return undefined
335 let raw: unknown
336 try {
337 raw = JSON.parse(text.slice(start, end + 1))
338 } catch {
339 return undefined
340 }
341 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
342 const o = raw as Record<string, unknown>
343 const str = (v: unknown) => (typeof v === 'string' ? v : '')
344 const strs = (v: unknown) =>
345 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
346 const findings: Finding[] = Array.isArray(o.findings)
347 ? o.findings.flatMap(item => {
348 if (typeof item !== 'object' || item === null) return []
349 const f = item as Record<string, unknown>
350 const why = str(f.why)
351 if (why === '') return []
352 const finding: Finding = {
353 principle: str(f.principle) || WORKAROUND,
354 severity: f.severity === 'violation' ? 'violation' : 'suspect',
355 file: str(f.file),
356 why,
357 evidence: str(f.evidence),
358 }
359 if (typeof f.line === 'number' && Number.isFinite(f.line)) finding.line = Math.round(f.line)
360 return [finding]
361 })
362 : []
363 return {
364 summary: str(o.summary),
365 intent: o.intent === 'partial' || o.intent === 'mismatch' ? o.intent : 'match',
366 intentNote: str(o.intent_note),
367 findings,
368 unstated: strs(o.unstated),
369 attention: strs(o.attention).slice(0, 3),
370 }
371}
372
373// ---- panel, status line, and the text handed back to Claude
374
375export function openFindings(review: Review): { finding: Finding; index: number }[] {
376 return (review.verdict?.findings ?? [])
377 .map((finding, index) => ({ finding, index }))
378 .filter(({ index }) => !review.excepted.includes(index))
379}
380
381export function hasIssues(review: Review): boolean {
382 const v = review.verdict
383 return v !== undefined && (openFindings(review).length > 0 || v.unstated.length > 0 || v.intent !== 'match')
384}
385
386export function principleLabel(id: string, lang: Lang): string {
387 return id === WORKAROUND || id === '땜질' ? WORDS[lang].workaround : id
388}
389
390function tag(finding: Finding, lang: Lang): string {
391 return `[${principleLabel(finding.principle, lang)} ${WORDS[lang].severity[finding.severity]}]`
392}
393
394export function statusLine(review: Review, lang: Lang): string {
395 const w = WORDS[lang]
396 const s = w.status
397 if (review.status === 'running') return `${w.title} · ${s.running}`
398 if (review.status === 'error') return `${w.title} · ${s.failed(review.error ?? '')}`.slice(0, 120)
399 if (review.status === 'skipped') {
400 return review.principleDocs.length > 0
401 ? `${w.title} · ${s.docsChanged(review.principleDocs.join(', '))}`
402 : `${w.title} · ${s.trivial}`
403 }
404 const v = review.verdict
405 if (v === undefined) return w.title
406 const open = openFindings(review)
407 const violations = open.filter(x => x.finding.severity === 'violation').length
408 const suspects = open.length - violations
409 if (!hasIssues(review)) return `${w.title} ✓ ${w.intent[v.intent]} · ${s.noFindings}`
410 const parts: string[] = [w.intent[v.intent]]
411 if (violations > 0) parts.push(s.violations(violations))
412 if (suspects > 0) parts.push(s.suspects(suspects))
413 if (v.unstated.length > 0) parts.push(s.unstated(v.unstated.length))
414 return `${w.title} ⚠ ${parts.join(' · ')} — /motive-mem`
415}
416
417function clock(at: number): string {
418 const d = new Date(at)
419 const two = (n: number) => String(n).padStart(2, '0')
420 return `${two(d.getHours())}:${two(d.getMinutes())}`
421}
422
423function where(f: Finding): string {
424 return f.line === undefined ? f.file : `${f.file}:${f.line}`
425}
426
427export function reviewMarkdown(review: Review, index: number, count: number, lang: Lang): string {
428 const w = WORDS[lang]
429 const p = w.panel
430 const out: string[] = []
431 out.push(
432 `**${p.header(w.origin[review.origin], index + 1, count)}** · ${clock(review.at)} · ${w.level[review.level]} · ${review.model}`,
433 )
434 out.push('')
435 if (review.principleDocs.length > 0) {
436 out.push(`> ${p.docsChanged(review.principleDocs.join(', '))}`)
437 out.push('')
438 }
439 if (review.status === 'running') out.push(`_${p.running}_`)
440 if (review.status === 'error') out.push(p.failed(review.error ?? p.unknown))
441 if (review.status === 'skipped') out.push(p.skipped)
442 const v = review.verdict
443 if (v !== undefined) {
444 out.push(`**${p.did}** ${v.summary || p.noSummary}`)
445 out.push('')
446 out.push(`**${w.intent[v.intent]}**${v.intentNote ? ` — ${v.intentNote}` : ''}`)
447 const open = openFindings(review)
448 if (open.length > 0) {
449 out.push('')
450 out.push(`**${p.findings}**`)
451 for (const { finding, index: i } of open) {
452 out.push(`${i + 1}. ${tag(finding, lang)} \`${where(finding)}\` — ${finding.why}`)
453 if (finding.evidence) out.push(` \`${finding.evidence.slice(0, 160)}\``)
454 }
455 }
456 if (review.excepted.length > 0) out.push(`\n_${p.excepted(review.excepted.map(i => i + 1))}_`)
457 if (v.unstated.length > 0) {
458 out.push('')
459 out.push(`**${p.unstated}**`)
460 for (const line of v.unstated) out.push(`- ${line}`)
461 }
462 if (v.attention.length > 0) {
463 out.push('')
464 out.push(`**${p.attention}**`)
465 for (const line of v.attention) out.push(`- ${line}`)
466 }
467 if (!hasIssues(review)) {
468 out.push('')
469 out.push(p.noFindings)
470 }
471 }
472 const shown = review.files.filter(f => f.kind !== 'binary').slice(0, 12)
473 if (shown.length > 0) {
474 out.push('')
475 out.push(
476 `**${p.files}** ${shown.map(f => `${f.path.split('/').pop()}(${w.kind[f.kind]})`).join(', ')}${review.files.length > shown.length ? p.more(review.files.length - shown.length) : ''}`,
477 )
478 }
479 const hit = Object.keys(review.hits)
480 if (hit.length > 0) out.push(`**${p.inScope}** ${hit.join(', ')}`)
481 return out.join('\n')
482}
483
484export function fixPrompt(review: Review, lang: Lang): string {
485 const v = review.verdict
486 if (v === undefined) return ''
487 const w = WORDS[lang]
488 const lines = [...w.fix.intro, '']
489 for (const { finding, index } of openFindings(review)) {
490 lines.push(`${index + 1}. ${tag(finding, lang)} ${where(finding)} — ${finding.why}`)
491 }
492 if (v.intent !== 'match' && v.intentNote) lines.push(`- ${w.fix.intent}: ${v.intentNote}`)
493 for (const line of v.unstated) lines.push(`- ${w.fix.unstated}: ${line}`)
494 return lines.join('\n')
495}
496hooks/text.ts 238 lines1// What the plugin shows a person, in each language it speaks — chosen by the `language` option.
2// The reviewer's prompt is not here: it lives in core.ts, in English, and only its output language follows this.
3
4import type { FileKind, Level, Review, Verdict } from '../types'
5
6export type Lang = 'en' | 'ko'
7
8export function langOf(value: unknown): Lang {
9 return value === 'ko' ? 'ko' : 'en'
10}
11
12export type Words = {
13 title: string
14 intent: Record<Verdict['intent'], string>
15 level: Record<Level, string>
16 origin: Record<Review['origin'], string>
17 kind: Record<FileKind, string>
18 severity: Record<'violation' | 'suspect', string>
19 workaround: string
20 status: {
21 on: string
22 paused: string
23 running: string
24 failed: (error: string) => string
25 docsChanged: (docs: string) => string
26 trivial: string
27 noFindings: string
28 violations: (n: number) => string
29 suspects: (n: number) => string
30 unstated: (n: number) => string
31 }
32 panel: {
33 header: (origin: string, nth: number, count: number) => string
34 docsChanged: (docs: string) => string
35 running: string
36 failed: (error: string) => string
37 unknown: string
38 skipped: string
39 did: string
40 noSummary: string
41 findings: string
42 excepted: (numbers: number[]) => string
43 unstated: string
44 attention: string
45 noFindings: string
46 files: string
47 more: (n: number) => string
48 inScope: string
49 empty: string
50 pausedBanner: string
51 notFound: string
52 prev: string
53 next: string
54 send: string
55 retry: string
56 reviewAll: string
57 uncommitted: string
58 exceptHint: string
59 except: (nth: number, principle: string, file: string) => string
60 }
61 fix: { intro: string[]; intent: string; unstated: string }
62 command: {
63 description: string
64 notRepo: string
65 noPrinciples: (path: string) => string
66 nothing: string
67 noHead: string
68 reviewingAll: string
69 paused: string
70 resumed: string
71 opened: string
72 }
73 docsChangedToast: (docs: string) => string
74 unreadable: (text: string) => string
75}
76
77const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
78
79const en: Words = {
80 title: 'Intent review',
81 intent: { match: 'matches intent', partial: 'partly off intent', mismatch: 'off intent' },
82 level: { logic: 'logic change', tests: 'tests only', trivial: 'trivial change' },
83 origin: { turn: 'Turn', all: 'Uncommitted', retry: 'Re-run' },
84 kind: {
85 code: 'code',
86 test: 'test',
87 principles: 'principles doc',
88 comment: 'comments only',
89 docs: 'docs',
90 binary: 'binary',
91 },
92 severity: { violation: 'violation', suspect: 'suspect' },
93 workaround: 'workaround',
94 status: {
95 on: 'on',
96 paused: 'paused',
97 running: 'reviewing…',
98 failed: error => `failed: ${error}`,
99 docsChanged: docs => `principle docs changed (${docs}) — check them`,
100 trivial: 'trivial change only (comments, docs)',
101 noFindings: 'no findings',
102 violations: n => plural(n, 'violation'),
103 suspects: n => plural(n, 'suspect'),
104 unstated: n => `${n} not in report`,
105 },
106 panel: {
107 header: (origin, nth, count) => `${origin} review ${nth}/${count}`,
108 docsChanged: docs => `Principle docs changed: ${docs} — this changes the rules, check it yourself`,
109 running: 'Reviewing…',
110 failed: error => `Review failed: ${error}`,
111 unknown: 'unknown',
112 skipped: 'Only comments, docs or binaries changed — skipped the model review.',
113 did: 'What it did',
114 noSummary: '(no summary)',
115 findings: 'Findings',
116 excepted: numbers => `Approved as exceptions: ${numbers.map(n => `#${n}`).join(', ')}`,
117 unstated: 'Changed but not in the report',
118 attention: 'Where to look',
119 noFindings: 'No findings.',
120 files: 'Files',
121 more: n => ` and ${n} more`,
122 inScope: 'In scope of',
123 empty: 'No reviews yet. They show up here when a turn that changed code ends.',
124 pausedBanner: 'Automatic review paused — /motive-mem resume',
125 notFound: 'Review not found.',
126 prev: '◀ Prev',
127 next: 'Next ▶',
128 send: 'Send to Claude',
129 retry: 'Review again',
130 reviewAll: 'Review all uncommitted',
131 uncommitted: 'All uncommitted',
132 exceptHint: "Wrong finding? Approve it as an exception — later reviews won't raise the same thing again.",
133 except: (nth, principle, file) => `Approve #${nth} as exception (${principle} ${file})`,
134 },
135 fix: {
136 intro: [
137 'The intent review flagged the following in the last change. Fix it so the principles (.claude/principles.md) hold.',
138 "If you think a finding is wrong, don't change the code — say why it is wrong.",
139 ],
140 intent: 'Intent',
141 unstated: 'Changed but not in the report',
142 },
143 command: {
144 description: 'Open the intent review panel · all: review all uncommitted changes · pause / resume: stop or restart automatic review',
145 notRepo: 'Intent review is idle: this is not a git repository.',
146 noPrinciples: path => `Intent review is idle: no ${path}. To create one, run /motive-mem:principles`,
147 nothing: 'Nothing to review.',
148 noHead: 'Could not read HEAD.',
149 reviewingAll: 'Reviewing all uncommitted changes. The result shows in the status line and the panel.',
150 paused: 'Automatic review paused. To turn it back on: /motive-mem resume',
151 resumed: 'Automatic review is back on, from the next turn.',
152 opened: 'Opened the intent review panel.',
153 },
154 docsChangedToast: docs => `Principle docs changed: ${docs}`,
155 unreadable: text => `could not read the answer: ${text}`,
156}
157
158const ko: Words = {
159 title: '의도 리뷰',
160 intent: { match: '의도 일치', partial: '의도 일부 어긋남', mismatch: '의도와 다름' },
161 level: { logic: '로직 변경', tests: '테스트만', trivial: '사소한 변경' },
162 origin: { turn: '턴', all: '미커밋 전체', retry: '다시 검토' },
163 kind: {
164 code: '코드',
165 test: '테스트',
166 principles: '원칙 문서',
167 comment: '주석만',
168 docs: '문서',
169 binary: '바이너리',
170 },
171 severity: { violation: '위반', suspect: '의심' },
172 workaround: '땜질',
173 status: {
174 on: '켜짐',
175 paused: '멈춤',
176 running: '검토 중…',
177 failed: error => `실패: ${error}`,
178 docsChanged: docs => `원칙 문서가 바뀜(${docs}) — 확인 필요`,
179 trivial: '사소한 변경만(주석·문서)',
180 noFindings: '지적 없음',
181 violations: n => `위반 ${n}`,
182 suspects: n => `의심 ${n}`,
183 unstated: n => `보고 밖 ${n}`,
184 },
185 panel: {
186 header: (origin, nth, count) => `${origin} 리뷰 ${nth}/${count}`,
187 docsChanged: docs => `원칙 문서가 바뀌었다: ${docs} — 규칙이 바뀌는 변경이니 직접 확인할 것`,
188 running: '검토 중…',
189 failed: error => `리뷰 실패: ${error}`,
190 unknown: '알 수 없음',
191 skipped: '주석·문서·바이너리만 바뀌어 모델 리뷰를 건너뛰었다.',
192 did: '한 일',
193 noSummary: '(요약 없음)',
194 findings: '지적',
195 excepted: numbers => `예외로 승인함: ${numbers.join(', ')}번`,
196 unstated: '보고에 없던 변경',
197 attention: '사람이 볼 곳',
198 noFindings: '지적 없음.',
199 files: '파일',
200 more: n => ` 외 ${n}`,
201 inScope: '원칙 범위',
202 empty: '아직 리뷰가 없습니다. 코드를 바꾼 턴이 끝나면 여기에 나옵니다.',
203 pausedBanner: '자동 리뷰 멈춤 — /motive-mem resume',
204 notFound: '리뷰를 찾지 못했습니다.',
205 prev: '◀ 이전',
206 next: '다음 ▶',
207 send: 'Claude에게 전달',
208 retry: '다시 검토',
209 reviewAll: '미커밋 전체 검토',
210 uncommitted: '미커밋 전체',
211 exceptHint: '틀린 지적이면 예외로 승인 — 다음 리뷰부터 같은 건을 다시 짚지 않습니다.',
212 except: (nth, principle, file) => `${nth}번 예외 승인 (${principle} ${file})`,
213 },
214 fix: {
215 intro: [
216 '의도 리뷰가 방금 변경에서 아래를 짚었다. 원칙(.claude/principles.md)을 지키는 방향으로 고쳐 줘.',
217 '지적이 틀렸다고 보면 고치지 말고 왜 틀렸는지 말해 줘.',
218 ],
219 intent: '의도',
220 unstated: '보고에 없던 변경',
221 },
222 command: {
223 description: '의도 리뷰 패널 열기 · all: 미커밋 전체 검토 · pause / resume: 자동 리뷰 멈춤·재개',
224 notRepo: 'git 저장소가 아니라 의도 리뷰가 쉬고 있습니다.',
225 noPrinciples: path => `${path} 가 없어 의도 리뷰가 쉬고 있습니다. 원칙 파일을 만들려면 /motive-mem:principles`,
226 nothing: '검토할 변경이 없습니다.',
227 noHead: 'HEAD 를 읽지 못했습니다.',
228 reviewingAll: '미커밋 변경 전체를 검토합니다. 끝나면 상태 줄과 패널에 나옵니다.',
229 paused: '자동 리뷰를 멈췄습니다. 다시 켜려면 /motive-mem resume',
230 resumed: '자동 리뷰를 다시 켰습니다. 다음 턴부터 봅니다.',
231 opened: '의도 리뷰 패널을 열었습니다.',
232 },
233 docsChangedToast: docs => `원칙 문서가 바뀌었습니다: ${docs}`,
234 unreadable: text => `답을 읽지 못함: ${text}`,
235}
236
237export const WORDS: Record<Lang, Words> = { en, ko }
238types/index.d.ts 63 lines1export type FileKind = 'code' | 'test' | 'comment' | 'docs' | 'binary' | 'principles'
2
3export type Level = 'logic' | 'tests' | 'trivial'
4
5export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
6
7export type FileSummary = {
8 path: string
9 status: FileStatus
10 added: number
11 removed: number
12 kind: FileKind
13}
14
15export type FileChange = FileSummary & { lines: string[] }
16
17export type Principle = { id: string; title: string; scope: string[] }
18
19export type Finding = {
20 principle: string
21 severity: 'violation' | 'suspect'
22 file: string
23 line?: number
24 why: string
25 evidence: string
26}
27
28export type Verdict = {
29 summary: string
30 intent: 'match' | 'partial' | 'mismatch'
31 intentNote: string
32 findings: Finding[]
33 unstated: string[]
34 attention: string[]
35}
36
37export type Decision = { at: number; principle: string; file: string; why: string }
38
39export type Intent = { prompts: string[]; answer: string }
40
41export type Review = {
42 id: string
43 at: number
44 origin: 'turn' | 'all' | 'retry'
45 status: 'running' | 'done' | 'skipped' | 'error'
46 level: Level
47 range: { from: string; to: string; fresh: string[] }
48 files: FileSummary[]
49 principleDocs: string[]
50 hits: Record<string, string[]>
51 intent: Intent
52 model: string
53 verdict?: Verdict
54 error?: string
55 excepted: number[]
56}
57
58declare module 'claude-code' {
59 interface PluginState {
60 'motive-mem': { reviews: Review[]; cursor: number; isPaused: boolean }
61 }
62}
63