claude-token-saver 3.42.6 → 3.42.8
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.ko.md +5 -1
- package/README.md +17 -2
- package/package.json +1 -1
- package/presets/korean-style/supplement.md +4 -0
- package/presets/ratchet-rules.json +4 -0
- package/src/commands/install.js +14 -0
- package/src/installer.js +19 -3
- package/src/korean-lint.cjs +157 -1
package/README.ko.md
CHANGED
|
@@ -458,7 +458,11 @@ sprag korean lint scope prose # 마크다운·텍스트 문서만
|
|
|
458
458
|
sprag korean lint docs/*.md # 이미 저장된 파일을 직접 검사
|
|
459
459
|
```
|
|
460
460
|
|
|
461
|
-
검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의
|
|
461
|
+
검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의 마침표, 그리고 v3.42.7부터는 이어지는 두 문장이 똑같은 서술어로 끝나는 종결 반복입니다. 판단이 필요한 조항(성분 생략, 한자어 선택, 비슷하지만 똑같지는 않은 반복)은 그대로 지침이 담당합니다.
|
|
462
|
+
|
|
463
|
+
검사 대상에는 Bash로 쓴 파일도 들어갑니다(v3.42.7). heredoc과 `tee`, `sed -i`는 Write와 똑같이 한국어를 디스크에 남기고, Write 도구가 없는 하위 에이전트는 `cat > 파일`부터 집습니다. 그래서 Bash를 빼 두면 위임해서 만든 결과물 전체가 검사를 비켜 갔습니다. 리다이렉션 대상 경로만 인식하고 명령문 자체는 읽지 않으므로, `echo`에 담긴 한국어가 잘못 걸리는 일은 없습니다.
|
|
464
|
+
|
|
465
|
+
검사기를 시험하는 파일이나 문체 보고서처럼 금지된 표현을 일부러 담아야 하는 파일에는 `korean-lint: off` 한 줄을 넣으면 그 파일을 건너뜁니다.
|
|
462
466
|
|
|
463
467
|
기본 범위 `all`은 문서뿐 아니라 **코드 주석과 화면에 나가는 문자열, 자막·템플릿, 생성 결과물까지** 검사합니다. 벤더링한 지침 원문은 코드 주석을 예외로 두지만, 주석도 사람이 읽고 PDF·HTML 같은 산출물은 그 문자열들로 조립되기 때문에 예외로 두면 정확히 문제가 됐던 경로가 다시 열립니다. 검사에서 빠지는 것은 설치된 의존성(`node_modules`), VCS 내부, 락 파일, 그리고 이진·이미지 파일뿐입니다. `dist`나 `build` 같은 산출물 디렉터리는 검사합니다. 원문 규약대로 문서만 보고 싶으면 `korean lint scope prose`로 되돌립니다.
|
|
464
468
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
> **`claude-token-saver` is the old package name.**
|
|
2
|
+
> Install `sprag-cli` instead — same tool, same binaries, same releases:
|
|
3
|
+
> `npm i -g sprag-cli`
|
|
4
|
+
> (switching needs a swap: `npm uninstall -g claude-token-saver && npm i -g sprag-cli`)
|
|
5
|
+
|
|
1
6
|
<div align="center">
|
|
2
7
|
|
|
3
8
|
<picture>
|
|
@@ -18,6 +23,8 @@
|
|
|
18
23
|
---
|
|
19
24
|
|
|
20
25
|
<!-- ko:begin (generated from README.ko.md — edit that file, then run npm run sync:ko) -->
|
|
26
|
+
**🇰🇷 한국어 문서: [README.ko.md](https://github.com/rootstudioyaml/sprag/blob/main/README.ko.md)** (아래 토글로도 펼칠 수 있습니다.)
|
|
27
|
+
|
|
21
28
|
<details>
|
|
22
29
|
<summary><strong>🇰🇷 한국어로 보기 (클릭하면 한국어 전문이 펼쳐집니다)</strong></summary>
|
|
23
30
|
|
|
@@ -462,7 +469,11 @@ sprag korean lint scope prose # 마크다운·텍스트 문서만
|
|
|
462
469
|
sprag korean lint docs/*.md # 이미 저장된 파일을 직접 검사
|
|
463
470
|
```
|
|
464
471
|
|
|
465
|
-
검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의
|
|
472
|
+
검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의 마침표, 그리고 v3.42.7부터는 이어지는 두 문장이 똑같은 서술어로 끝나는 종결 반복입니다. 판단이 필요한 조항(성분 생략, 한자어 선택, 비슷하지만 똑같지는 않은 반복)은 그대로 지침이 담당합니다.
|
|
473
|
+
|
|
474
|
+
검사 대상에는 Bash로 쓴 파일도 들어갑니다(v3.42.7). heredoc과 `tee`, `sed -i`는 Write와 똑같이 한국어를 디스크에 남기고, Write 도구가 없는 하위 에이전트는 `cat > 파일`부터 집습니다. 그래서 Bash를 빼 두면 위임해서 만든 결과물 전체가 검사를 비켜 갔습니다. 리다이렉션 대상 경로만 인식하고 명령문 자체는 읽지 않으므로, `echo`에 담긴 한국어가 잘못 걸리는 일은 없습니다.
|
|
475
|
+
|
|
476
|
+
검사기를 시험하는 파일이나 문체 보고서처럼 금지된 표현을 일부러 담아야 하는 파일에는 `korean-lint: off` 한 줄을 넣으면 그 파일을 건너뜁니다.
|
|
466
477
|
|
|
467
478
|
기본 범위 `all`은 문서뿐 아니라 **코드 주석과 화면에 나가는 문자열, 자막·템플릿, 생성 결과물까지** 검사합니다. 벤더링한 지침 원문은 코드 주석을 예외로 두지만, 주석도 사람이 읽고 PDF·HTML 같은 산출물은 그 문자열들로 조립되기 때문에 예외로 두면 정확히 문제가 됐던 경로가 다시 열립니다. 검사에서 빠지는 것은 설치된 의존성(`node_modules`), VCS 내부, 락 파일, 그리고 이진·이미지 파일뿐입니다. `dist`나 `build` 같은 산출물 디렉터리는 검사합니다. 원문 규약대로 문서만 보고 싶으면 `korean lint scope prose`로 되돌립니다.
|
|
468
479
|
|
|
@@ -1123,7 +1134,11 @@ sprag korean lint scope prose # documents only
|
|
|
1123
1134
|
sprag korean lint docs/*.md # check files already on disk
|
|
1124
1135
|
```
|
|
1125
1136
|
|
|
1126
|
-
Checked: 15 figurative phrases, translationese markers, separators (`—`·`ㅡ`·`|`), three or more `의` particles in one phrase,
|
|
1137
|
+
Checked: 15 figurative phrases, translationese markers, separators (`—`·`ㅡ`·`|`), three or more `의` particles in one phrase, a period after a nominal ending, and since v3.42.7 two consecutive sentences closing on the identical predicate. Clauses that genuinely need judgement — dropped sentence elements, word choice, near-miss repetition — stay with the guidance text.
|
|
1138
|
+
|
|
1139
|
+
Bash writes are checked too (v3.42.7). A heredoc, a `tee`, or a `sed -i` puts Korean on disk exactly like Write does, and a subagent handed Bash but not Write reaches for `cat > file` first — so leaving Bash off the matcher exempted every artifact produced by delegated work. Only redirection targets are recognised, never the command body, so Korean inside an `echo` is not mistaken for prose.
|
|
1140
|
+
|
|
1141
|
+
A file that must quote the banned forms — a fixture for this checker, a style report — opts out with one `korean-lint: off` line anywhere in it.
|
|
1127
1142
|
|
|
1128
1143
|
The default `all` scope covers code comments, UI strings, subtitles, templates, and build output, not just documents. The vendored guidance exempts comments, but comments are read by people and generated artifacts (PDF, HTML) are assembled from those strings, so exempting them reopens the exact gap that was reported. Only installed dependencies, VCS internals, lockfiles, and binary or image files are skipped; `dist/` and `build/` are checked. `korean lint scope prose` restores the narrow reading.
|
|
1129
1144
|
|
package/package.json
CHANGED
|
@@ -83,6 +83,10 @@ LLM 산출물에서 반복적으로 관찰되는 습관입니다. 금지가 아
|
|
|
83
83
|
- ✗ 토끼와 거북이가 경주를 했다. 토끼는 잠을 잤다. 거북이가 이겼다.
|
|
84
84
|
- ○ 잠을 잔 토끼와 달리, 거북이는 꾸준히 걸어 경주에서 이겼다.
|
|
85
85
|
|
|
86
|
+
7. 이어지는 두 문장을 같은 서술어로 끝내지 않습니다. 쓰기 시점 검사가 동일한 종결 어절만 '종결 반복'으로 잡아 주므로, 비슷하되 똑같지는 않은 반복은 직접 살펴야 합니다.
|
|
87
|
+
- ✗ 역회전하는 순간 잠급니다. 에이전트는 그대로 일하고, 퇴행만 잠급니다.
|
|
88
|
+
- ○ 역회전하는 순간 잠기는 부품입니다. 에이전트도 같아서, 하던 일은 그대로 진행되고 퇴행만 막힙니다.
|
|
89
|
+
|
|
86
90
|
### 과교정 금지 조항
|
|
87
91
|
|
|
88
92
|
교정 자체가 오류가 되는 경우입니다. 나무위키 번역체 문서와 국립국어원 논의가 공통으로 경고하는 지점이므로, 아래 표현에는 위 조항들을 적용하지 않습니다.
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
{
|
|
34
34
|
"ko": "탐색·조회·상태 확인·명령 실행 같은 단순 요청은 `model: haiku` 서브에이전트로 위임해 상위 모델 토큰을 아낀다 (읽기 전용 preset 에이전트가 이미 있으면 그걸 쓰고, 없으면 티어만 내려서 위임)",
|
|
35
35
|
"en": "Delegate simple requests — lookup, status checks, running commands — to a `model: haiku` subagent to keep the expensive model's tokens for real work (use an existing read-only preset agent when there is one; otherwise delegate with the tier lowered)"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"ko": "지침이나 룰이 지켜지지 않아 같은 품질 문제가 재발하면 문구를 다시 쓰지 말 것 → 그 조항이 기계로 판정 가능한지부터 따지고(문자열 비교나 정규식으로 결정되면 판정 가능), 가능하면 쓰기 시점 훅이나 테스트 같은 차단 지점을 추가한다. 판단이 필요한 잔여 부분만 지침에 남기고 그 경계를 지침에 적는다. 실제 코퍼스에 돌려 오탐 건수를 센 뒤 좁히고, 회귀 테스트를 같은 커밋에 넣는다",
|
|
39
|
+
"en": "When a guideline keeps being violated and the same quality defect recurs, do NOT reword the instruction → first ask whether the clause is machine-decidable (a string comparison or regex settles it), and if so add an enforcement point such as a write-time hook or a test. Leave only the judgement remainder in the prose and state that boundary there. Measure the false-positive count on a real corpus, narrow until it is acceptable, and land a regression test in the same commit"
|
|
36
40
|
}
|
|
37
41
|
]
|
|
38
42
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -228,6 +228,20 @@ export async function run({ hasFlag }) {
|
|
|
228
228
|
console.log(lang === 'ko'
|
|
229
229
|
? ` korean: 기존 설정 유지 — 한국어 문체 지침 ${ks.koreanStyleEnabled() ? '켜짐' : '꺼짐'}`
|
|
230
230
|
: ` korean: keeping your setting — Korean writing guidance is ${ks.koreanStyleEnabled() ? 'on' : 'off'}`);
|
|
231
|
+
// The answer is kept, but the hook registration behind it still has to
|
|
232
|
+
// reach the current shape. A machine that enabled the guidance before
|
|
233
|
+
// Bash joined the matcher would otherwise keep the narrow entry for
|
|
234
|
+
// good, and upgrading would silently leave shell-written files
|
|
235
|
+
// unchecked. installKoreanLintHook widens in place and is idempotent.
|
|
236
|
+
if (ks.koreanStyleEnabled()) {
|
|
237
|
+
const { installKoreanLintHook } = await import('../installer.js');
|
|
238
|
+
const migrated = installKoreanLintHook();
|
|
239
|
+
if (migrated.action === 'updated') {
|
|
240
|
+
console.log(lang === 'ko'
|
|
241
|
+
? ' 검사 범위 갱신: Bash 로 쓴 파일도 이제 확인합니다'
|
|
242
|
+
: ' matcher widened: files written through Bash are checked now');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
231
245
|
} else {
|
|
232
246
|
// The locale only decides what the question defaults to. It is a good
|
|
233
247
|
// guess, not an answer, so a user at a terminal gets to overrule it
|
package/src/installer.js
CHANGED
|
@@ -306,6 +306,11 @@ export function installBriefHook() {
|
|
|
306
306
|
// gap: it runs on the file the model just wrote, while it can still fix it.
|
|
307
307
|
// Installed by `korean on`, removed by `korean off`. Idempotent.
|
|
308
308
|
const KOREAN_LINT_HOOK_COMMAND = 'claude-token-saver korean --hook';
|
|
309
|
+
// Bash belongs on this matcher because a heredoc, a `tee`, or a `sed -i` puts
|
|
310
|
+
// Korean on disk exactly like Write does. A subagent without the Write tool
|
|
311
|
+
// reaches for `cat > file` first, so leaving Bash off the list exempted every
|
|
312
|
+
// file those agents produce (found 2026-09-14 by probing a haiku subagent).
|
|
313
|
+
const KOREAN_LINT_MATCHER = 'Write|Edit|MultiEdit|Bash';
|
|
309
314
|
|
|
310
315
|
export function installKoreanLintHook() {
|
|
311
316
|
const dir = claudeUserDir();
|
|
@@ -326,13 +331,24 @@ export function installKoreanLintHook() {
|
|
|
326
331
|
return { path: file, action: 'skipped', reason: 'hooks.PostToolUse is not an array — fix settings.json manually' };
|
|
327
332
|
}
|
|
328
333
|
const list = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
|
|
329
|
-
const
|
|
334
|
+
const existing = list.find((m) =>
|
|
330
335
|
Array.isArray(m?.hooks) && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('korean --hook')),
|
|
331
336
|
);
|
|
332
|
-
if (
|
|
337
|
+
if (existing) {
|
|
338
|
+
// An install from before Bash joined the matcher leaves the narrow entry in
|
|
339
|
+
// place forever, so upgrading a machine would not close the gap. Widen it
|
|
340
|
+
// here; anything the user hand-edited to something wider is left alone.
|
|
341
|
+
if (existing.matcher === 'Write|Edit|MultiEdit') {
|
|
342
|
+
existing.matcher = KOREAN_LINT_MATCHER;
|
|
343
|
+
settings.hooks.PostToolUse = list;
|
|
344
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
345
|
+
return { path: file, action: 'updated' };
|
|
346
|
+
}
|
|
347
|
+
return { path: file, action: 'exists' };
|
|
348
|
+
}
|
|
333
349
|
|
|
334
350
|
list.push({
|
|
335
|
-
matcher:
|
|
351
|
+
matcher: KOREAN_LINT_MATCHER,
|
|
336
352
|
hooks: [{ type: 'command', command: KOREAN_LINT_HOOK_COMMAND, timeout: 10 }],
|
|
337
353
|
});
|
|
338
354
|
settings.hooks.PostToolUse = list;
|
package/src/korean-lint.cjs
CHANGED
|
@@ -49,7 +49,15 @@ const HTML_EXTENSIONS = new Set(['.html', '.htm', '.xhtml', '.vue', '.svelte']);
|
|
|
49
49
|
// documents the check exists for.
|
|
50
50
|
const SKIP_PATH = /(^|[\\/])(node_modules|\.git|.*\.min\.[a-z]+|.*-lock\.json|.*\.lock)([\\/]|$)/i;
|
|
51
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;
|
|
52
|
+
const SELF_PATH = /(^|[\\/])(korean-lint\.cjs|cache-monitor-hook\.cjs|korean-lint\.test\.js)$/i;
|
|
53
|
+
|
|
54
|
+
// Opt-out marker. A file that exists to exercise the checker has to contain the
|
|
55
|
+
// forms the checker bans, and so does a document quoting a style report. Before
|
|
56
|
+
// Bash joined the matcher those files slipped through by accident; now that
|
|
57
|
+
// every write route is covered, the exemption has to be something the author
|
|
58
|
+
// states on purpose rather than something the tool guesses. One line anywhere
|
|
59
|
+
// in the file turns the check off for that file.
|
|
60
|
+
const OPT_OUT_MARKER = /korean-lint:\s*(?:off|ignore-file)/i;
|
|
53
61
|
const BINARY_EXTENSIONS = new Set([
|
|
54
62
|
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.pdf', '.zip', '.gz', '.tar',
|
|
55
63
|
'.mp3', '.mp4', '.wav', '.mov', '.woff', '.woff2', '.ttf', '.otf', '.map', '.bin',
|
|
@@ -115,6 +123,15 @@ const TRANSLATIONESE = [
|
|
|
115
123
|
// Guidance 3.7: a period belongs after a 종결어미, not after a nominal ending.
|
|
116
124
|
const NOMINAL_ENDING = /(?:음|함|됨|임|점|론|양|성|화)\.$/;
|
|
117
125
|
|
|
126
|
+
// Sentence closers with no lexical content of their own: existence, negation,
|
|
127
|
+
// the copula, and the do-verb. Korean puts these at the end of most sentences,
|
|
128
|
+
// so repeating one across two sentences is normal writing. Only content verbs
|
|
129
|
+
// repeating is the style problem the cohesion clause means.
|
|
130
|
+
const AUXILIARY_ENDING = /^(?:있|없|않|아니|아닙|같|이|뿐이|때문이|것이|거|합|한|했|됩|된|됐|입|였|이었)/;
|
|
131
|
+
// The copula and the do-verb also attach to a preceding noun, so they have to
|
|
132
|
+
// be matched at the tail as well: `수치입니다`, `설명합니다`, `반영됩니다`.
|
|
133
|
+
const AUXILIARY_TAIL = /(?:입니다|입니다만|이다|합니다|한다|됩니다|된다|있습니다|없습니다|않습니다|않는다)$/;
|
|
134
|
+
|
|
118
135
|
/**
|
|
119
136
|
* Which files the checker opens.
|
|
120
137
|
*
|
|
@@ -195,6 +212,7 @@ function hasKorean(s) {
|
|
|
195
212
|
*/
|
|
196
213
|
function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
|
|
197
214
|
const findings = [];
|
|
215
|
+
if (OPT_OUT_MARKER.test(text)) return findings;
|
|
198
216
|
const lines = stripCode(text);
|
|
199
217
|
|
|
200
218
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -244,9 +262,80 @@ function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
|
|
|
244
262
|
}
|
|
245
263
|
}
|
|
246
264
|
|
|
265
|
+
findings.push(...repeatedEndings(lines, maxFindings - findings.length));
|
|
266
|
+
|
|
247
267
|
return findings;
|
|
248
268
|
}
|
|
249
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Adjacent sentences closing on the same predicate.
|
|
272
|
+
*
|
|
273
|
+
* This is a cohesion clause, and cohesion was written off as "needs judgement,
|
|
274
|
+
* so the guidance text has to carry it". That reading cost us the September
|
|
275
|
+
* 2026 report: two sentences in a row ended in `잠급니다`, the machine layer
|
|
276
|
+
* passed the file, and a human caught it on the published page. Identical
|
|
277
|
+
* final 어절 in consecutive sentences needs no judgement at all — it is a
|
|
278
|
+
* string comparison — so it belongs here, where it blocks, rather than in an
|
|
279
|
+
* instruction nobody re-reads.
|
|
280
|
+
*
|
|
281
|
+
* Deliberately narrow to keep the false-positive rate at zero on our own
|
|
282
|
+
* corpus: the two 어절 must match exactly, run at least three characters, and
|
|
283
|
+
* close a sentence. Near-misses (`막습니다` after `잠급니다`) are a judgement
|
|
284
|
+
* call and stay with the cohesion review.
|
|
285
|
+
*/
|
|
286
|
+
function repeatedEndings(lines, budget) {
|
|
287
|
+
if (budget <= 0) return [];
|
|
288
|
+
const out = [];
|
|
289
|
+
// Sentences are collected with the line they end on, so a finding still
|
|
290
|
+
// points the model at a line it can jump to.
|
|
291
|
+
const sentences = [];
|
|
292
|
+
let buf = '';
|
|
293
|
+
let startLine = 1;
|
|
294
|
+
for (let i = 0; i < lines.length; i++) {
|
|
295
|
+
const raw = lines[i];
|
|
296
|
+
// Headings, list markers and table rows are labels, not flowing prose.
|
|
297
|
+
if (/^\s*(?:#|\||[-*+]\s|\d+\.\s|>)/.test(raw)) { buf = ''; continue; }
|
|
298
|
+
// Structured data (YAML keys, JSON fields, front matter) holds independent
|
|
299
|
+
// values that happen to sit on neighbouring lines. Joining them invents
|
|
300
|
+
// sentence adjacency that no reader ever experiences.
|
|
301
|
+
if (/^\s*["'\w.-]+\s*:\s/.test(raw) || /^\s*---\s*$/.test(raw)) { buf = ''; continue; }
|
|
302
|
+
if (!buf) startLine = i + 1;
|
|
303
|
+
buf += (buf ? ' ' : '') + raw.trim();
|
|
304
|
+
const parts = buf.split(/(?<=[.!?])\s+/);
|
|
305
|
+
buf = /[.!?]\s*$/.test(raw.trim()) ? '' : parts.pop() || '';
|
|
306
|
+
for (const p of parts) sentences.push({ text: p, line: i + 1, startLine });
|
|
307
|
+
if (!buf) startLine = i + 2;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tailOf = (s) => {
|
|
311
|
+
const m = String(s).trim().replace(/[.!?]+$/, '').match(/([가-힣]+)$/);
|
|
312
|
+
const t = m ? m[1] : '';
|
|
313
|
+
// Only verb/adjective endings count; a sentence closing on a noun is
|
|
314
|
+
// already covered by the nominal-ending rule.
|
|
315
|
+
if (!/다$/.test(t) || t.length < 3) return '';
|
|
316
|
+
// Existential, negative and copular closers carry no lexical content, so
|
|
317
|
+
// two of them in a row is ordinary Korean, not a repetition the writer
|
|
318
|
+
// should fix. Measured on 272 Korean files: without this stoplist the rule
|
|
319
|
+
// fires 74 times and nearly all of it is this class.
|
|
320
|
+
if (AUXILIARY_ENDING.test(t) || AUXILIARY_TAIL.test(t)) return '';
|
|
321
|
+
return t;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
for (let i = 1; i < sentences.length && out.length < budget; i++) {
|
|
325
|
+
const a = tailOf(sentences[i - 1].text);
|
|
326
|
+
const b = tailOf(sentences[i].text);
|
|
327
|
+
if (a && a === b) {
|
|
328
|
+
out.push({
|
|
329
|
+
line: sentences[i].line,
|
|
330
|
+
rule: '종결 반복',
|
|
331
|
+
hit: a,
|
|
332
|
+
fix: '앞 문장과 같은 서술어로 끝납니다. 한쪽을 다른 서술어로 바꾸거나 두 문장을 합칩니다',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
250
339
|
/** Pull the text a Write/Edit/MultiEdit call just put on disk. */
|
|
251
340
|
function writtenTextOf(toolName, toolInput) {
|
|
252
341
|
if (!toolInput || typeof toolInput !== 'object') return null;
|
|
@@ -260,6 +349,37 @@ function writtenTextOf(toolName, toolInput) {
|
|
|
260
349
|
return null;
|
|
261
350
|
}
|
|
262
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Files a shell command just wrote.
|
|
354
|
+
*
|
|
355
|
+
* Write is not the only way Korean reaches disk. A heredoc, a `tee`, a `sed -i`
|
|
356
|
+
* or a generator script puts the same prose in the same file, and a subagent
|
|
357
|
+
* that was handed Bash but not Write reaches for `cat > file` as its first
|
|
358
|
+
* choice. Matching only the file-editing tools therefore exempted exactly the
|
|
359
|
+
* artifacts produced by delegated work.
|
|
360
|
+
*
|
|
361
|
+
* Only redirection targets are recognised, never the command body: the body is
|
|
362
|
+
* shell, and linting shell would flag every Korean string in an echo. The file
|
|
363
|
+
* is read back from disk afterwards, so whatever the command actually produced
|
|
364
|
+
* is what gets checked.
|
|
365
|
+
*/
|
|
366
|
+
function writtenPathsOfBash(command) {
|
|
367
|
+
if (typeof command !== 'string' || !command) return [];
|
|
368
|
+
const out = new Set();
|
|
369
|
+
const add = (p) => {
|
|
370
|
+
if (!p) return;
|
|
371
|
+
const clean = p.replace(/^["']|["']$/g, '');
|
|
372
|
+
if (clean && !clean.startsWith('/dev/') && !/[*?]/.test(clean)) out.add(clean);
|
|
373
|
+
};
|
|
374
|
+
// `> file`, `>> file` — the redirection that covers heredocs and echo alike.
|
|
375
|
+
for (const m of command.matchAll(/(?<![0-9&])>>?\s*("[^"]+"|'[^']+'|[^\s|&;<>()]+)/g)) add(m[1]);
|
|
376
|
+
// `tee file`, `tee -a file`
|
|
377
|
+
for (const m of command.matchAll(/\btee\b(?:\s+-\w+)*\s+("[^"]+"|'[^']+'|[^\s|&;<>()]+)/g)) add(m[1]);
|
|
378
|
+
// In-place edits name their target at the end of the argument list.
|
|
379
|
+
for (const m of command.matchAll(/\b(?:sed|perl)\b[^|;&]*?\s-\w*i\w*\b[^|;&]*?\s("[^"]+"|'[^']+'|[^\s|&;<>()]+)\s*(?:$|[|;&])/g)) add(m[1]);
|
|
380
|
+
return [...out];
|
|
381
|
+
}
|
|
382
|
+
|
|
263
383
|
/**
|
|
264
384
|
* Full check for one PostToolUse payload. Returns null when there is nothing to
|
|
265
385
|
* say, which is the common case and must stay cheap.
|
|
@@ -268,6 +388,9 @@ function lintToolUse(context, { scope = 'all' } = {}) {
|
|
|
268
388
|
if (!context) return null;
|
|
269
389
|
const toolName = context.tool_name;
|
|
270
390
|
const toolInput = context.tool_input;
|
|
391
|
+
|
|
392
|
+
if (toolName === 'Bash') return lintBashWrites(toolInput, scope);
|
|
393
|
+
|
|
271
394
|
const filePath = toolInput && typeof toolInput.file_path === 'string' ? toolInput.file_path : '';
|
|
272
395
|
if (!isLintTarget(filePath, scope)) return null;
|
|
273
396
|
|
|
@@ -282,6 +405,37 @@ function lintToolUse(context, { scope = 'all' } = {}) {
|
|
|
282
405
|
return { filePath, findings };
|
|
283
406
|
}
|
|
284
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Check the files a Bash call redirected into. Reads from disk rather than from
|
|
410
|
+
* the command text, because the command is a recipe and only the result is
|
|
411
|
+
* prose. A path that does not exist, is unreadable, or is too large to be
|
|
412
|
+
* authored prose is skipped in silence: this runs after every shell command, so
|
|
413
|
+
* it must cost nothing in the overwhelmingly common case.
|
|
414
|
+
*/
|
|
415
|
+
const BASH_LINT_MAX_BYTES = 512 * 1024;
|
|
416
|
+
|
|
417
|
+
function lintBashWrites(toolInput, scope) {
|
|
418
|
+
const command = toolInput && typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
419
|
+
const paths = writtenPathsOfBash(command).filter((p) => isLintTarget(p, scope));
|
|
420
|
+
if (paths.length === 0) return null;
|
|
421
|
+
|
|
422
|
+
const fs = require('node:fs');
|
|
423
|
+
for (const p of paths) {
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
const st = fs.statSync(p);
|
|
427
|
+
if (!st.isFile() || st.size > BASH_LINT_MAX_BYTES) continue;
|
|
428
|
+
text = fs.readFileSync(p, 'utf8');
|
|
429
|
+
} catch {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (!hasKorean(text)) continue;
|
|
433
|
+
const findings = lintKoreanText(isHtmlFile(p) ? stripHtml(text) : text, { code: !isProseFile(p) });
|
|
434
|
+
if (findings.length > 0) return { filePath: p, findings };
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
285
439
|
/** Render findings as the message handed back to the model. */
|
|
286
440
|
function formatFindings(filePath, findings) {
|
|
287
441
|
const head = `[korean-style] ${filePath} 에 문체 규약 위반 ${findings.length}건이 있습니다. 파일을 고친 뒤 계속하십시오.`;
|
|
@@ -293,11 +447,13 @@ function formatFindings(filePath, findings) {
|
|
|
293
447
|
module.exports = {
|
|
294
448
|
METAPHOR_LEXICON,
|
|
295
449
|
isLintTarget,
|
|
450
|
+
OPT_OUT_MARKER,
|
|
296
451
|
isProseFile,
|
|
297
452
|
isHtmlFile,
|
|
298
453
|
stripHtml,
|
|
299
454
|
lintKoreanText,
|
|
300
455
|
writtenTextOf,
|
|
301
456
|
lintToolUse,
|
|
457
|
+
writtenPathsOfBash,
|
|
302
458
|
formatFindings,
|
|
303
459
|
};
|