claude-token-saver 3.23.3 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subcommand: upgrade — install the latest release with the package manager
3
+ * that put this copy on disk.
4
+ *
5
+ * claude-token-saver upgrade # refresh the check, then install
6
+ * claude-token-saver upgrade --print # show the command, run nothing
7
+ *
8
+ * This is the command the model runs after the user says yes to the
9
+ * session-start offer, so it prints the exact command it is about to execute
10
+ * before executing it: an install that writes outside the project should never
11
+ * be a black box.
12
+ */
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ function runCommand(cmd) {
17
+ return new Promise((resolve) => {
18
+ // Shell form: the upgrade command is a fixed string we composed ourselves
19
+ // (no user input reaches it), and `npm install -g` needs the user's PATH
20
+ // resolution to find the same npm that installed us.
21
+ const child = spawn(cmd, { shell: true, stdio: 'inherit' });
22
+ child.on('close', (code) => resolve(code === null ? 1 : code));
23
+ child.on('error', () => resolve(1));
24
+ });
25
+ }
26
+
27
+ export async function run({ hasFlag, version }) {
28
+ const { updateStatus, refreshUpdateState, upgradeCommand } = await import('../update-check.js');
29
+ const { userLanguage } = await import('../config.js');
30
+ const lang = userLanguage();
31
+ const cmd = upgradeCommand();
32
+
33
+ if (hasFlag('--print')) {
34
+ console.log(cmd);
35
+ return;
36
+ }
37
+
38
+ // Refresh first so the "already latest" answer is trustworthy rather than up
39
+ // to 24h stale — an upgrade is a deliberate, interactive action, so one
40
+ // network round trip is fine here.
41
+ await refreshUpdateState(version);
42
+ const s = updateStatus(version);
43
+ if (!s.available) {
44
+ console.log(lang === 'ko'
45
+ ? `이미 최신 버전입니다 (v${version}). 설치할 것이 없습니다.`
46
+ : `Already on the latest version (v${version}). Nothing to install.`);
47
+ return;
48
+ }
49
+
50
+ console.log(lang === 'ko'
51
+ ? `v${version} → ${s.latest} 로 업그레이드합니다.`
52
+ : `Upgrading v${version} → ${s.latest}.`);
53
+ console.log(`$ ${cmd}`);
54
+ const code = await runCommand(cmd);
55
+ if (code !== 0) {
56
+ console.error(lang === 'ko'
57
+ ? `업그레이드 명령이 종료 코드 ${code}로 실패했습니다. 위 출력을 확인하고, 권한 문제라면 sudo 없이 설치되는 전역 경로인지 점검하십시오.`
58
+ : `Upgrade command failed with exit code ${code}. Check the output above; if it is a permissions error, verify your global install prefix.`);
59
+ process.exit(code);
60
+ }
61
+ // The freshly installed copy is a different file on disk, so this process
62
+ // still reports the old version. Refresh the cache against the new latest so
63
+ // the statusline chip clears on the next render instead of lingering.
64
+ await refreshUpdateState(s.latest);
65
+ console.log(lang === 'ko'
66
+ ? `설치가 끝났습니다. 새 셸에서 claude-token-saver --version 으로 ${s.latest} 인지 확인하십시오.`
67
+ : `Done. In a fresh shell, run claude-token-saver --version to confirm ${s.latest}.`);
68
+ }
@@ -157,6 +157,33 @@ function buildKoreanSeg(c, isIcon, verbose) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Version segment builder — "which copy of this tool am I looking at", plus
162
+ * the upgrade nudge when a newer one has been published.
163
+ *
164
+ * Two states, deliberately different in weight:
165
+ * - up to date → `v3.24.0` in gray. Identity context, not news.
166
+ * - update available → `⬆ v3.24.0 → 3.25.0` in yellow. Same tone as the
167
+ * other "you should do something eventually" chips, never red: nothing is
168
+ * broken, and a permanently-red statusline trains the eye to ignore red.
169
+ *
170
+ * A statusline cannot open a dialog, so the *asking* happens at session start
171
+ * (see route-scan --hook, which briefs the model to offer the upgrade). This
172
+ * chip is the persistent reminder between those offers, which is why it keeps
173
+ * rendering after the user declines — declining hides the session-start
174
+ * question, not the fact that a new version exists.
175
+ */
176
+ function buildVersionSeg(version, update, c, isIcon, verbose) {
177
+ if (!version) return null;
178
+ if (update && update.available && update.latest) {
179
+ const body = verbose
180
+ ? `Update v${version} → ${update.latest}`
181
+ : `v${version} → ${update.latest}`;
182
+ return `${c(YELLOW)}${isIcon ? '⬆ ' : ''}${body}${c(RESET)}`;
183
+ }
184
+ return `${c(GRAY)}v${version}${c(RESET)}`;
185
+ }
186
+
160
187
  /**
161
188
  * Harness 🅷 segment builder — shared by the full report and the no-session
162
189
  * fallback line. Best-effort: never throws into the statusline (corrupted
@@ -209,18 +236,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
209
236
  * just because the user has been idle past the window — so cap-warn,
210
237
  * harness, and model chips still render around the "no session data" note.
211
238
  */
