SLOPSHOPPER

yomiyasu-gate

A mod that runs the yomiyasu linter (yomiyasu_lint.py) on the Japanese body of gh pr / gh issue create, edit and comment and of git commit before the Bash tool…

newguardtoastpromptprocess
A shopper browsing a rack in a slop shop
README

English | 日本語

yomiyasu-gate

A Claude Code mod that checks the Japanese text the agent writes into pull requests, issues and commits with the yomiyasu linter, yomiyasu_lint.py, before the Bash tool runs the command. When the text breaks the rules, the mod stops the command and sends the offending lines and their kinds back to the agent, which rewrites the text and runs the same command again.

Install

claude plugin marketplace add insession-space/claude-ship
claude plugin install yomiyasu-gate@claude-ship

It loads after Claude Code restarts. The mod is the hooks module named under modules in hooks/hooks.json (hooks/gate.ts); a Claude Code without mods does not load it, and nothing happens.

The linter ships with the yomiyasu plugin. Without it, the mod lets every command through unchecked.

claude plugin marketplace add nanaism/yomiyasu
claude plugin install yomiyasu@yomiyasu

Commands it checks

CommandWhere the text comes from
gh pr create / gh pr edit / gh pr comment--body / -b / --body-file / -F
gh issue create / gh issue edit / gh issue comment--body / -b / --body-file / -F
git commit-m (several are joined as paragraphs) / --message / -F / --file

--body-file - and -F - read the heredoc or here-string on the same line. A command substitution of the form --body "$(cat <<'EOF' ... EOF)" is read as the text too. Commands joined with && or ; are checked one by one.

The mod lets the command through unchecked when:

  • the text holds $VAR or another command substitution, so it is not known until the command runs
  • the --body-file file cannot be read
  • the text has no kana or kanji (English text is not checked)

Lines left out

Before handing the text to the linter, the mod blanks these lines, so line numbers still match the linter's findings:

  • the 🤖 Generated with Claude Code line at the end of a PR body, and a line holding only https://claude.ai/code/session_...
  • a commit's Co-Authored-By: and Claude-Session: lines

When it sends a command back

From the linter's --json findings the mod drops the ignored rules and scores the rest the linter's way: 100 minus 5 per warning and 2 per info. It sends the command back when either holds:

  • there is a finding of a rule that always sends back (emoji and trailing colons by default)
  • the score is below the threshold (80 by default)

The default of 80 comes from the PR bodies recently merged in this repository, which score 85 to 100 with the space rule left out.

The warning about a space between Japanese and Latin text is ignored by default, since this repository and its owner's repositories write that space.

The reason lists up to 8 findings with the line number, the kind of rule and the line, the always-send-back rules first.

Send-backs in a row

The mod counts how many times in a row the same agent's command of the same kind (git commit, gh pr create, ...) was sent back. Once the count reaches the limit (2 by default), the next run is let through whatever the result, with a toast, and the count starts over. A run that passes the check also starts the count over.

The count is per kind of command rather than per text, so that it also stops an agent that keeps editing the text a little and running again.

Subagents

By default the mod checks subagents' commands too: delegated agents also create commits and pull requests, and what they write stays just as long. With includeSubagents set to false it checks the main agent's commands only.

When the linter is missing or fails

The mod lets the command through and shows a toast once per session when:

  • the linter is not found
  • Python does not start, the linter exits non-zero, or its output is not JSON
  • it does not finish within 10 seconds

A hook that throws midway also lets the command through.

Options

Set them in /config or under pluginConfigs["yomiyasu-gate"].options in settings.

KeyDefaultMeaning
linterPathemptyAbsolute path to yomiyasu_lint.py. Empty looks in ~/.claude/plugins/marketplaces/yomiyasu/skills/yomiyasu/scripts/, then ~/.claude/plugins/marketplaces/yomiyasu/scripts/
pythonpython3The Python that runs the linter
minScore80Send back below this score
hardRulesemoji_prohibited,trailing_colonRules of which one finding sends back (comma-separated)
ignoreRulesunnatural_halfwidth_spaceRules left out (comma-separated)
maxDenials2Send-backs in a row before one run is let through; 0 for no limit
includeSubagentstrueCheck subagents' commands too

Language

The reason and the toasts follow Claude Code's language setting, then the language of your latest prompt, then English.

Out of scope

  • rewriting the text automatically
  • checking chat replies
  • checking English text
  • checking pull request and issue titles

Tests

