sprag-cli 3.40.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
@@ -0,0 +1,303 @@
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
+ // HTML is a document a reader receives, so it is checked under BOTH scopes,
39
+ // like prose — but through `stripHtml` first, so tags, comments, and CSS never
40
+ // trip the rules. Script bodies are kept: on a static page the Korean UI copy
41
+ // (i18n dictionaries, template strings) lives exactly there, and that gap is
42
+ // how an em dash shipped to a real landing page unchecked (2026-09-13).
43
+ const HTML_EXTENSIONS = new Set(['.html', '.htm', '.xhtml', '.vue', '.svelte']);
44
+
45
+ // Only two kinds of path are skipped: installed dependencies and VCS
46
+ // internals, neither of which anyone in this session wrote. Build output is
47
+ // deliberately NOT on this list. Generated artifacts are the files a reader
48
+ // actually receives, so exempting `dist/` or `build/` would exempt the very
49
+ // documents the check exists for.
50
+ const SKIP_PATH = /(^|[\\/])(node_modules|\.git|.*\.min\.[a-z]+|.*-lock\.json|.*\.lock)([\\/]|$)/i;
51
+ // This module and the hook copy that embeds it quote every banned form.
52
+ const SELF_PATH = /(^|[\\/])(korean-lint\.cjs|cache-monitor-hook\.cjs)$/i;
53
+ const BINARY_EXTENSIONS = new Set([
54
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar',
55
+ '.mp3', '.mp4', '.wav', '.mov', '.woff', '.woff2', '.ttf', '.otf', '.map', '.bin',
56
+ ]);
57
+
58
+ /**
59
+ * Figurative vocabulary (guidance 3.4). This is the clause human review misses
60
+ * most often, because each individual phrase reads fine in isolation — it is
61
+ * only against the rule that it is standing where a plain noun or verb should.
62
+ * Add a line here when a new one shows up; that is the whole maintenance story.
63
+ */
64
+ const METAPHOR_LEXICON = [
65
+ // `자리` is the single most common substitution for a plain noun. Physical
66
+ // seating is the rare literal use, so those modifiers are excluded and
67
+ // everything else is raised for confirmation.
68
+ { re: /(?<!빈 |좌석 |앞 |뒤 |옆 )(?:^|(?<=\s))자리(?:에서|에서는|에|로|가|는|다|입니다|였습니다)?(?=\s|$|[.,·)])/, fix: "'지점'·'상황'·'부분'처럼 뜻을 그대로 담은 명사로 바꿉니다" },
69
+ { re: /[가-힣]+의\s*흐름/, fix: "'방향'·'순서'·'경과'로 바꿉니다" },
70
+ { re: /닿(?:는다|습니다|아|는 지점)/, fix: "'겨냥하다'·'해당하다'처럼 동작을 그대로 서술합니다" },
71
+ { re: /박(?:아 두|혀 있|아 넣)/, fix: "'명시하다'·'기록하다'로 바꿉니다" },
72
+ { re: /걷어내/, fix: "'없애다'·'제거하다'로 바꿉니다" },
73
+ { re: /손대(?:는|지|어)/, fix: "'수정하다'·'변경하다'로 바꿉니다" },
74
+ { re: /발목을 잡/, fix: '무엇이 어떻게 막는지 그대로 서술합니다' },
75
+ { re: /민낯|속살/, fix: "'실제 상태'·'내부 구조'로 바꿉니다" },
76
+ { re: /열쇠(?:다|입니다|가 된다)/, fix: "'핵심 조건'·'결정 요인'으로 바꿉니다" },
77
+ { re: /물꼬|신호탄|분수령|기폭제/, fix: '무엇이 시작되고 무엇이 바뀌는지 그대로 적습니다' },
78
+ { re: /판(?:을|도가|이)\s*(?:흔들|바뀌|뒤집)/, fix: '무엇이 어떻게 달라지는지 그대로 적습니다' },
79
+ { re: /몸집|심장부|두뇌 역할/, fix: "'규모'·'중심 구성 요소'로 바꿉니다" },
80
+ { re: /벽에 부딪|길을 열|문을 열/, fix: '무엇이 막히고 무엇이 가능해지는지 그대로 적습니다' },
81
+ { re: /갈림길|갈리는 지점/, fix: "'결정이 나뉘는 조건'으로 바꿉니다" },
82
+ { re: /깨어나|잠들어 있/, fix: "'동작을 시작하다'·'실행되지 않고 있다'로 바꿉니다" },
83
+ // Added 2026-09-13. "fails loudly / fails silently" reads as idiom in English
84
+ // but lands as figurative vocabulary in Korean: a failure has no volume.
85
+ // 0 hits across 400 Korean files in yaml-sns-agent, so the confirm-request is
86
+ // not noise.
87
+ {
88
+ re: /(?:시끄럽게|시끄러운|조용히|조용한)\s*[가-힣]*(?:실패|틀리|드러나|어긋)|(?:실패|오류|버그)[가은는이]?\s*(?:시끄럽|시끄러운|조용하|조용한)/,
89
+ fix: "무엇으로 실패를 확인하는지 적습니다. '종료 코드로 바로 드러나는가'·'검증 없이 지나가는가'",
90
+ },
91
+ {
92
+ re: /안전망/,
93
+ fix: "'검증 수단'·'확인 장치'처럼 무엇이 실패를 잡아내는지 그대로 적습니다",
94
+ },
95
+ ];
96
+
97
+ // Guidance 3.2/3.3: literal renderings of English noun phrases.
98
+ const TRANSLATIONESE = [
99
+ { re: /에 대한/, fix: '서술어로 풀어 씁니다' },
100
+ { re: /[을를] 위한/, fix: '서술어로 풀어 씁니다' },
101
+ { re: /되어지/, fix: '이중 피동을 없애고 능동이나 단일 피동으로 씁니다' },
102
+ { re: /하는 것을 통해/, fix: "'~해서'·'~함으로써'로 줄입니다" },
103
+ { re: /라고 할 수 있다/, fix: '단정하거나 근거를 붙여 서술합니다' },
104
+ // Conservative additions (2026-09-13), sourced from 국립국어원 공공언어
105
+ // 지침·한글문화연대 교정 사례·쿠버네티스 한글화 가이드. Bar for inclusion:
106
+ // the form is nearly always an improvement to change, so a confirm-request
107
+ // on it is rarely noise. See presets/korean-style/supplement.md.
108
+ { re: /(?:보여|쓰여|불려|잊혀)[지집진질져]/, fix: "이중 피동입니다. '보인다'·'쓰인'·'불린'·'잊힌'처럼 단일 피동으로 씁니다" },
109
+ { re: /에 다름 아니/, fix: "일본어 번역투입니다. '~일 뿐이다'·'바로 ~이다'로 바꿉니다" },
110
+ { re: /지 않으면 안 [되된됩돼]/, fix: "이중 부정 번역투입니다. '~해야 합니다'로 바꿉니다" },
111
+ { re: /(?<!여기|거기|저기|어디)에 있어서/, fix: "'~에서'·'~에는'으로 바꿉니다" },
112
+ { re: /(?:의미|특징|장점|단점|성격|가능성|중요성|효과)[을를] 가지고 있/, fix: "'~이다'·'~가 있다'로 바꿉니다 (have 직역)" },
113
+ ];
114
+
115
+ // Guidance 3.7: a period belongs after a 종결어미, not after a nominal ending.
116
+ const NOMINAL_ENDING = /(?:음|함|됨|임|점|론|양|성|화)\.$/;
117
+
118
+ /**
119
+ * Which files the checker opens.
120
+ *
121
+ * `prose` follows the guidance's own exemption list: documents only, because
122
+ * code and comments are excluded there. `all` is the scope users asked for
123
+ * once they saw what the narrow reading costs — Korean written into a comment,
124
+ * a UI string, or a template ends up in front of a reader exactly like a
125
+ * document does, and a generated PDF is assembled from those strings. Under
126
+ * `all` the only things skipped are installed dependencies, lockfiles, and
127
+ * files that are not text.
128
+ *
129
+ * When the scope is `all` the injected guidance says so too (see
130
+ * korean-style.js), so the model is told the same rule the checker enforces.
131
+ */
132
+ function isLintTarget(filePath, scope = 'all') {
133
+ if (!filePath) return false;
134
+ if (SKIP_PATH.test(filePath)) return false;
135
+ // The rule table has to spell every banned form out, so this file always
136
+ // matches its own lexicon. Checking it reports the rules, not a breach.
137
+ if (SELF_PATH.test(filePath)) return false;
138
+ const dot = filePath.lastIndexOf('.');
139
+ const ext = dot === -1 ? '' : filePath.slice(dot).toLowerCase();
140
+ if (PROSE_EXTENSIONS.has(ext)) return true;
141
+ if (HTML_EXTENSIONS.has(ext)) return true;
142
+ if (scope !== 'all') return false;
143
+ if (!ext) return false;
144
+ return !BINARY_EXTENSIONS.has(ext);
145
+ }
146
+
147
+ // Kept for callers that only ever meant documents.
148
+ function isProseFile(filePath) {
149
+ return isLintTarget(filePath, 'prose');
150
+ }
151
+
152
+ function isHtmlFile(filePath) {
153
+ const dot = String(filePath).lastIndexOf('.');
154
+ const ext = dot === -1 ? '' : String(filePath).slice(dot).toLowerCase();
155
+ return HTML_EXTENSIONS.has(ext);
156
+ }
157
+
158
+ /**
159
+ * Blank out the parts of an HTML document no reader sees — comments, CSS, and
160
+ * the tags themselves — while keeping every newline, so finding line numbers
161
+ * still point into the original file. Script bodies stay: that is where the
162
+ * page's Korean copy lives on a static site.
163
+ */
164
+ function stripHtml(text) {
165
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
166
+ return String(text)
167
+ .replace(/<!--[\s\S]*?-->/g, blank)
168
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, blank)
169
+ .replace(/<[^>]+>/g, blank);
170
+ }
171
+
172
+ /** Drop fenced code blocks and inline code so snippets never trip the rules. */
173
+ function stripCode(text) {
174
+ const out = [];
175
+ let fenced = false;
176
+ for (const line of String(text).split('\n')) {
177
+ if (/^\s*(```|~~~)/.test(line)) {
178
+ fenced = !fenced;
179
+ out.push('');
180
+ continue;
181
+ }
182
+ out.push(fenced ? '' : line.replace(/`[^`]*`/g, ' '));
183
+ }
184
+ return out;
185
+ }
186
+
187
+ function hasKorean(s) {
188
+ return /[가-힣]/.test(s);
189
+ }
190
+
191
+ /**
192
+ * Lint one document. Returns `[{ line, rule, hit, fix }]`, empty when clean.
193
+ * `lines` are 1-indexed against the original text so the model can jump
194
+ * straight to the offending line.
195
+ */
196
+ function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
197
+ const findings = [];
198
+ const lines = stripCode(text);
199
+
200
+ for (let i = 0; i < lines.length; i++) {
201
+ const line = lines[i];
202
+ if (!hasKorean(line)) continue;
203
+ // Reference lists and link lines are citations, not authored prose.
204
+ if (/^\s*\[\d+\]:/.test(line)) continue;
205
+ // A document that teaches the rules has to spell the banned form out. Its
206
+ // headings and its ✗/○ example pairs are quotations of the rule, not
207
+ // breaches of it, so they are left alone.
208
+ if (/^\s*#/.test(line)) continue;
209
+ if (/[✗○✘❌⭕]/.test(line)) continue;
210
+
211
+ const at = i + 1;
212
+ const push = (rule, hit, fix) => {
213
+ if (findings.length < maxFindings) findings.push({ line: at, rule, hit, fix });
214
+ };
215
+
216
+ // In a source file `|` is an operator, so only the dashes are checked
217
+ // there; in a document a leading `|` is a table row rather than a sentence.
218
+ const sep = line.match(code ? /[—ㅡ]/ : /[—ㅡ|]/);
219
+ if (sep && !(sep[0] === '|' && /^\s*\|/.test(line))) {
220
+ push('구분자', sep[0], '접속사나 쉼표, 가운뎃점(·)으로 바꿉니다');
221
+ }
222
+
223
+ for (const { re, fix } of TRANSLATIONESE) {
224
+ const m = line.match(re);
225
+ if (m) push('번역체', m[0], fix);
226
+ }
227
+
228
+ for (const { re, fix } of METAPHOR_LEXICON) {
229
+ const m = line.match(re);
230
+ if (m) push('비유 어휘', m[0], fix);
231
+ }
232
+
233
+ for (const chunk of line.split(/[.,·\n]/)) {
234
+ const count = (chunk.match(/[가-힣]의(?=\s|[가-힣])/g) || []).length;
235
+ if (count >= 3) {
236
+ push("'의' 반복", chunk.trim().slice(0, 30), '생략된 문장 성분이 없는지 확인합니다');
237
+ break;
238
+ }
239
+ }
240
+
241
+ const trimmed = line.trim();
242
+ if (NOMINAL_ENDING.test(trimmed) && !/^[#>\-*\d]/.test(trimmed)) {
243
+ push('명사형 종결', trimmed.slice(-8), "마침표를 빼거나 '~습니다'로 끝맺습니다");
244
+ }
245
+ }
246
+
247
+ return findings;
248
+ }
249
+
250
+ /** Pull the text a Write/Edit/MultiEdit call just put on disk. */
251
+ function writtenTextOf(toolName, toolInput) {
252
+ if (!toolInput || typeof toolInput !== 'object') return null;
253
+ if (toolName === 'Write') return typeof toolInput.content === 'string' ? toolInput.content : null;
254
+ if (toolName === 'Edit') return typeof toolInput.new_string === 'string' ? toolInput.new_string : null;
255
+ if (toolName === 'MultiEdit' || toolName === 'NotebookEdit') {
256
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
257
+ const joined = edits.map((e) => (e && typeof e.new_string === 'string' ? e.new_string : '')).join('\n');
258
+ return joined || null;
259
+ }
260
+ return null;
261
+ }
262
+
263
+ /**
264
+ * Full check for one PostToolUse payload. Returns null when there is nothing to
265
+ * say, which is the common case and must stay cheap.
266
+ */
267
+ function lintToolUse(context, { scope = 'all' } = {}) {
268
+ if (!context) return null;
269
+ const toolName = context.tool_name;
270
+ const toolInput = context.tool_input;
271
+ const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
272
+ if (!isLintTarget(filePath, scope)) return null;
273
+
274
+ let text = writtenTextOf(toolName, toolInput);
275
+ if (!text || !hasKorean(text)) return null;
276
+
277
+ // Edits arrive as fragments, so tag-stripping only applies when the payload
278
+ // is a whole document; a fragment is still linted, just without stripping.
279
+ if (isHtmlFile(filePath) && toolName === 'Write') text = stripHtml(text);
280
+ const findings = lintKoreanText(text, { code: !isProseFile(filePath) });
281
+ if (findings.length === 0) return null;
282
+ return { filePath, findings };
283
+ }
284
+
285
+ /** Render findings as the message handed back to the model. */
286
+ function formatFindings(filePath, findings) {
287
+ const head = `[korean-style] ${filePath} 에 문체 규약 위반 ${findings.length}건이 있습니다. 파일을 고친 뒤 계속하십시오.`;
288
+ const body = findings.map((f) => ` ${f.line}행 ${f.rule}: "${f.hit}" 이 걸렸습니다. ${f.fix}`);
289
+ const tail = ' (원문 인용이거나 이미 굳은 표현이면 그대로 두고, 그 이유를 한 줄로 밝히십시오.)';
290
+ return [head, ...body, tail].join('\n');
291
+ }
292
+
293
+ module.exports = {
294
+ METAPHOR_LEXICON,
295
+ isLintTarget,
296
+ isProseFile,
297
+ isHtmlFile,
298
+ stripHtml,
299
+ lintKoreanText,
300
+ writtenTextOf,
301
+ lintToolUse,
302
+ formatFindings,
303
+ };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * korean-style — inject Korean writing guidance into every session.
3
+ *
4
+ * Claude Code's own mechanism for this is an output style, which is a global
5
+ * switch: turning it on replaces whatever style the user had, and it only
6
+ * applies where the user remembered to configure it. Projects opened on a
7
+ * different machine, or by a teammate, get nothing.
8
+ *
9
+ * This module carries the guidance inside the package instead and hands it to
10
+ * the model through the SessionStart hook claude-token-saver already installs.
11
+ * The rules then apply in every project on the machine, with no output-style
12
+ * change and no plugin to install, and they survive `/clear` because the hook
13
+ * fires again.
14
+ *
15
+ * Cost: the text is ~1.3k tokens, injected once per session (not per turn) and
16
+ * covered by the prompt cache from the second request on. A token-saving tool
17
+ * has no business spending that silently, so the feature is opt-in via
18
+ * `claude-token-saver korean on`.
19
+ *
20
+ * The guidance itself is vendored from fluent-korean
21
+ * (https://github.com/snflkd/fluent-korean), Copyright (c) 2026 snflkd, MIT
22
+ * License — see presets/korean-style/LICENSE-fluent-korean. Only the
23
+ * output-style frontmatter was stripped; the wording is unmodified.
24
+ */
25
+
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { createRequire } from 'node:module';
30
+ import { loadConfig, saveConfig } from './config.js';
31
+
32
+ const require = createRequire(import.meta.url);
33
+
34
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
35
+
36
+ export const KOREAN_STYLE_PATH = join(packageRoot, 'presets', 'korean-style', 'fluent-korean.md');
37
+ // Our own conservative additions (국립국어원 공공언어 지침, 쿠버네티스 한글화
38
+ // 가이드 등에서 수집). Appended after the vendored text so the vendored file
39
+ // stays byte-identical to upstream.
40
+ export const KOREAN_STYLE_SUPPLEMENT_PATH = join(packageRoot, 'presets', 'korean-style', 'supplement.md');
41
+ export const KOREAN_STYLE_LICENSE_PATH = join(packageRoot, 'presets', 'korean-style', 'LICENSE-fluent-korean');
42
+ // The separator here is a colon, not an em dash. The guidance this line cites
43
+ // bans em dashes in Korean prose, and shipping one inside its own attribution
44
+ // is the kind of contradiction that teaches the model the rule is negotiable.
45
+ export const KOREAN_STYLE_SOURCE = 'fluent-korean by snflkd (MIT): https://github.com/snflkd/fluent-korean';
46
+
47
+ /** Whether session-start injection is enabled. Off unless the user asked. */
48
+ export function koreanStyleEnabled(cfg = loadConfig()) {
49
+ return cfg?.koreanStyle?.enabled === true;
50
+ }
51
+
52
+ /** True once the user has turned the feature on or off explicitly. */
53
+ export function koreanStyleDecided(cfg = loadConfig()) {
54
+ return typeof cfg?.koreanStyle?.enabled === 'boolean';
55
+ }
56
+
57
+ /**
58
+ * Whether this machine looks like it writes Korean.
59
+ *
60
+ * Used only to decide the DEFAULT at install time. Turning the guidance on for
61
+ * everyone would bill ~1.5k tokens per session to users who never write a
62
+ * Korean sentence; leaving it off for everyone means the people who need it
63
+ * have to discover a command that exists for exactly them. Locale answers the
64
+ * question well enough, and the user can override either way afterwards.
65
+ *
66
+ * Signals, cheapest first: the tool's own language setting, then the POSIX
67
+ * locale variables, then (macOS only, where those are routinely unset) the
68
+ * system locale.
69
+ */
70
+ export function koreanLocaleDetected({ env = process.env, platform = process.platform } = {}) {
71
+ try {
72
+ if (loadConfig().language === 'ko') return true;
73
+ } catch { /* unreadable config falls through to the env checks */ }
74
+ const posix = [env.LC_ALL, env.LC_MESSAGES, env.LANG, env.LANGUAGE].filter(
75
+ (v) => typeof v === 'string' && v.trim() && v !== 'C' && v !== 'POSIX',
76
+ );
77
+ for (const v of posix) {
78
+ // `ko` must be a whole subtag: `ko`, `ko_KR.UTF-8`, `ko-KR`, and the
79
+ // colon-separated `LANGUAGE=ko:en` all count, while `kok` (Konkani) and
80
+ // `tok` do not.
81
+ if (/(^|[:._-])ko([:._-]|$)/i.test(v)) return true;
82
+ }
83
+ // A locale variable that is set and is NOT Korean is an answer, not a missing
84
+ // signal: `LANG=en_US.UTF-8` on a machine whose system locale is Korean means
85
+ // the user chose English for their shell. Only consult the system locale when
86
+ // the POSIX variables say nothing at all, which is the macOS GUI-shell case
87
+ // this fallback exists for.
88
+ if (platform === 'darwin' && posix.length === 0) {
89
+ try {
90
+ // `LANG` is commonly unset in macOS GUI-launched shells, so the system
91
+ // locale is the only reliable signal there.
92
+ const { execFileSync } = require('node:child_process');
93
+ const out = execFileSync('defaults', ['read', '-g', 'AppleLocale'], {
94
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
95
+ }).trim();
96
+ if (/^ko(_|-|$)/i.test(out)) return true;
97
+ } catch { /* `defaults` missing or slow — treat as "not detected" */ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ export function setKoreanStyleEnabled(enabled) {
103
+ const cfg = loadConfig();
104
+ cfg.koreanStyle = { ...(cfg.koreanStyle || {}), enabled: !!enabled };
105
+ saveConfig(cfg);
106
+ return cfg.koreanStyle;
107
+ }
108
+
109
+ /**
110
+ * The guidance text, with the vendoring comment stripped (it is provenance for
111
+ * readers of the repo, not instruction for the model — and every token of it
112
+ * would be charged on each session).
113
+ *
114
+ * Returns null when the file is missing, which the hook reads as "inject
115
+ * nothing" rather than failing a session start.
116
+ */
117
+ export function koreanStyleText() {
118
+ try {
119
+ if (!existsSync(KOREAN_STYLE_PATH)) return null;
120
+ const raw = readFileSync(KOREAN_STYLE_PATH, 'utf8');
121
+ const body = raw.replace(/^<!--[\s\S]*?-->\s*/, '').trim();
122
+ if (!body) return null;
123
+ // The supplement is optional: a missing or empty file degrades to the
124
+ // vendored guidance alone rather than failing the injection.
125
+ try {
126
+ if (existsSync(KOREAN_STYLE_SUPPLEMENT_PATH)) {
127
+ const sup = readFileSync(KOREAN_STYLE_SUPPLEMENT_PATH, 'utf8')
128
+ .replace(/^<!--[\s\S]*?-->\s*/, '')
129
+ .trim();
130
+ if (sup) return `${body}\n\n${sup}`;
131
+ }
132
+ } catch { /* supplement unreadable: fall back to the vendored text */ }
133
+ return body;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Block to inject at session start, or null when disabled/unavailable.
141
+ *
142
+ * The framing line matters: without it the model can read the guidance as
143
+ * "the user is asking about Korean writing rules" instead of "these rules
144
+ * govern how I write from now on".
145
+ */
146
+ // The injected scope sentence and the write-time checker have to agree. When
147
+ // they disagree the model is told one thing and corrected for another, which
148
+ // is how the August 2026 report started: the scope line read as excluding
149
+ // documents, so a session shipped 18 em dashes into a markdown deliverable
150
+ // while the guidance was active. These lines are therefore derived from the
151
+ // same `lintScope` setting the checker reads.
152
+ export function koreanScopeLines(cfg = loadConfig()) {
153
+ const scope = cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all';
154
+ if (scope === 'prose') {
155
+ return [
156
+ '적용 대상: 대화 답변, 그리고 새로 작성하거나 수정하는 마크다운·문서·보고서의 한국어 산문을 모두 포함합니다.',
157
+ '적용 예외: 원문을 그대로 옮기는 인용, 코드, 코드 주석, 그리고 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
158
+ ];
159
+ }
160
+ return [
161
+ '적용 대상: 대화 답변, 그리고 세션이 쓰거나 고치는 모든 파일의 한국어를 포함합니다. 문서와 보고서는 물론이고 코드 주석, 화면에 나가는 문자열, 자막과 템플릿, 스크립트가 읽어 산출물을 만드는 데이터 파일까지 모두 해당합니다.',
162
+ '적용 예외: 원문을 그대로 옮기는 인용과, 프로젝트의 기존 표기 관례를 따라야 하는 커밋 메시지와 로그 문자열입니다.',
163
+ // The vendored text says the opposite of the line above, and it says so
164
+ // twice. Left unaddressed the model has no way to decide which sentence
165
+ // governs a code comment, and in September 2026 that ambiguity shipped em
166
+ // dashes into comments and log strings while the guidance was active. The
167
+ // vendored wording stays untouched, so the conflict is resolved by naming
168
+ // which side wins rather than by editing the source.
169
+ '아래에 이어지는 fluent-korean 본문은 "인용, 코드, 코드 주석에는 이 지침들을 적용하지 않습니다"라고 적고 있으며 동작 범위 2번 조항에서 한 번 더 강조합니다. 그러나 이 세션에서는 바로 위의 적용 대상과 적용 예외가 그 문장보다 우선합니다. 즉 코드 주석에도 지침을 적용하십시오.',
170
+ '쓰기 시점에 기계 검사가 함께 돌아갑니다. 위반을 알리면 그 파일을 고친 뒤에 다음 작업으로 넘어가십시오.',
171
+ ];
172
+ }
173
+
174
+ export function koreanStyleInjection({ cfg = loadConfig() } = {}) {
175
+ if (!koreanStyleEnabled(cfg)) return null;
176
+ const text = koreanStyleText();
177
+ if (!text) return null;
178
+ return [
179
+ '[claude-token-saver korean-style] 이 세션에서 한국어를 출력할 때는 아래 지침을 따르십시오.',
180
+ '이 지침은 사용자가 claude-token-saver에 설정한 것입니다.',
181
+ ...koreanScopeLines(cfg),
182
+ '표기 규칙(예외 없이 적용): 도구 호출 인자에 한국어를 비롯한 비ASCII 문자열을 담을 때에는 반드시 리터럴 UTF-8로 작성하고, \\uXXXX 유니코드 이스케이프로는 절대 작성하지 마십시오. 이스케이프로 작성하면 글자가 깨진 채 파일에 기록되는 사례가 자주 발생합니다.',
183
+ `(출처: ${KOREAN_STYLE_SOURCE})`,
184
+ '',
185
+ text,
186
+ ].join('\n');
187
+ }