212
- export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
239
+ export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
213
240
  const c = (v) => (color ? v : '');
214
241
  const isIcon = mode === 'icon';
215
242
  const segs = [];
216
243
  const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
217
244
  if (capSeg) segs.push(capSeg);
245
+ const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
246
+ if (versionSeg && update && update.available) segs.push(versionSeg);
218
247
  const harnessSeg = buildHarnessSeg(c, isIcon);
219
248
  if (harnessSeg) segs.push(harnessSeg);
220
249
  if (typeof model === 'string' && model.length > 0) {
221
250
  segs.push(isIcon ? `${c(MAGENTA)}🤖 ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
222
251
  }
223
252
  segs.push(`${c(GRAY)}🧠 no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
253
+ if (versionSeg && !(update && update.available)) segs.push(versionSeg);
224
254
  return segs.join(' · ') + (color ? '\x1b[K' : '');
225
255
  }
226
256
 
@@ -231,7 +261,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '' }
231
261
  * @param {boolean} [opts.verbose=false] - longer layout with labels
232
262
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
233
263
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
234
- * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
264
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
235
265
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
236
266
  */
237
267
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
@@ -446,6 +476,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
446
476
  // a missing section at a glance and know to run `harness init`.
447
477
  const harnessSeg = buildHarnessSeg(c, isIcon);
448
478
 
479
+ // Version / upgrade chip. Read from a cache written by a detached background
480
+ // check — this render path never touches the network.
481
+ const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
482
+ const updateAvailable = !!(data.update && data.update.available);
483
+
449
484
  // Korean-style chip — rendered only when the session-start injection is on.
450
485
  const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
451
486
 
@@ -550,6 +585,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
550
585
  const segs = [];
551
586
  if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
552
587
  if (spikeSeg && want('spike')) segs.push(spikeSeg);
588
+ // An available upgrade rides up front with the other "act on this" chips.
589
+ // When there is nothing to upgrade to, the same segment is pure identity and
590
+ // sits at the tail instead (pushed after `saved`, below).
591
+ if (versionSeg && updateAvailable && want('version')) segs.push(versionSeg);
553
592
  if (harnessSeg && want('harness')) segs.push(harnessSeg);
554
593
  if (koreanSeg && want('korean')) segs.push(koreanSeg);
555
594
  if (modelSeg && want('model')) segs.push(modelSeg);
@@ -569,6 +608,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
569
608
  // it sits near the tail. The period label closes the line as a quiet
570
609
  // timeframe footer.
571
610
  if (want('saved')) segs.push(saveSeg);
611
+ if (versionSeg && !updateAvailable && want('version')) segs.push(versionSeg);
572
612
  if (want('period')) segs.push(periodSeg);
