sprag-cli 3.58.1 → 3.60.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.
@@ -20,6 +20,10 @@
20
20
  * wrote:
21
21
  * sprag korean lint block|warn|off # how findings are handled
22
22
  * sprag korean lint <file...> # check files on disk
23
+ *
24
+ * The third half is a Stop hook for the one slip a write-time check cannot
25
+ * see: an English reply to a Korean prompt, which writes no file at all.
26
+ * sprag korean reply on|off # toggle it (default on)
23
27
  */
24
28
 
25
29
  // Enforcement is only useful if it is on by default — a check the user has to
@@ -39,6 +43,12 @@ function koreanLintScope(cfg) {
39
43
  return cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all';
40
44
  }
41
45
 
46
+ // Default on, like the lint: a check the user has to discover is the same
47
+ // hole in a different shape. Only an explicit false turns it off.
48
+ function koreanReplyCheckOn(cfg) {
49
+ return cfg?.koreanReplyCheck !== false;
50
+ }
51
+
42
52
  export async function run({ args, hasFlag }) {
43
53
  const sub = args[1] || 'status';
44
54
  const ks = await import('../korean-style.js');
@@ -72,6 +82,55 @@ export async function run({ args, hasFlag }) {
72
82
  return;
73
83
  }
74
84
 
85
+ // Stop hook. Fires when the model finishes a turn; if the reply is English
86
+ // to a Korean prompt, block the stop so the model answers again in Korean.
87
+ // Imports stay minimal on purpose: this runs after every turn.
88
+ if (hasFlag?.('--reply-hook') || sub === '--reply-hook') {
89
+ const { readStdinJson } = await import('../stdin-payload.js');
90
+ const payload = readStdinJson();
91
+ if (!payload) return;
92
+ // Claude Code sets stop_hook_active while the model is already continuing
93
+ // because of a Stop hook. Blocking again would loop forever.
94
+ if (payload.stop_hook_active) return;
95
+ if (!ks.koreanStyleEnabled()) return;
96
+ if (!koreanReplyCheckOn(loadConfig())) return;
97
+ const { createRequire } = await import('node:module');
98
+ const found = createRequire(import.meta.url)('../reply-language.cjs').checkReplyLanguage(payload.transcript_path);
99
+ if (!found) return;
100
+ console.log(JSON.stringify({
101
+ decision: 'block',
102
+ reason: '사용자는 한국어로 질문했는데 마지막 답변이 영어로 작성됐습니다. 같은 내용을 한국어로 다시 답하십시오. 코드, 명령어, 오류 원문, 커밋 메시지처럼 원문을 유지해야 하는 부분은 그대로 둡니다.',
103
+ }));
104
+ return;
105
+ }
106
+
107
+ // Toggle: `korean reply on|off`. Off keeps the hook installed and makes it
108
+ // a no-op, the same way lint mode `off` works; `korean off` removes it.
109
+ if (sub === 'reply') {
110
+ const cfg = loadConfig();
111
+ const next = args[2];
112
+ if (!next) {
113
+ console.log(lang === 'ko'
114
+ ? `답변 언어 검사: ${koreanReplyCheckOn(cfg) ? '켜짐' : '꺼짐'}`
115
+ : `Reply-language check: ${koreanReplyCheckOn(cfg) ? 'on' : 'off'}`);
116
+ return;
117
+ }
118
+ if (!['on', 'off'].includes(next)) {
119
+ console.error('Usage: sprag korean reply [on|off]');
120
+ process.exit(1);
121
+ }
122
+ cfg.koreanReplyCheck = next === 'on';
123
+ saveConfig(cfg);
124
+ console.log(next === 'on'
125
+ ? (lang === 'ko'
126
+ ? '답변 언어 검사를 켰습니다. 한국어 질문에 영어로 답하면 모델에게 되돌려 보내 다시 답하게 합니다.'
127
+ : 'Reply-language check is on. An English reply to a Korean prompt is sent back for a Korean answer.')
128
+ : (lang === 'ko'
129
+ ? '답변 언어 검사를 껐습니다. 훅은 남아 있지만 아무 일도 하지 않습니다.'
130
+ : 'Reply-language check is off. The hook stays installed and does nothing.'));
131
+ return;
132
+ }
133
+
75
134
  // Manual run over files already on disk: `korean lint docs/*.md`.
76
135
  if (sub === 'lint' && args.length > 2 && !['on', 'off', 'warn', 'block', 'scope'].includes(args[2])) {
77
136
  const { readFileSync } = await import('node:fs');
@@ -116,8 +175,8 @@ export async function run({ args, hasFlag }) {
116
175
  saveConfig(cfg);
117
176
  console.log(lang === 'ko'
118
177
  ? (next === 'all'
119
- ? '검사 범위: all — 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
120
- : '검사 범위: prose — 마크다운과 텍스트 문서만 검사합니다.')
178
+ ? '검사 범위를 all 로 바꿨습니다. 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
179
+ : '검사 범위를 prose 로 좁혔습니다. 마크다운과 텍스트 문서만 검사합니다.')
121
180
  : (next === 'all'
122
181
  ? 'Check scope: all — every text file the session writes, comments and UI strings included.'
123
182
  : 'Check scope: prose — documents only.'));
@@ -163,8 +222,13 @@ export async function run({ args, hasFlag }) {
163
222
  if (sub === 'on' || sub === 'off') {
164
223
  const enabled = sub === 'on';
165
224
  ks.setKoreanStyleEnabled(enabled);
166
- const { installKoreanLintHook, removeKoreanLintHook } = await import('../installer.js');
225
+ const {
226
+ installKoreanLintHook, removeKoreanLintHook,
227
+ installReplyLanguageHook, removeReplyLanguageHook,
228
+ } = await import('../installer.js');
167
229
  const hook = enabled ? installKoreanLintHook() : removeKoreanLintHook();
230
+ if (enabled) installReplyLanguageHook();
231
+ else removeReplyLanguageHook();
168
232
  if (enabled) {
169
233
  console.log(lang === 'ko'
170
234
  ? '한국어 문체 지침을 켰습니다. 다음 세션부터 모든 프로젝트에 적용됩니다.'
@@ -202,6 +266,7 @@ export async function run({ args, hasFlag }) {
202
266
  console.log(`한국어 문체 지침: ${on ? '켜짐' : '꺼짐'}`);
203
267
  console.log(` 비용: 세션당 약 ${tokens} 토큰 (세션 시작 1회 주입)`);
204
268
  console.log(` 쓰기 시점 검사: ${koreanLintMode(loadConfig())}, 범위 ${koreanLintScope(loadConfig())} (바꾸려면 sprag korean lint block|warn|off, korean lint scope all|prose)`);
269
+ console.log(` 답변 언어 검사: ${koreanReplyCheckOn(loadConfig()) ? '켜짐' : '꺼짐'} (바꾸려면 sprag korean reply on|off)`);
205
270
  console.log(` 출처: ${ks.KOREAN_STYLE_SOURCE}`);
206
271
  console.log(` 라이선스 전문: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
207
272
  console.log(on
@@ -211,6 +276,7 @@ export async function run({ args, hasFlag }) {
211
276
  console.log(`Korean writing guidance: ${on ? 'on' : 'off'}`);
212
277
  console.log(` Cost: ~${tokens} tokens per session (injected once at session start)`);
213
278
  console.log(` Write-time check: ${koreanLintMode(loadConfig())}, scope ${koreanLintScope(loadConfig())} (change with: sprag korean lint block|warn|off, korean lint scope all|prose)`);
279
+ console.log(` Reply-language check: ${koreanReplyCheckOn(loadConfig()) ? 'on' : 'off'} (change with: sprag korean reply on|off)`);
214
280
  console.log(` Source: ${ks.KOREAN_STYLE_SOURCE}`);
215
281
  console.log(` License text: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
216
282
  console.log(on
@@ -56,9 +56,22 @@ export async function run({ args, hasFlag, numArg }) {
56
56
  // which model actually ran it.
57
57
  if (args[1] === 'savings') {
58
58
  const { loadLedger, delegationSavedTotals } = await import('../savings-ledger.js');
59
+ // Runs from before `sprag saved reset` are kept in the ledger but not listed.
60
+ const { readResetMarks, isBeforeReset } = await import('../saved-reset.cjs');
61
+ const { userDataDir } = await import('../paths.js');
62
+ const since = readResetMarks(userDataDir()).routing;
59
63
  const events = Object.entries(loadLedger().events)
60
64
  .map(([key, e]) => ({ key, ...e }))
65
+ .filter((e) => !isBeforeReset(e.ts, since))
61
66
  .sort((a, b) => b.ts - a.ts);
67
+ if (events.length === 0 && since !== null) {
68
+ // After a reset an empty list is the expected state, not a setup gap.
69
+ const at = new Date(since).toLocaleString(lang === 'ko' ? 'ko-KR' : 'en-US');
70
+ console.log(lang === 'ko'
71
+ ? `${at}에 카운터를 초기화한 뒤로 기록된 라우팅 절감이 아직 없습니다. 되돌리려면 \`sprag saved undo\` 를 실행하십시오.`
72
+ : `No routing savings recorded since the counter was reset at ${at}. Run \`sprag saved undo\` to restore it.`);
73
+ return;
74
+ }
62
75
  if (events.length === 0) {
63
76
  console.log(lang === 'ko'
64
77
  ? '기록된 라우팅 절감 없음. 승격된 룰이 실제로 위임을 일으킨 뒤 `route-scan --refresh` 를 돌리면 채워집니다.'
@@ -72,6 +85,12 @@ export async function run({ args, hasFlag, numArg }) {
72
85
  console.log(lang === 'ko'
73
86
  ? `🔀 라우팅 절감 누적 ${money(t.total)} (최근 7일 ${money(t.week)} · 30일 ${money(t.month)})`
74
87
  : `🔀 Routing saved, lifetime ${money(t.total)} (last 7d ${money(t.week)} · 30d ${money(t.month)})`);
88
+ if (since !== null) {
89
+ const at = new Date(since).toLocaleString(lang === 'ko' ? 'ko-KR' : 'en-US');
90
+ console.log(lang === 'ko'
91
+ ? ` ${at}에 카운터를 초기화했습니다. 이후 기록만 집계합니다.`
92
+ : ` Counter reset at ${at}; only later runs are counted.`);
93
+ }
75
94
 
76
95
  // Per model-pair rollup first: the "what moved where" question is what
77
96
  // this view exists to answer, and it is easier to read than the log.
@@ -341,12 +360,14 @@ export async function run({ args, hasFlag, numArg }) {
341
360
  // — the refresh runs detached off the statusline path, never here.
342
361
  let updateBlock = null;
343
362
  try {
344
- const { updateStatus, maybeSpawnUpdateCheck } = await import('../update-check.js');
363
+ const { updateStatus, maybeSpawnUpdateCheck, SESSION_START_CHECK_INTERVAL_MS } = await import('../update-check.js');
345
364
  const pkgVersion = readPackageVersion();
346
365
  // Session start is the one moment where a stale cache matters most, so
347
- // kick the detached refresh here too. It lands in time for the
348
- // statusline and for the next session, not for this line.
349
- if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion);
366
+ // kick the detached refresh here too, on a shorter interval than the
367
+ // statusline's 24h: releases can land several times a day, and a
368
+ // session opened that afternoon should hear about them. It lands in
369
+ // time for the statusline and for the next session, not for this line.
370
+ if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion, { intervalMs: SESSION_START_CHECK_INTERVAL_MS });
350
371
  const u = pkgVersion ? updateStatus(pkgVersion) : null;
351
372
  // `dismissed` is the whole point of asking once: a user who said no to
352
373
  // this version must not be asked again every `/clear`.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Subcommand: saved — a stopwatch reset for the lifetime "saved" counters.
3
+ *
4
+ * sprag saved # totals since the last reset
5
+ * sprag saved reset [routing|doc2md|all] # start counting from now
6
+ * sprag saved undo # put the previous counters back
7
+ * (add --agent codex for the Codex counters: codex-routing, codex-docs)
8
+ *
9
+ * Nothing is deleted. A reset only records a timestamp, and the totals count
10
+ * events from that moment on; see src/saved-reset.cjs.
11
+ *
12
+ * The routing mark also bounds the per-rule `saved ~$` figures in
13
+ * model-rules.json and ratchet-model.md, which are rebuilt by a scan rather than
14
+ * summed from the ledger. So a reset or undo that touches `routing` finishes by
15
+ * running that scan; see refreshRuleSavings.
16
+ */
17
+
18
+ import { debug } from '../debug.js';
19
+
20
+ const SCOPE_SETS = {
21
+ claude: ['routing', 'doc2md'],
22
+ codex: ['codex-routing', 'codex-docs'],
23
+ };
24
+
25
+ /** Totals since each scope's mark: { usd } for money counters, { docs } for the Codex document counter. */
26
+ async function currentTotals(scope, dir) {
27
+ if (scope === 'routing') {
28
+ const { delegationSavedTotals } = await import('../savings-ledger.js');
29
+ return { usd: delegationSavedTotals(Date.now(), dir).total };
30
+ }
31
+ if (scope === 'doc2md') {
32
+ const { doc2mdSavedTotals } = await import('../doc2md-ledger.cjs');
33
+ return { usd: doc2mdSavedTotals(dir).total };
34
+ }
35
+ if (scope === 'codex-routing') {
36
+ const { codexRoutingSavedTotals } = await import('../codex-ledger.js');
37
+ return { usd: codexRoutingSavedTotals({ dir }).total };
38
+ }
39
+ const { codexDocumentTotals } = await import('../codex-doc2md-ledger.js');
40
+ return { docs: codexDocumentTotals({ dir }).docs };
41
+ }
42
+
43
+ /**
44
+ * Rebuild each rule's savedUsd and re-render ratchet-model.md after the routing
45
+ * mark moved. The 14-day scan does both (it reads the mark, refreshes the
46
+ * registry, writes the managed files) in about half a second, so it is reused
47
+ * rather than duplicated here.
48
+ *
49
+ * Best-effort by design: the mark is already saved, and a failed rescan must not
50
+ * turn a successful reset into an error. It says so in one line instead, since
51
+ * the figures then stay stale until the next scan.
52
+ *
53
+ * @param {boolean} ko
54
+ * @param {Function} [rescan] injected by tests; defaults to runRouteScan
55
+ */
56
+ async function refreshRuleSavings(ko, rescan) {
57
+ try {
58
+ const scan = rescan || (await import('../route-scan.js')).runRouteScan;
59
+ await scan({ days: 14 });
60
+ } catch (e) {
61
+ debug('saved:rule-savings', e);
62
+ console.log(ko
63
+ ? '룰별 절감액은 다음 route-scan 때 다시 계산됩니다.'
64
+ : 'Per-rule savings will be recalculated at the next route-scan.');
65
+ }
66
+ }
67
+
68
+ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg, rescan }) {
69
+ const { userLanguage } = await import('../config.js');
70
+ const { userDataDir } = await import('../paths.js');
71
+ const { readResetMarks, resetSaved, undoReset } = await import('../saved-reset.cjs');
72
+ const ko = userLanguage() === 'ko';
73
+ const dir = dirArg || userDataDir();
74
+ const scopes = SCOPE_SETS[agent === 'codex' ? 'codex' : 'claude'];
75
+ const suffix = agent === 'codex' ? ' --agent codex' : '';
76
+
77
+ const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`);
78
+ const fmtTotal = (t) => (t.docs !== undefined ? (ko ? `${t.docs}건` : `${t.docs} doc(s)`) : money(t.usd));
79
+ const fmtTime = (ms) => new Date(ms).toLocaleString(ko ? 'ko-KR' : 'en-US');
80
+ const label = (scope) => ({
81
+ routing: ko ? '라우팅 절감' : 'Routing saved',
82
+ doc2md: ko ? '문서 변환 절감' : 'Document conversion saved',
83
+ 'codex-routing': ko ? 'Codex 라우팅 절감' : 'Codex routing saved',
84
+ 'codex-docs': ko ? 'Codex 문서 변환' : 'Codex documents converted',
85
+ })[scope];
86
+
87
+ const sub = args[1];
88
+
89
+ if (sub === undefined) {
90
+ const marks = readResetMarks(dir);
91
+ for (const s of scopes) {
92
+ const t = await currentTotals(s, dir);
93
+ const since = marks[s] === null
94
+ ? (ko ? '초기화한 적 없음' : 'not reset')
95
+ : (ko ? `${fmtTime(marks[s])} 이후` : `since ${fmtTime(marks[s])}`);
96
+ console.log(`${label(s)}: ${fmtTotal(t)} (${since})`);
97
+ }
98
+ return;
99
+ }
100
+
101
+ if (sub === 'reset') {
102
+ const arg = args[2] || 'all';
103
+ const base = agent === 'codex' ? { routing: 'codex-routing', doc2md: 'codex-docs' } : { routing: 'routing', doc2md: 'doc2md' };
104
+ let targets;
105
+ if (arg === 'all') targets = scopes;
106
+ else if (scopes.includes(arg)) targets = [arg];
107
+ else if (base[arg]) targets = [base[arg]];
108
+ else {
109
+ console.error(ko
110
+ ? `알 수 없는 범위입니다: ${arg}. 사용할 수 있는 범위는 ${scopes.join(', ')}, all 입니다.`
111
+ : `Unknown scope: ${arg}. Valid scopes are ${scopes.join(', ')}, all.`);
112
+ process.exit(1);
113
+ }
114
+ const before = {};
115
+ for (const s of targets) before[s] = await currentTotals(s, dir);
116
+ resetSaved(targets, { dir });
117
+ for (const s of targets) {
118
+ console.log(ko
119
+ ? `${label(s)} 카운터를 0으로 초기화했습니다. 이전 합계는 ${fmtTotal(before[s])}입니다.`
120
+ : `Reset the ${label(s)} counter to zero. The previous total was ${fmtTotal(before[s])}.`);
121
+ }
122
+ console.log(ko
123
+ ? `원장 기록은 지우지 않았습니다. \`sprag saved undo${suffix}\`로 되돌릴 수 있습니다.`
124
+ : `No ledger data was deleted. Run \`sprag saved undo${suffix}\` to restore the previous counters.`);
125
+ // Codex keeps no per-rule figure (its rules are a separate store with no
126
+ // savedUsd), so only the Claude `routing` scope has anything to recompute.
127
+ if (targets.includes('routing')) await refreshRuleSavings(ko, rescan);
128
+ return;
129
+ }
130
+
131
+ if (sub === 'undo') {
132
+ const undone = undoReset({ dir });
133
+ if (!undone) {
134
+ console.log(ko ? '되돌릴 초기화 기록이 없습니다.' : 'There is no reset to undo.');
135
+ return;
136
+ }
137
+ for (const s of undone.scopes) {
138
+ const t = await currentTotals(s, dir);
139
+ console.log(ko
140
+ ? `${label(s)} 카운터를 되돌렸습니다. 현재 합계는 ${fmtTotal(t)}입니다.`
141
+ : `Restored the ${label(s)} counter. The total is now ${fmtTotal(t)}.`);
142
+ }
143
+ // Keyed on what was undone, not on --agent: undo pops the latest reset
144
+ // whichever agent made it, so a bare `undo --agent codex` can restore
145
+ // the Claude routing mark and the rule figures must follow it.
146
+ if (undone.scopes.includes('routing')) await refreshRuleSavings(ko, rescan);
147
+ return;
148
+ }
149
+
150
+ console.error(ko
151
+ ? `알 수 없는 하위 명령입니다: ${sub}. 사용법은 sprag saved [reset|undo] 입니다.`
152
+ : `Unknown subcommand: ${sub}. Usage: sprag saved [reset|undo].`);
153
+ process.exit(1);
154
+ }
@@ -59,3 +59,17 @@ export function liveWindowFor(sessionId) {
59
59
  const s = load().sessions[sessionId];
60
60
  return s && validSize(s.size) ? s.size : null;
61
61
  }
62
+
63
+ /**
64
+ * The window most recently reported by any session's statusline, or null.
65
+ * `sprag compact-window` runs outside a session and has no payload of its own;
66
+ * judged by the settings model id alone, a bare `opus` (1M by default on Opus
67
+ * 5.5, with no `[1m]` suffix) read as 200k while the statusline said 1M.
68
+ */
69
+ export function latestLiveWindow() {
70
+ let best = null;
71
+ for (const s of Object.values(load().sessions)) {
72
+ if (s && validSize(s.size) && Number.isFinite(s.ts) && (!best || s.ts > best.ts)) best = s;
73
+ }
74
+ return best ? best.size : null;
75
+ }
@@ -69,6 +69,7 @@
69
69
 
70
70
  const fs = require('node:fs');
71
71
  const path = require('node:path');
72
+ const { readResetMarks, isBeforeReset } = require('./saved-reset.cjs');
72
73
 
73
74
  const WEEK_MS = 7 * 24 * 3600 * 1000;
74
75
  const MONTH_MS = 30 * 24 * 3600 * 1000;
@@ -185,13 +186,18 @@ function recordConversion(userDataDir, event) {
185
186
  * the lifetime rollup per format, priciest first, then most-converted. Never
186
187
  * throws; an unreadable ledger yields zeros, and the statusline hides the
187
188
  * chip on a zero.
189
+ *
190
+ * Events before the `doc2md` reset mark (`sprag saved reset`) are skipped in
191
+ * every figure, and `since` reports the mark (null when never reset).
188
192
  */
189
193
  function doc2mdSavedTotals(userDataDir, now = Date.now()) {
190
- const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [] });
194
+ const since = readResetMarks(userDataDir).doc2md;
195
+ const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [], since });
191
196
  const totals = empty();
192
197
  const byExt = new Map();
193
198
  try {
194
199
  for (const e of Object.values(loadLedger(userDataDir).events)) {
200
+ if (isBeforeReset(e.ts, since)) continue;
195
201
  const usd = Number(e.usd) || 0;
196
202
  totals.total += usd;
197
203
  totals.docs += 1;