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.
- package/README.en.md +46 -1
- package/README.md +46 -1
- package/bin/cli.js +51 -1
- package/package.json +1 -1
- package/src/commands/korean.js +145 -2
- package/src/commands/route-scan.js +48 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/formatters/statusline.js +42 -2
- package/src/installer.js +62 -0
- package/src/korean-lint.cjs +244 -0
- package/src/korean-style.js +22 -2
- package/src/update-check.js +198 -0
|
@@ -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
|
+
};
|
package/src/korean-style.js
CHANGED
|
@@ -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
|
'',
|