573
613
  // Trailing erase-to-end-of-line so any leftover characters from a previous
574
614
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
package/src/installer.js CHANGED
@@ -284,6 +284,68 @@ export function installBriefHook() {
284
284
  return { path: file, action: 'created' };
285
285
  }
286
286
 
287
+ // Registers the PostToolUse hook that checks Korean prose the session just
288
+ // wrote. Session-start guidance teaches the model but is never re-read, so
289
+ // files written later drift back to the patterns the guidance forbids and the
290
+ // drift surfaces only when a human reads the artifact. This hook closes that
291
+ // gap: it runs on the file the model just wrote, while it can still fix it.
292
+ // Installed by `korean on`, removed by `korean off`. Idempotent.
293
+ const KOREAN_LINT_HOOK_COMMAND = 'claude-token-saver korean --hook';
294
+
295
+ export function installKoreanLintHook() {
296
+ const dir = claudeUserDir();
297
+ const file = join(dir, 'settings.json');
298
+ mkdirSync(dir, { recursive: true });
299
+
300
+ let settings = {};
301
+ if (existsSync(file)) {
302
+ try {
303
+ settings = JSON.parse(readFileSync(file, 'utf8'));
304
+ } catch (e) {
305
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
306
+ }
307
+ }
308
+
309
+ settings.hooks = settings.hooks || {};
310
+ if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
311
+ return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
312
+ }
313
+ const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
314
+ const already = list.some((m) =>
315
+ Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook')),
316
+ );
317
+ if (already) return { path: file, action: 'exists' };
318
+
319
+ list.push({
320
+ matcher: 'Write|Edit|MultiEdit',
321
+ hooks: [{ type: 'command', command: KOREAN_LINT_HOOK_COMMAND, timeout: 10 }],
322
+ });
323
+ settings.hooks.PostToolUse = list;
324
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
325
+ return { path: file, action: 'created' };
326
+ }
327
+
328
+ export function removeKoreanLintHook() {
329
+ const file = join(claudeUserDir(), 'settings.json');
330
+ if (!existsSync(file)) return { path: file, action: 'absent' };
331
+ let settings;
332
+ try {
333
+ settings = JSON.parse(readFileSync(file, 'utf8'));
334
+ } catch (e) {
335
+ return { path: file, action: 'skipped', reason: `unreadable JSON (${e.message})` };
336
+ }
337
+ const list = settings?.hooks?.PostToolUse;
338
+ if (!Array.isArray(list)) return { path: file, action: 'absent' };
339
+ const kept = list.filter((m) =>
340
+ !(Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook'))),
341
+ );
342
+ if (kept.length === list.length) return { path: file, action: 'absent' };
343
+ if (kept.length === 0) delete settings.hooks.PostToolUse;
344
+ else settings.hooks.PostToolUse = kept;
345
+ writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
346
+ return { path: file, action: 'removed' };
347
+ }
348
+
287
349
  export function installAll({ force = false } = {}) {
288
350
  return {
289
351
  skill: installSkill({ force }),
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * korean-lint — machine-checkable half of the fluent-korean guidance.
4
+ *
5
+ * Why this exists next to korean-style.js:
6
+ *
7
+ * korean-style.js hands the model ~1.3k tokens of prose at session start. That
8
+ * is the right way to teach judgement, but prose has no enforcement point. The
9
+ * model reads it once, writes forty files over the next two hours, and nothing
10
+ * ever re-checks the output. Users reported exactly that hole: the guidance was
11
+ * loaded, the session still shipped `~는 자리` and `~의 흐름` into documents,
12
+ * and it surfaced only when a human read the finished artifact.
13
+ *
14
+ * So the clauses a machine can decide are checked at write time instead. The
15
+ * PostToolUse hook already runs on Write/Edit, so every file the session
16
+ * produces passes through here and findings are handed back to the model while
17
+ * it can still fix them.
18
+ *
19
+ * Scope: every text file the session writes, documents and source alike. The
20
+ * vendored guidance exempts code comments, but a comment is read by a person
21
+ * and generated artifacts are assembled from the strings sitting in source, so
22
+ * exempting them reopens the gap for exactly the outputs that were reported.
23
+ * `isLintTarget(path, 'prose')` restores the narrow reading. Only dependencies,
24
+ * VCS internals, lockfiles, and binary or image files are skipped; build output
25
+ * is checked like anything else, and fenced code blocks are dropped from
26
+ * documents. Findings are requests to confirm, not
27
+ * verdicts — a settled idiom or a verbatim quotation is allowed to stay.
28
+ *
29
+ * Zero dependencies, CommonJS, so ~/.claude/cache-monitor-hook.cjs can require
30
+ * it standalone.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ // Documents. Under the `prose` scope these are the only files checked.
36
+ const PROSE_EXTENSIONS = new Set(['.md', '.mdx', '.markdown', '.txt', '.rst', '.adoc']);
37
+
38
+ // Only two kinds of path are skipped: installed dependencies and VCS
39
+ // internals, neither of which anyone in this session wrote. Build output is
40
+ // deliberately NOT on this list. Generated artifacts are the files a reader
41
+ // actually receives, so exempting `dist/` or `build/` would exempt the very
42
+ // documents the check exists for.
43
+ const SKIP_PATH = /(^|[\\/])(node_modules|\.git|.*\.min\.[a-z]+|.*-lock\.json|.*\.lock)([\\/]|$)/i;
44
+ const BINARY_EXTENSIONS = new Set([
45
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar',
46
+ '.mp3', '.mp4', '.wav', '.mov', '.woff', '.woff2', '.ttf', '.otf', '.map', '.bin',
47
+ ]);
48
+
49
+ /**
50
+ * Figurative vocabulary (guidance 3.4). This is the clause human review misses
51
+ * most often, because each individual phrase reads fine in isolation — it is
52
+ * only against the rule that it is standing where a plain noun or verb should.
53
+ * Add a line here when a new one shows up; that is the whole maintenance story.
54
+ */
55
+ const METAPHOR_LEXICON = [
56
+ // `자리` is the single most common substitution for a plain noun. Physical
57
+ // seating is the rare literal use, so those modifiers are excluded and
58
+ // everything else is raised for confirmation.
59
+ { re: /(?<!빈 |좌석 |앞 |뒤 |옆 )(?:^|(?<=\s))자리(?:에서|에서는|에|로|가|는|다|입니다|였습니다)?(?=\s|$|[.,·)])/, fix: "'지점'·'상황'·'부분'처럼 뜻을 그대로 담은 명사로 바꿉니다" },
60
+ { re: /[가-힣]+의\s*흐름/, fix: "'방향'·'순서'·'경과'로 바꿉니다" },
61
+ { re: /닿(?:는다|습니다|아|는 지점)/, fix: "'겨냥하다'·'해당하다'처럼 동작을 그대로 서술합니다" },
62
+ { re: /박(?:아 두|혀 있|아 넣)/, fix: "'명시하다'·'기록하다'로 바꿉니다" },
63
+ { re: /걷어내/, fix: "'없애다'·'제거하다'로 바꿉니다" },
64
+ { re: /손대(?:는|지|어)/, fix: "'수정하다'·'변경하다'로 바꿉니다" },
65
+ { re: /발목을 잡/, fix: '무엇이 어떻게 막는지 그대로 서술합니다' },
66
+ { re: /민낯|속살/, fix: "'실제 상태'·'내부 구조'로 바꿉니다" },
67
+ { re: /열쇠(?:다|입니다|가 된다)/, fix: "'핵심 조건'·'결정 요인'으로 바꿉니다" },
68
+ { re: /물꼬|신호탄|분수령|기폭제/, fix: '무엇이 시작되고 무엇이 바뀌는지 그대로 적습니다' },
69
+ { re: /판(?:을|도가|이)\s*(?:흔들|바뀌|뒤집)/, fix: '무엇이 어떻게 달라지는지 그대로 적습니다' },
70
+ { re: /몸집|심장부|두뇌 역할/, fix: "'규모'·'중심 구성 요소'로 바꿉니다" },
71
+ { re: /벽에 부딪|길을 열|문을 열/, fix: '무엇이 막히고 무엇이 가능해지는지 그대로 적습니다' },
72
+ { re: /갈림길|갈리는 지점/, fix: "'결정이 나뉘는 조건'으로 바꿉니다" },
73
+ { re: /깨어나|잠들어 있/, fix: "'동작을 시작하다'·'실행되지 않고 있다'로 바꿉니다" },
74
+ ];
75
+
76
+ // Guidance 3.2/3.3: literal renderings of English noun phrases.
77
+ const TRANSLATIONESE = [
78
+ { re: /에 대한/, fix: '서술어로 풀어 씁니다' },
79
+ { re: /[을를] 위한/, fix: '서술어로 풀어 씁니다' },
80
+ { re: /되어지/, fix: '이중 피동을 없애고 능동이나 단일 피동으로 씁니다' },
81
+ { re: /하는 것을 통해/, fix: "'~해서'·'~함으로써'로 줄입니다" },
82
+ { re: /라고 할 수 있다/, fix: '단정하거나 근거를 붙여 서술합니다' },
83
+ ];
84
+
85
+ // Guidance 3.7: a period belongs after a 종결어미, not after a nominal ending.
86
+ const NOMINAL_ENDING = /(?:음|함|됨|임|점|론|양|성|화)\.$/;
87
+
88
+ /**
89
+ * Which files the checker opens.
90
+ *
91
+ * `prose` follows the guidance's own exemption list: documents only, because
92
+ * code and comments are excluded there. `all` is the scope users asked for
93
+ * once they saw what the narrow reading costs — Korean written into a comment,
94
+ * a UI string, or a template ends up in front of a reader exactly like a
95
+ * document does, and a generated PDF is assembled from those strings. Under
96
+ * `all` the only things skipped are installed dependencies, lockfiles, and
97
+ * files that are not text.
98
+ *
99
+ * When the scope is `all` the injected guidance says so too (see
100
+ * korean-style.js), so the model is told the same rule the checker enforces.
101
+ */
102
+ function isLintTarget(filePath, scope = 'all') {
103
+ if (!filePath) return false;
104
+ if (SKIP_PATH.test(filePath)) return false;
105
+ const dot = filePath.lastIndexOf('.');
106
+ const ext = dot === -1 ? '' : filePath.slice(dot).toLowerCase();
107
+ if (PROSE_EXTENSIONS.has(ext)) return true;
108
+ if (scope !== 'all') return false;
109
+ if (!ext) return false;
110
+ return !BINARY_EXTENSIONS.has(ext);
111
+ }
112
+
113
+ // Kept for callers that only ever meant documents.
114
+ function isProseFile(filePath) {
115
+ return isLintTarget(filePath, 'prose');
116
+ }
117
+
118
+ /** Drop fenced code blocks and inline code so snippets never trip the rules. */
119
+ function stripCode(text) {
120
+ const out = [];
121
+ let fenced = false;
122
+ for (const line of String(text).split('\n')) {
123
+ if (/^\s*(```|~~~)/.test(line)) {
124
+ fenced = !fenced;
125
+ out.push('');
126
+ continue;
127
+ }
128
+ out.push(fenced ? '' : line.replace(/`[^`]*`/g, ' '));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function hasKorean(s) {
134
+ return /[가-힣]/.test(s);
135
+ }
136
+
137
+ /**
138
+ * Lint one document. Returns `[{ line, rule, hit, fix }]`, empty when clean.
139
+ * `lines` are 1-indexed against the original text so the model can jump
140
+ * straight to the offending line.
141
+ */
142
+ function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
143
+ const findings = [];
144
+ const lines = stripCode(text);
145
+
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i];
148
+ if (!hasKorean(line)) continue;
149
+ // Reference lists and link lines are citations, not authored prose.
150
+ if (/^\s*\[\d+\]:/.test(line)) continue;
151
+ // A document that teaches the rules has to spell the banned form out. Its
152
+ // headings and its ✗/○ example pairs are quotations of the rule, not
153
+ // breaches of it, so they are left alone.
154
+ if (/^\s*#/.test(line)) continue;
155
+ if (/[✗○✘❌⭕]/.test(line)) continue;
156
+
157
+ const at = i + 1;
158
+ const push = (rule, hit, fix) => {
159
+ if (findings.length < maxFindings) findings.push({ line: at, rule, hit, fix });
160
+ };
161
+
162
+ // In a source file `|` is an operator, so only the dashes are checked
163
+ // there; in a document a leading `|` is a table row rather than a sentence.
164
+ const sep = line.match(code ? /[—ㅡ]/ : /[—ㅡ|]/);
165
+ if (sep && !(sep[0] === '|' && /^\s*\|/.test(line))) {
166
+ push('구분자', sep[0], '접속사나 쉼표, 가운뎃점(·)으로 바꿉니다');
167
+ }
168
+
169
+ for (const { re, fix } of TRANSLATIONESE) {
170
+ const m = line.match(re);
171
+ if (m) push('번역체', m[0], fix);
172
+ }
173
+
174
+ for (const { re, fix } of METAPHOR_LEXICON) {
175
+ const m = line.match(re);
176
+ if (m) push('비유 어휘', m[0], fix);
177
+ }
178
+
179
+ for (const chunk of line.split(/[.,·\n]/)) {
180
+ const count = (chunk.match(/[가-힣]의(?=\s|[가-힣])/g) || []).length;
181
+ if (count >= 3) {
182
+ push("'의' 반복", chunk.trim().slice(0, 30), '생략된 문장 성분이 없는지 확인합니다');
183
+ break;
184
+ }
185
+ }
186
+
187
+ const trimmed = line.trim();
188
+ if (NOMINAL_ENDING.test(trimmed) && !/^[#>\-*\d]/.test(trimmed)) {
189
+ push('명사형 종결', trimmed.slice(-8), "마침표를 빼거나 '~습니다'로 끝맺습니다");
190
+ }
191
+ }
192
+
193
+ return findings;
194
+ }
195
+
196
+ /** Pull the text a Write/Edit/MultiEdit call just put on disk. */
197
+ function writtenTextOf(toolName, toolInput) {
198
+ if (!toolInput || typeof toolInput !== 'object') return null;
199
+ if (toolName === 'Write') return typeof toolInput.content === 'string' ? toolInput.content : null;
200
+ if (toolName === 'Edit') return typeof toolInput.new_string === 'string' ? toolInput.new_string : null;
201
+ if (toolName === 'MultiEdit' || toolName === 'NotebookEdit') {
202
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
203
+ const joined = edits.map((e) => (e && typeof e.new_string === 'string' ? e.new_string : '')).join('\n');
204
+ return joined || null;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Full check for one PostToolUse payload. Returns null when there is nothing to
211
+ * say, which is the common case and must stay cheap.
212
+ */
213
+ function lintToolUse(context, { scope = 'all' } = {}) {
214
+ if (!context) return null;
215
+ const toolName = context.tool_name;
216
+ const toolInput = context.tool_input;
217
+ const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
218
+ if (!isLintTarget(filePath, scope)) return null;
219
+
220
+ const text = writtenTextOf(toolName, toolInput);
221
+ if (!text || !hasKorean(text)) return null;
222
+
223
+ const findings = lintKoreanText(text, { code: !isProseFile(filePath) });
224
+ if (findings.length === 0) return null;
225
+ return { filePath, findings };
226
+ }
227
+
228
+ /** Render findings as the message handed back to the model. */
229
+ function formatFindings(filePath, findings) {
230
+ const head = `[korean-style] ${filePath} 에 문체 규약 위반 ${findings.length}건이 있습니다. 파일을 고친 뒤 계속하십시오.`;
231
+ const body = findings.map((f) => ` ${f.line}행 ${f.rule}: "${f.hit}" 이 걸렸습니다. ${f.fix}`);
232
+ const tail = ' (원문 인용이거나 이미 굳은 표현이면 그대로 두고, 그 이유를 한 줄로 밝히십시오.)';
233
+ return [head, ...body, tail].join('\n');
234
+ }
235
+
236
+ module.exports = {
237
+ METAPHOR_LEXICON,
238
+ isLintTarget,
239
+ isProseFile,
240
+ lintKoreanText,
241
+ writtenTextOf,
242
+ lintToolUse,
243
+ formatFindings,
244
+ };
@@ -117,6 +117,27 @@ export function koreanStyleText() {
117
117
  * "the user is asking about Korean writing rules" instead of "these rules
118
118
  * govern how I write from now on".
119
119
  */
120
+ // The injected scope sentence and the write-time checker have to agree. When
121
+ // they disagree the model is told one thing and corrected for another, which
122
+ // is how the August 2026 report started: the scope line read as excluding
123
+ // documents, so a session shipped 18 em dashes into a markdown deliverable
124
+ // while the guidance was active. These lines are therefore derived from the
125
+ // same `lintScope` setting the checker reads.
126
+ export function koreanScopeLines(cfg = loadConfig()) {
127
+ const scope = cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all';
128
+ if (scope === 'prose') {
129
+ return [
130
+ '적용 대상: 대화 답변, 그리고 새로 작성하거나 수정하는 마크다운·문서·보고서의 한국어 산문을 모두 포함합니다.',
131
+ '적용 예외: 원문을 그대로 옮기는 인용, 코드, 코드 주석, 그리고 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
132
+ ];
133
+ }
134
+ return [
135
+ '적용 대상: 대화 답변, 그리고 세션이 쓰거나 고치는 모든 파일의 한국어를 포함합니다. 문서와 보고서는 물론이고 코드 주석, 화면에 나가는 문자열, 자막과 템플릿, 스크립트가 읽어 산출물을 만드는 데이터 파일까지 모두 해당합니다.',
136
+ '적용 예외: 원문을 그대로 옮기는 인용과, 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
137
+ '쓰기 시점에 기계 검사가 함께 돌아갑니다. 위반을 알리면 그 파일을 고친 뒤에 다음 작업으로 넘어가십시오.',
138
+ ];
139
+ }
140
+
120
141
  export function koreanStyleInjection({ cfg = loadConfig() } = {}) {
121
142
  if (!koreanStyleEnabled(cfg)) return null;
122
143
  const text = koreanStyleText();
@@ -124,8 +145,7 @@ export function koreanStyleInjection({ cfg = loadConfig() } = {}) {
124
145
  return [
125
146
  '[claude-token-saver korean-style] 이 세션에서 한국어를 출력할 때는 아래 지침을 따르십시오.',
126
147
  '이 지침은 사용자가 claude-token-saver에 설정한 것입니다.',
127
- '적용 대상: 대화 답변, 그리고 새로 작성하거나 수정하는 마크다운·문서·보고서의 한국어 산문을 모두 포함합니다.',
128
- '적용 예외: 원문을 그대로 옮기는 인용, 코드, 코드 주석, 그리고 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
148
+ ...koreanScopeLines(cfg),
129
149
  '표기 규칙(예외 없이 적용): 도구 호출 인자에 한국어를 비롯한 비ASCII 문자열을 담을 때에는 반드시 리터럴 UTF-8로 작성하고, \\uXXXX 유니코드 이스케이프로는 절대 작성하지 마십시오. 이스케이프로 작성하면 글자가 깨진 채 파일에 기록되는 사례가 자주 발생합니다.',
130
150
  `(출처: ${KOREAN_STYLE_SOURCE})`,
131
151
  '',