claude plugin validate plugins/yomiyasu-gate
claude plugin test plugins/yomiyasu-gate
Source 4 files
hooks/gate.ts 149 lines
1// 残る日本語の文章(gh の PR / Issue の本文とコメント、git commit のメッセージ)を、
2// Bash ツールが実行する前に yomiyasu_lint.py にかけ、規則に合わなければ理由を添えて差し戻す mod。
3// どこかで失敗したら素通しにする(fail-open)。
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Lang } from '../types'
8import type { Target } from './command'
9import { findTargets, hasJapanese, stripExcluded } from './command'
10import type { Policy } from './verdict'
11import { LABELS, denyReason, findingsFromJson, judge, langFromSetting, ruleList } from './verdict'
12
13const denials = atom({ plugin: 'yomiyasu-gate', key: 'denials' } as const, {} as Record<string, number>)
14const hasWarned = atom({ plugin: 'yomiyasu-gate', key: 'hasWarned' } as const, false)
15const lang = atom({ plugin: 'yomiyasu-gate', key: 'lang' } as const, null as Lang | null)
16
17/** userConfig を指定しないときの yomiyasu_lint.py の探し場所(HOME からの相対) */
18const DEFAULT_LINTERS = [
19  '.claude/plugins/marketplaces/yomiyasu/skills/yomiyasu/scripts/yomiyasu_lint.py',
20  '.claude/plugins/marketplaces/yomiyasu/scripts/yomiyasu_lint.py',
21]
22
23const LINT_TIMEOUT_MS = 10_000
24
25const str = (v: unknown, fallback: string) => (typeof v === 'string' ? v : fallback)
26const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
27
28/** リンターの場所を決める。userConfig のパスがあればそれだけを見る。見つからなければ null */
29const findLinter = async ($: EngineInterface, configured: string): Promise<string | null> => {
30  if (configured.trim() !== '') return (await $.fs.exists(configured.trim())) ? configured.trim() : null
31  const home = await $.env.get('HOME')
32  if (home === undefined || home === '') return null
33  for (const rel of DEFAULT_LINTERS) {
34    const path = `${home.replace(/\/$/, '')}/${rel}`
35    if (await $.fs.exists(path)) return path
36  }
37  return null
38}
39
40/** 本文の取り出し元から本文を読む。読めない・確定できないときは null(素通し) */
41const bodyOf = async ($: EngineInterface, target: Target): Promise<string | null> => {
42  if (target.body.kind === 'text') return target.body.text
43  if (target.body.kind === 'unknown') return null
44  try {
45    return await $.fs.read(target.body.path)
46  } catch {
47    return null
48  }
49}
50
51/** このセッションで1回だけトーストで知らせる */
52const warnOnce = async ($: EngineInterface, text: string) => {
53  if (await read($, hasWarned)) return
54  await update($, hasWarned, () => true)
55  $.ui.toast(text, { timeoutMs: 8000 })
56}
57
58export const register: Register = (on, options) => {
59  const policy: Policy = {
60    minScore: num(options.minScore, 80),
61    hardRules: ruleList(str(options.hardRules, 'emoji_prohibited,trailing_colon')),
62    ignoreRules: ruleList(str(options.ignoreRules, 'unnatural_halfwidth_space')),
63  }
64  const maxDenials = Math.max(0, Math.floor(num(options.maxDenials, 2)))
65  const includeSubagents = options.includeSubagents !== false
66  const linterPath = str(options.linterPath, '')
67  const python = str(options.python, 'python3').trim() || 'python3'
68
69  // settings の `language` が決まっていればそれを使い、無ければプロンプトの文字で決める
70  let isLangFromSettings = false
71
72  on('session.start', async ($, e, next) => {
73    const fromSettings = langFromSetting((await $.settings.read()).language)
74    isLangFromSettings = fromSettings !== null
75    if (fromSettings !== null) await update($, lang, () => fromSettings)
76    return next(e)
77  })
78
79  on('prompt.submit', async ($, e, next) => {
80    const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
81    if (isUserText && !isLangFromSettings) await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
82    return next(e)
83  })
84
85  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
86    if (e.agentId !== undefined && !includeSubagents) return next(e)
87    const targets = findTargets(e.command)
88    if (targets.length === 0) return next(e)
89
90    const bodies: { target: Target; text: string }[] = []
91    for (const target of targets) {
92      const raw = await bodyOf($, target)
93      if (raw === null) continue
94      const text = stripExcluded(target.kind, raw)
95      if (hasJapanese(text)) bodies.push({ target, text })
96    }
97    if (bodies.length === 0) return next(e)
98
99    const l = (await read($, lang)) ?? 'en'
100    const linter = await findLinter($, linterPath)
101    if (linter === null) {
102      await warnOnce($, LABELS[l].linterMissing)
103      return next(e)
104    }
105
106    const reasons: string[] = []
107    for (const { target, text } of bodies) {
108      let findings
109      try {
110        const ran = await $.process.run([python, linter, '--json'], { stdin: text, timeoutMs: LINT_TIMEOUT_MS })
111        findings = ran.exitCode === 0 ? findingsFromJson(ran.stdout) : null
112        if (findings === null) {
113          const why = ran.exitCode === 0 ? 'unreadable output' : `exit ${ran.exitCode}: ${ran.stderr.trim().split('\n').pop() ?? ''}`
114          await warnOnce($, LABELS[l].linterFailed(why))
115          return next(e)
116        }
117      } catch (err) {
118        await warnOnce($, LABELS[l].linterFailed(String(err)))
119        return next(e)
120      }
121
122      const key = `${e.agentId ?? 'main'}:${target.kind}`
123      const verdict = judge(findings, policy)
124      if (verdict.kind === 'pass') {
125        await update($, denials, cur => {
126          const { [key]: _, ...rest } = cur
127          return rest
128        })
129        continue
130      }
131      const count = (await read($, denials))[key] ?? 0
132      if (maxDenials > 0 && count >= maxDenials) {
133        // 続けて止めた回数が上限に達した。止め続けると作業が進まないので、今回は通して知らせる
134        await update($, denials, cur => {
135          const { [key]: _, ...rest } = cur
136          return rest
137        })
138        $.ui.toast(LABELS[l].capped(target.kind, count), { timeoutMs: 8000 })
139        continue
140      }
141      await update($, denials, cur => ({ ...cur, [key]: (cur[key] ?? 0) + 1 }))
142      reasons.push(denyReason(l, target.kind, verdict, policy.minScore))
143    }
144
145    if (reasons.length > 0) return { deny: reasons.join('\n\n') }
146    return next(e)
147  }).catch(($, e, next) => next(e)) // fail-open: 判定の途中で失敗したらコマンドを通す
148}
149
hooks/command.ts 427 lines
1// Bash のコマンド文字列から、残る文章を出すコマンド(gh の PR / Issue、git commit)とその本文を取り出す。
2// シェルを完全には解釈しない。内容を確定できない形(変数展開・コマンド置換など)は unknown として返し、
3// 呼び出し側はそれを素通しにする。
4
5/** 1語。`isDynamic` は変数展開やコマンド置換を含み、実行時まで中身が決まらない語 */
6export type Word = { text: string; isDynamic: boolean }
7
8/** `;` `&&` `|` 改行などで区切った1つの単純コマンド。`stdin` はその行に付いた heredoc / here-string */
9export type Segment = { words: Word[]; stdin: string | null }
10
11/** 本文の取り出し元 */
12export type BodySource =
13  | { kind: 'text'; text: string }
14  | { kind: 'file'; path: string }
15  | { kind: 'unknown' }
16
17/** 対象のコマンド1つ。`kind` は `gh pr create` / `git commit` など */
18export type Target = { kind: string; body: BodySource }
19
20const isSpace = (c: string | undefined) => c === ' ' || c === '\t'
21
22/** 引用符で囲まない区切りの heredoc は、実行時に展開される。展開を含む本文は中身が決まらない */
23const hasExpansion = (body: string) => /\$[A-Za-z_{(?!#@*$0-9-]|`/.test(body)
24const isName = (c: string | undefined) => c !== undefined && /[A-Za-z0-9_]/.test(c)
25
26/**
27 * `$(cat <<'EOF' ... EOF)` の形のコマンド置換を `at` の位置で読む。
28 * 読めたら本文と、置換の直後の位置を返す。ほかの形なら null。
29 */
30const readCatHeredoc = (src: string, at: number): { body: string; isDynamic: boolean; end: number } | null => {
31  const head = /\$\(\s*cat\s*<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2[ \t]*\n/y
32  head.lastIndex = at
33  const m = head.exec(src)
34  if (m === null) return null
35  const strip = m[1] === '-'
36  const isQuoted = m[2] !== ''
37  const delim = m[3] ?? ''
38  const lines: string[] = []
39  let pos = head.lastIndex
40  for (;;) {
41    if (pos > src.length) return null
42    const nl = src.indexOf('\n', pos)
43    const line = nl === -1 ? src.slice(pos) : src.slice(pos, nl)
44    const cmp = strip ? line.replace(/^\t+/, '') : line
45    if (cmp.trim() === delim) {
46      const tail = /\s*\)/y
47      tail.lastIndex = nl === -1 ? src.length : nl
48      const t = tail.exec(src)
49      if (t === null) return null
50      const body = lines.join('\n')
51      return { body, isDynamic: !isQuoted && hasExpansion(body), end: tail.lastIndex }
52    }
53    lines.push(strip ? line.replace(/^\t+/, '') : line)
54    if (nl === -1) return null
55    pos = nl + 1
56  }
57}
58
59/** `$(` から対応する `)` の直後までを読み飛ばす(引用符の中の括弧は数えない) */
60const skipSubst = (src: string, at: number): number => {
61  let depth = 0
62  let i = at + 1
63  while (i < src.length) {
64    const c = src[i]
65    if (c === '\\') i += 2
66    else if (c === "'") {
67      const close = src.indexOf("'", i + 1)
68      i = close === -1 ? src.length : close + 1
69    } else if (c === '(') {
70      depth++
71      i++
72    } else if (c === ')') {
73      depth--
74      i++
75      if (depth === 0) return i
76    } else i++
77  }
78  return src.length
79}
80
81/** `$` で始まる展開を `at` の位置で読む。heredoc の cat なら本文を返し、ほかは動的な語として読み飛ばす */
82const readDollar = (src: string, at: number): { text: string; isDynamic: boolean; end: number } => {
83  const next = src[at + 1]
84  if (next === '(') {
85    const hd = readCatHeredoc(src, at)
86    if (hd !== null) return { text: hd.body, isDynamic: hd.isDynamic, end: hd.end }
87    return { text: '', isDynamic: true, end: skipSubst(src, at) }
88  }
89  if (next === '{') {
90    const close = src.indexOf('}', at + 2)
91    return { text: '', isDynamic: true, end: close === -1 ? src.length : close + 1 }
92  }
93  if (isName(next) || (next !== undefined && '?!#@*$-'.includes(next))) {
94    let i = at + 2
95    if (isName(next)) while (isName(src[i])) i++
96    return { text: '', isDynamic: true, end: i }
97  }
98  return { text: '$', isDynamic: false, end: at + 1 }
99}
100
101const ANSI_C: Record<string, string> = { n: '\n', t: '\t', r: '\r', '\\': '\\', "'": "'", '"': '"', a: '\x07', e: '\x1b' }
102
103/**
104 * コマンド文字列を単純コマンドの並びに分ける。引用符・エスケープ・heredoc・`$(cat <<EOF)` を読み、
105 * リダイレクトの行き先は語から外す。
106 */
107export const splitCommand = (src: string): Segment[] => {
108  const segments: Segment[] = []
109  let seg: Segment = { words: [], stdin: null }
110  let word: Word | null = null
111  let redirectNext = false
112  let herestringNext = false
113  const pending: { seg: Segment; delim: string; strip: boolean; isQuoted: boolean }[] = []
114
115  const endWord = () => {
116    if (word === null) return
117    if (herestringNext) {
118      seg.stdin = word.isDynamic ? null : word.text
119      herestringNext = false
120    } else if (redirectNext) {
121      redirectNext = false
122    } else seg.words.push(word)
123    word = null
124  }
125  const endSegment = () => {
126    endWord()
127    if (seg.words.length > 0 || seg.stdin !== null || pending.some(p => p.seg === seg)) segments.push(seg)
128    seg = { words: [], stdin: null }
129  }
130  const w = (): Word => (word ??= { text: '', isDynamic: false })
131
132  let i = 0
133  while (i < src.length) {
134    const c = src[i] as string
135    if (isSpace(c)) {
136      endWord()
137      i++
138    } else if (c === '\n') {
139      endSegment()
140      i++
141      // この行で始まった heredoc の本文を読む
142      while (pending.length > 0) {
143        const p = pending.shift()!
144        const lines: string[] = []
145        let closed = false
146        while (i <= src.length) {
147          const nl = src.indexOf('\n', i)
148          const line = nl === -1 ? src.slice(i) : src.slice(i, nl)
149          i = nl === -1 ? src.length + 1 : nl + 1
150          const cmp = p.strip ? line.replace(/^\t+/, '') : line
151          if (cmp === p.delim) {
152            closed = true
153            break
154          }
155          lines.push(cmp)
156          if (nl === -1) break
157        }
158        const body = lines.join('\n')
159        p.seg.stdin = closed && (p.isQuoted || !hasExpansion(body)) ? body : null
160      }
161    } else if (c === '#' && word === null) {
162      const nl = src.indexOf('\n', i)
163      i = nl === -1 ? src.length : nl
164    } else if (c === ';' || c === '&' || c === '|' || c === '(' || c === ')') {
165      if (c === '&' && src[i - 1] === '>') {
166        i++
167        continue
168      }
169      endSegment()
170      i++
171    } else if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
172      endWord()
173      herestringNext = true
174      i += 3
175    } else if (c === '<' && src[i + 1] === '<') {
176      endWord()
177      i += 2
178      const strip = src[i] === '-'
179      if (strip) i++
180      while (isSpace(src[i])) i++
181      const m = /(['"]?)([^\s'";&|<>()]+)\1/y
182      m.lastIndex = i
183      const got = m.exec(src)
184      if (got === null) continue
185      pending.push({ seg, delim: got[2] ?? '', strip, isQuoted: got[1] !== '' })
186      i = m.lastIndex
187    } else if (c === '<' || c === '>') {
188      // `2>` のような番号付きのリダイレクトは、直前の数字を語にしない
189      // word は閉じ込めた関数の中でも書き換えるため、TypeScript の絞り込みに頼らずに読む
190      const before = word as Word | null
191      if (before !== null && /^\d+$/.test(before.text) && !before.isDynamic) word = null
192      endWord()
193      i++
194      while (src[i] === '>' || src[i] === '&') i++
195      while (isSpace(src[i])) i++
196      // `>&2` のように番号だけを指すものは行き先の語を持たない
197      if (/\d/.test(src[i] ?? '') && !/\S/.test(src[i + 1] ?? ' ')) {
198        i++
199        continue
200      }
201      redirectNext = true
202    } else if (c === '\\') {
203      if (src[i + 1] === '\n') i += 2
204      else {
205        w().text += src[i + 1] ?? ''
206        i += 2
207      }
208    } else if (c === "'") {
209      const close = src.indexOf("'", i + 1)
210      const end = close === -1 ? src.length : close
211      w().text += src.slice(i + 1, end)
212      i = end + 1
213    } else if (c === '$' && src[i + 1] === "'") {
214      i += 2
215      const cur = w()
216      while (i < src.length && src[i] !== "'") {
217        if (src[i] === '\\') {
218          cur.text += ANSI_C[src[i + 1] ?? ''] ?? src[i + 1] ?? ''
219          i += 2
220        } else cur.text += src[i++]
221      }
222      i++
223    } else if (c === '"') {
224      i++
225      const cur = w()
226      while (i < src.length && src[i] !== '"') {
227        const d = src[i] as string
228        if (d === '\\' && src[i + 1] !== undefined && '"\\$`\n'.includes(src[i + 1] as string)) {
229          if (src[i + 1] !== '\n') cur.text += src[i + 1]
230          i += 2
231        } else if (d === '$') {
232          const got = readDollar(src, i)
233          cur.text += got.text
234          cur.isDynamic ||= got.isDynamic
235          i = got.end
236        } else if (d === '`') {
237          const close = src.indexOf('`', i + 1)
238          cur.isDynamic = true
239          i = close === -1 ? src.length : close + 1
240        } else cur.text += src[i++]
241      }
242      i++
243    } else if (c === '$') {
244      const got = readDollar(src, i)
245      const cur = w()
246      cur.text += got.text
247      cur.isDynamic ||= got.isDynamic
248      i = got.end
249    } else if (c === '`') {
250      const close = src.indexOf('`', i + 1)
251      w().isDynamic = true
252      i = close === -1 ? src.length : close + 1
253    } else {
254      w().text += c
255      i++
256    }
257  }
258  endSegment()
259
260  return segments
261}
262
263const GH_TARGETS: Record<string, readonly string[]> = {
264  pr: ['create', 'edit', 'comment'],
265  issue: ['create', 'edit', 'comment'],
266}
267
268/** 値を取る git のグローバルオプション */
269const GIT_GLOBAL_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
270
271/** 値を取る git commit の短いオプション(本文以外も、値を読み飛ばすために並べる) */
272const COMMIT_SHORT_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't'])
273const COMMIT_LONG_WITH_VALUE = new Set([
274  '--message',
275  '--file',
276  '--reuse-message',
277  '--reedit-message',
278  '--fixup',
279  '--squash',
280  '--template',
281  '--author',
282  '--date',
283  '--cleanup',
284  '--trailer',
285  '--pathspec-from-file',
286])
287
288const sourceOf = (word: Word, kind: 'text' | 'file'): BodySource => {
289  if (word.isDynamic) return { kind: 'unknown' }
290  return kind === 'text' ? { kind: 'text', text: word.text } : { kind: 'file', path: word.text }
291}
292
293/** gh の `--body` / `-b` / `--body-file` / `-F` を読む。最後に書いたものを使う */
294const ghBody = (words: Word[], stdin: string | null): BodySource | null => {
295  let body: BodySource | null = null
296  const fileOrStdin = (word: Word): BodySource =>
297    word.text === '-' && !word.isDynamic ? (stdin === null ? { kind: 'unknown' } : { kind: 'text', text: stdin }) : sourceOf(word, 'file')
298  for (let i = 0; i < words.length; i++) {
299    const t = (words[i] as Word).text
300    const next = words[i + 1]
301    if (t === '--') break
302    if (t === '--body' || t === '-b') {
303      body = next === undefined ? { kind: 'unknown' } : sourceOf(next, 'text')
304      i++
305    } else if (t === '--body-file' || t === '-F') {
306      body = next === undefined ? { kind: 'unknown' } : fileOrStdin(next)
307      i++
308    } else if (t.startsWith('--body=')) {
309      body = sourceOf({ ...(words[i] as Word), text: t.slice('--body='.length) }, 'text')
310    } else if (t.startsWith('--body-file=')) {
311      body = fileOrStdin({ ...(words[i] as Word), text: t.slice('--body-file='.length) })
312    } else if (/^-b./.test(t)) {
313      body = sourceOf({ ...(words[i] as Word), text: t.slice(2) }, 'text')
314    } else if (/^-F./.test(t)) {
315      body = fileOrStdin({ ...(words[i] as Word), text: t.slice(2) })
316    }
317  }
318  return body
319}
320
321/** git commit の `-m`(複数なら段落にしてつなぐ)/ `-F` / `--message` / `--file` を読む */
322const commitBody = (words: Word[], stdin: string | null): BodySource | null => {
323  const messages: Word[] = []
324  let file: Word | null = null
325  for (let i = 0; i < words.length; i++) {
326    const word = words[i] as Word
327    const t = word.text
328    const next = words[i + 1]
329    if (t === '--') break
330    if (t.startsWith('--')) {
331      const eq = t.indexOf('=')
332      const name = eq === -1 ? t : t.slice(0, eq)
333      if (!COMMIT_LONG_WITH_VALUE.has(name)) continue
334      const value = eq === -1 ? next : { ...word, text: t.slice(eq + 1) }
335      if (eq === -1) i++
336      if (value === undefined) return { kind: 'unknown' }
337      if (name === '--message') messages.push(value)
338      else if (name === '--file') file = value
339      continue
340    }
341    if (!/^-[A-Za-z]/.test(t)) continue
342    // `-am "..."` のような短いオプションのまとまり。値を取る文字の後ろは値
343    for (let j = 1; j < t.length; j++) {
344      const ch = t[j] as string
345      if (!COMMIT_SHORT_WITH_VALUE.has(ch)) continue
346      const rest = t.slice(j + 1)
347      let value: Word | undefined
348      if (rest !== '') value = { ...word, text: rest }
349      else {
350        value = next
351        i++
352      }
353      if (value === undefined) return { kind: 'unknown' }
354      if (ch === 'm') messages.push(value)
355      else if (ch === 'F') file = value
356      break
357    }
358  }
359  if (file !== null) {
360    if (file.text === '-' && !file.isDynamic) return stdin === null ? { kind: 'unknown' } : { kind: 'text', text: stdin }
361    return sourceOf(file, 'file')
362  }
363  if (messages.length === 0) return null
364  if (messages.some(m => m.isDynamic)) return { kind: 'unknown' }
365  return { kind: 'text', text: messages.map(m => m.text).join('\n\n') }
366}
367
368/** 単純コマンド1つが対象なら、その種類と本文の取り出し元を返す。本文を渡していないコマンドは null */
369const targetOf = (segment: Segment): Target | null => {
370  const words = [...segment.words]
371  // 先頭の `VAR=value` と env / command は読み飛ばす
372  while (words.length > 0) {
373    const t = (words[0] as Word).text
374    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || t === 'env' || t === 'command') words.shift()
375    else break
376  }
377  const head = words[0]?.text
378  if (head === 'gh') {
379    const group = words[1]?.text ?? ''
380    const verb = words[2]?.text ?? ''
381    if (!(GH_TARGETS[group] ?? []).includes(verb)) return null
382    const body = ghBody(words.slice(3), segment.stdin)
383    return body === null ? null : { kind: `gh ${group} ${verb}`, body }
384  }
385  if (head === 'git') {
386    let i = 1
387    while (i < words.length && (words[i] as Word).text.startsWith('-')) {
388      i += GIT_GLOBAL_WITH_VALUE.has((words[i] as Word).text) ? 2 : 1
389    }
390    if (words[i]?.text !== 'commit') return null
391    const body = commitBody(words.slice(i + 1), segment.stdin)
392    return body === null ? null : { kind: 'git commit', body }
393  }
394  return null
395}
396
397/** コマンド文字列の中の対象コマンドを、書かれた順に返す。 */
398export const findTargets = (command: string): Target[] =>
399  splitCommand(command)
400    .map(targetOf)
401    .filter((t): t is Target => t !== null)
402
403/** PR(本文とコメント)の末尾の帰属行とセッション URL */
404const PR_ATTRIBUTION = [
405  /^\s*🤖 Generated with \[Claude Code\]\(https:\/\/claude\.com\/claude-code\)\s*$/,
406  /^\s*https:\/\/claude\.ai\/code\/session_\S+\s*$/,
407]
408
409/** コミットのトレーラー */
410const COMMIT_TRAILERS = [/^\s*Co-Authored-By:.*$/i, /^\s*Claude-Session:.*$/i]
411
412/** コマンドの種類ごとに検査から外す行 */
413const excludedLinesOf = (kind: string): readonly RegExp[] =>
414  kind === 'git commit' ? COMMIT_TRAILERS : kind.startsWith('gh pr ') ? PR_ATTRIBUTION : []
415
416/** 除外する行を空行に置き換える。行番号はリンターの指摘とそろえるため保つ */
417export const stripExcluded = (kind: string, body: string): string => {
418  const excluded = excludedLinesOf(kind)
419  return body
420    .split('\n')
421    .map(line => (excluded.some(re => re.test(line)) ? '' : line))
422    .join('\n')
423}
424
425/** かなか漢字を含むか */
426export const hasJapanese = (text: string): boolean => /[぀-ヿ一-鿿]/.test(text)
427
hooks/verdict.ts 159 lines
1// yomiyasu_lint.py の JSON 出力を読み、差し戻すかどうかと差し戻しの理由を決める。
2
3import type { Lang } from '../types'
4
5/** yomiyasu_lint.py の指摘1件(`--json` の findings の要素) */
6export type Finding = { rule: string; line: number; severity: string; message: string; snippet: string }
7
8export type Verdict =
9  | { kind: 'pass'; score: number }
10  | { kind: 'deny'; score: number; findings: Finding[]; hardRules: string[] }
11
12export type Policy = {
13  /** これを下回ったら差し戻すスコア(0〜100) */
14  minScore: number
15  /** 1件でもあれば差し戻す規則 */
16  hardRules: readonly string[]
17  /** 数えない規則 */
18  ignoreRules: readonly string[]
19}
20
21/** yomiyasu_lint.py の `--json` の出力から指摘を読む。形が違えば null */
22export const findingsFromJson = (stdout: string): Finding[] | null => {
23  let parsed: unknown
24  try {
25    parsed = JSON.parse(stdout)
26  } catch {
27    return null
28  }
29  if (typeof parsed !== 'object' || parsed === null) return null
30  const raw = (parsed as { findings?: unknown }).findings
31  if (!Array.isArray(raw)) return null
32  return raw.flatMap((f): Finding[] => {
33    if (typeof f !== 'object' || f === null) return []
34    const o = f as Record<string, unknown>
35    if (typeof o.rule !== 'string') return []
36    return [
37      {
38        rule: o.rule,
39        line: typeof o.line === 'number' ? o.line : 0,
40        severity: typeof o.severity === 'string' ? o.severity : 'warn',
41        message: typeof o.message === 'string' ? o.message : '',
42        snippet: typeof o.snippet === 'string' ? o.snippet : '',
43      },
44    ]
45  })
46}
47
48/** yomiyasu_lint.py と同じ減点(warn / error は 5 点、それ以外は 2 点)でスコアを出す */
49export const scoreOf = (findings: readonly Finding[]): number =>
50  Math.max(0, 100 - findings.reduce((sum, f) => sum + (f.severity === 'warn' || f.severity === 'error' ? 5 : 2), 0))
51
52/** 無視する規則を除いてスコアを計算し直し、差し戻すかを決める */
53export const judge = (findings: readonly Finding[], policy: Policy): Verdict => {
54  const counted = findings.filter(f => !policy.ignoreRules.includes(f.rule))
55  const score = scoreOf(counted)
56  const hard = [...new Set(counted.filter(f => policy.hardRules.includes(f.rule)).map(f => f.rule))]
57  if (hard.length === 0 && score >= policy.minScore) return { kind: 'pass', score }
58  return { kind: 'deny', score, findings: counted, hardRules: hard }
59}
60
61/** カンマ区切りの規則名の並び */
62export const ruleList = (value: unknown): string[] =>
63  typeof value === 'string'
64    ? value
65        .split(',')
66        .map(s => s.trim())
67        .filter(s => s !== '')
68    : []
69
70const RULE_NAMES: Record<Lang, Record<string, string>> = {
71  ja: {
72    emoji_prohibited: '絵文字',
73    trailing_colon: '文末コロン',
74    unnatural_halfwidth_space: '和欧文間の半角空白',
75    slop_vocabulary: 'AI頻出語彙',
76    metaphor_verb: '比喩動詞',
77    meta_filler: '前置き・定型句',
78    negative_parallelism: '否定対比',
79    sentence_end_repetition: '同じ文末の連続',
80    excess_bold: '太字が多い',
81    excess_list: '箇条書きが多い',
82    redundant_bracket: '言い換えだけのカッコ',
83  },
84  en: {
85    emoji_prohibited: 'emoji',
86    trailing_colon: 'trailing colon',
87    unnatural_halfwidth_space: 'space between Japanese and Latin text',
88    slop_vocabulary: 'AI-typical vocabulary',
89    metaphor_verb: 'metaphorical verb',
90    meta_filler: 'filler opening or closing',
91    negative_parallelism: '"not A but B" construction',
92    sentence_end_repetition: 'repeated sentence ending',
93    excess_bold: 'too much bold',
94    excess_list: 'too many bullet lines',
95    redundant_bracket: 'redundant parenthetical',
96  },
97}
98
99/** 差し戻しの理由に並べる指摘の上限 */
100const MAX_LISTED = 8
101
102const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…` : s)
103
104/** エージェントが読んで直せるように、違反の行・種類・該当行を並べた差し戻しの理由 */
105export const denyReason = (lang: Lang, kind: string, verdict: Extract<Verdict, { kind: 'deny' }>, minScore: number): string => {
106  const names = RULE_NAMES[lang]
107  // 1件で差し戻す規則を先に並べる
108  const sorted = [...verdict.findings].sort(
109    (a, b) => Number(verdict.hardRules.includes(b.rule)) - Number(verdict.hardRules.includes(a.rule)) || a.line - b.line,
110  )
111  const rows = sorted.slice(0, MAX_LISTED).map(f => {
112    const name = names[f.rule] ?? f.rule
113    const where = f.line > 0 ? `L${f.line} ` : ''
114    return `- ${where}${name}: ${clip(f.snippet.trim(), 80)}`
115  })
116  const rest = sorted.length - rows.length
117  if (lang === 'ja') {
118    const why =
119      verdict.hardRules.length > 0
120        ? `${verdict.hardRules.map(r => names[r] ?? r).join('・')}があるため`
121        : `スコアが ${verdict.score} でしきい値 ${minScore} を下回ったため`
122    return [
123      `yomiyasu-gate: \`${kind}\` の本文が yomiyasu の規則に合わず、${why}実行を止めました(スコア ${verdict.score})。`,
124      '次の箇所を書き直してから、同じコマンドを再実行してください。',
125      ...rows,
126      ...(rest > 0 ? [`- ほか ${rest} 件`] : []),
127    ].join('\n')
128  }
129  const why =
130    verdict.hardRules.length > 0
131      ? `it contains: ${verdict.hardRules.map(r => names[r] ?? r).join(', ')}`
132      : `its score ${verdict.score} is below the threshold ${minScore}`
133  return [
134    `yomiyasu-gate: stopped \`${kind}\` because its Japanese text does not follow the yomiyasu rules; ${why} (score ${verdict.score}).`,
135    'Rewrite the lines below, then run the same command again.',
136    ...rows,
137    ...(rest > 0 ? [`- and ${rest} more`] : []),
138  ].join('\n')
139}
140
141/** settings の `language` から表示言語を決める。決まらなければ null(acceptance-progress と同じ規則) */
142export const langFromSetting = (language: unknown): Lang | null => {
143  if (typeof language !== 'string' || language.trim() === '') return null
144  return /^(ja([-_].*)?|日本語?|japanese)$/i.test(language.trim()) ? 'ja' : 'en'
145}
146
147export const LABELS = {
148  ja: {
149    linterMissing: 'yomiyasu-gate: yomiyasu_lint.py が見つからないため、本文を検査せずに通しています',
150    linterFailed: (why: string) => `yomiyasu-gate: yomiyasu_lint.py の実行に失敗したため、本文を検査せずに通しています(${why})`,
151    capped: (kind: string, n: number) => `yomiyasu-gate: \`${kind}\` を続けて ${n} 回差し戻したため、今回は検査結果に関わらず通しました`,
152  },
153  en: {
154    linterMissing: 'yomiyasu-gate: yomiyasu_lint.py was not found, so text is passed through unchecked',
155    linterFailed: (why: string) => `yomiyasu-gate: running yomiyasu_lint.py failed, so text is passed through unchecked (${why})`,
156    capped: (kind: string, n: number) => `yomiyasu-gate: \`${kind}\` was stopped ${n} times in a row, so this run was let through regardless`,
157  },
158} as const
159
types/index.d.ts 15 lines
1/** 表示言語 */
2export type Lang = 'ja' | 'en'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'yomiyasu-gate': {
7      /** `<エージェント>:<コマンドの種類>` ごとの、続けて差し戻した回数。通したら消す */
8      denials: Record<string, number>
9      /** リンターが無い・失敗したことを、このセッションでトーストで知らせたか */
10      hasWarned: boolean
11      lang: Lang | null
12    }
13  }
14}
15