claude-token-saver 3.42.6 → 3.42.7

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 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
@@ -462,7 +462,11 @@ sprag korean lint scope prose # 마크다운·텍스트 문서만
462
462
  sprag korean lint docs/*.md # 이미 저장된 파일을 직접 검사
463
463
  ```
464
464
 
465
- 검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의 마침표입니다. 판단이 필요한 조항(성분 생략, 한자어 선택)은 그대로 지침이 담당합니다.
465
+ 검사 항목은 사람이 판정할 필요가 없는 것들입니다. 비유 어휘 15종(`~는 자리`, `~의 흐름`, `닿는다`, `걷어내다`, `발목을 잡다` 등), 번역체 표지(`~에 대한`, `~를 위한`, `~되어지`), 구분자(`—`·`ㅡ`·`|`), 한 구에 세 번 이상 이어지는 조사 `의`, 명사형 종결 뒤의 마침표, 그리고 v3.42.7부터는 이어지는 두 문장이 똑같은 서술어로 끝나는 종결 반복입니다. 판단이 필요한 조항(성분 생략, 한자어 선택, 비슷하지만 똑같지는 않은 반복)은 그대로 지침이 담당합니다.
466
+
467
+ 검사 대상에는 Bash로 쓴 파일도 들어갑니다(v3.42.7). heredoc과 `tee`, `sed -i`는 Write와 똑같이 한국어를 디스크에 남기고, Write 도구가 없는 하위 에이전트는 `cat > 파일`부터 집습니다. 그래서 Bash를 빼 두면 위임해서 만든 결과물 전체가 검사를 비켜 갔습니다. 리다이렉션 대상 경로만 인식하고 명령문 자체는 읽지 않으므로, `echo`에 담긴 한국어가 잘못 걸리는 일은 없습니다.
468
+
469
+ 검사기를 시험하는 파일이나 문체 보고서처럼 금지된 표현을 일부러 담아야 하는 파일에는 `korean-lint: off` 한 줄을 넣으면 그 파일을 건너뜁니다.
466
470
 
467
471
  기본 범위 `all`은 문서뿐 아니라 **코드 주석과 화면에 나가는 문자열, 자막·템플릿, 생성 결과물까지** 검사합니다. 벤더링한 지침 원문은 코드 주석을 예외로 두지만, 주석도 사람이 읽고 PDF·HTML 같은 산출물은 그 문자열들로 조립되기 때문에 예외로 두면 정확히 문제가 됐던 경로가 다시 열립니다. 검사에서 빠지는 것은 설치된 의존성(`node_modules`), VCS 내부, 락 파일, 그리고 이진·이미지 파일뿐입니다. `dist`나 `build` 같은 산출물 디렉터리는 검사합니다. 원문 규약대로 문서만 보고 싶으면 `korean lint scope prose`로 되돌립니다.
468
472
 
@@ -1123,7 +1127,11 @@ sprag korean lint scope prose # documents only
1123
1127
  sprag korean lint docs/*.md # check files already on disk
1124
1128
  ```
1125
1129
 
1126
- Checked: 15 figurative phrases, translationese markers, separators (`—`·`ㅡ`·`|`), three or more `의` particles in one phrase, and a period after a nominal ending. Clauses that need judgement stay with the guidance text.
1130
+ 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.
1131
+
1132
+ 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.
1133
+
1134
+ 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
1135
 
1128
1136
  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
1137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.42.6",
3
+ "version": "3.42.7",
4
4
  "description": "Legacy name of sprag-cli (Sprag) - same tool, kept publishing so existing installs stay current. Prefer: npm i -g sprag-cli",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -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 already = list.some((m) =>
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 (already) return { path: file, action: 'exists' };
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: 'Write|Edit|MultiEdit',
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;
@@ -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
  };