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…

English | 日本語
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.
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
| Command | Where 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:
$VAR or another command substitution, so it is not known until the command runs--body-file file cannot be readBefore handing the text to the linter, the mod blanks these lines, so line numbers still match the linter's findings:
🤖 Generated with Claude Code line at the end of a PR body, and a line holding only https://claude.ai/code/session_...Co-Authored-By: and Claude-Session: linesFrom 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:
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.
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.
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.
The mod lets the command through and shows a toast once per session when:
A hook that throws midway also lets the command through.
Set them in /config or under pluginConfigs["yomiyasu-gate"].options in settings.
| Key | Default | Meaning |
|---|---|---|
linterPath | empty | Absolute path to yomiyasu_lint.py. Empty looks in ~/.claude/plugins/marketplaces/yomiyasu/skills/yomiyasu/scripts/, then ~/.claude/plugins/marketplaces/yomiyasu/scripts/ |
python | python3 | The Python that runs the linter |
minScore | 80 | Send back below this score |
hardRules | emoji_prohibited,trailing_colon | Rules of which one finding sends back (comma-separated) |
ignoreRules | unnatural_halfwidth_space | Rules left out (comma-separated) |
maxDenials | 2 | Send-backs in a row before one run is let through; 0 for no limit |
includeSubagents | true | Check subagents' commands too |
The reason and the toasts follow Claude Code's language setting, then the language of your latest prompt, then English.
claude plugin validate plugins/yomiyasu-gate
claude plugin test plugins/yomiyasu-gatehooks/gate.ts 149 lines1// 残る日本語の文章(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}
149hooks/command.ts 427 lines1// 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)
427hooks/verdict.ts 159 lines1// 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
159types/index.d.ts 15 lines1/** 表示言語 */
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