SLOPSHOPPER

motive-mem

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…

newpanecommandtoaststatusmodel
v0.1.0MITupdated 2026-10-15wjsekrud/motive-mem
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · motive-mem
│ ┃ motive-mem ✕ › fix the failing auth test and add an audit log call │ ┃ No reviews yet. They show up here when a │ ┃ turn that changed code ends. ⏺ Read(src/auth.ts) │ ┃ [ Review all uncommitted ] ⎿ 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 │ │ › /motive-mem │ ⎿ motive-mem: Intent review is idle: this is not a git repository. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · motive-mem
No reviews yet. They show up here when a turn that changed code ends. [ Review all uncommitted ]
README

Motive-mem

한국어

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.

  • Intent match — does the diff match the request and Claude's report? Changes missing from the report, reports that claim more than happened.
  • Principle violations — did it break a principle your repository has settled (layers, single paths, "the one place", promises)?
  • Band-aid fixes — traces of routing around the structure to make a local problem go away (skipping the entry point, a second copy, changing test expectations …).

<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.

How it works

  1. When you send a message, the working tree is snapshotted with git stash create (the tree itself is untouched).
  2. When the answer ends, it snapshots again and takes only the diff in between. Comment- or docs-only changes are skipped.
  3. A separate model (default sonnet) gets only the principles, the request, Claude's last report and the diff — never the author's reasoning.
  4. The status line and a toast report the result; when there are findings, a panel opens with Send to Claude, Review again and Approve as exception. Approved exceptions are kept in .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.

Install

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-memOpen the panel
/motive-mem allReview all uncommitted changes
/motive-mem pause · resumeStop · restart automatic review
/motive-mem:principlesCreate or revise the principles file (skill)

Settings

/plugin configure motive-mem@motive-mem

SettingDefault
languageenPanel, status line and review text — en or ko. Principles files can be in any language.
modelsonnetThe review model. opus judges hard cases better and costs more.
effortmediumHow hard the review model thinks.
maxDiffChars60000Past this, code files go in first and the rest are listed by name only.
openOnIssuetrueOpen the panel when there is a violation, a suspect, or a change missing from the report.

The principles file

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.

Limitations

  • One model call per turn that changes code.
  • Edits to files that were never tracked are invisible; up to 30 new untracked files per turn.
  • The same model family writes and reviews, so their blind spots can overlap.
  • The mod API is early access — if the mod stops loading after a Claude Code update, check for a newer version.

Development

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

License

MIT

Source 4 files
hooks/register.tsx 436 lines
1// 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}
436
hooks/core.ts 496 lines
1// 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}
496
hooks/text.ts 238 lines
1// 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 }
238
types/index.d.ts 63 lines
1export 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