sprag-cli 3.50.1 → 3.52.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/bin/cli.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  sessionMetrics,
53
53
  diagnoseSession,
54
54
  } from '../src/stats.js';
55
- import { estimateCost } from '../src/cost.js';
55
+ import { estimateCostAcross } from '../src/cost.js';
56
56
  import { chipForIssues } from '../src/advice.js';
57
57
  import { debug } from '../src/debug.js';
58
58
  import { createArgs } from '../src/cli-args.js';
@@ -561,7 +561,7 @@ async function main() {
561
561
  const ttl = ttlBreakdown(sessions);
562
562
  const sum = summary(sessions);
563
563
  const anomalies = detectAnomalies(trend);
564
- const cost = estimateCost(sum, sessions[0]?.model);
564
+ const cost = estimateCostAcross(sessions);
565
565
  const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
566
566
  const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
567
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.50.1",
3
+ "version": "3.52.0",
4
4
  "description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/caps-cache.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * than `maxAgeMs` the loader returns null and the table view stays quiet.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { userDataDir } from './paths.js';
15
15
 
@@ -38,7 +38,11 @@ export function persistSnapshot(snapshot) {
38
38
  caps: snapshot.caps || null,
39
39
  model: snapshot.model || null,
40
40
  };
41
- writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
41
+ // tmp + rename: the statusline reads this every tick and must never see a
42
+ // half-written file.
43
+ const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(payload) + '\n');
45
+ renameSync(tmp, CACHE_PATH);
42
46
  } catch {
43
47
  // best-effort cache, never blocks the statusline
44
48
  }
@@ -34,8 +34,12 @@ export async function run({ hasFlag }) {
34
34
  // the match here removes both steps. Silent unless it is sure — see
35
35
  // src/route-inject.js.
36
36
  try {
37
- const { routeHint } = await import('../route-inject.js');
38
- const hint = routeHint(ctx.prompt);
37
+ const { routeHint, sessionModelRank } = await import('../route-inject.js');
38
+ // The payload names the session but not its model, so the rank comes from
39
+ // the transcript: without it a Sonnet session is told to delegate to
40
+ // Sonnet, which saves nothing.
41
+ const sessionRank = sessionModelRank({ transcriptPath: ctx.transcript_path });
42
+ const hint = routeHint(ctx.prompt, { sessionRank });
39
43
  if (hint) parts.push(hint);
40
44
  } catch (e) { debug('brief:route-hint', e); }
41
45
  if (parts.length) console.log(parts.join('\n'));
@@ -161,7 +161,9 @@ export async function run({ args, hasFlag }) {
161
161
  }
162
162
  process.exit(1);
163
163
  }
164
- rule = `반복 감지 ×${cand.count}: ${cand.pattern} — TODO: 원인·예방책 한 줄로`;
164
+ // No em dash here: this text lands in ratchet.md, where the Korean
165
+ // write-time lint would flag it on the user's next edit.
166
+ rule = `반복 감지 ×${cand.count}: ${cand.pattern} · TODO: 원인과 예방책을 한 줄로 적습니다`;
165
167
  }
166
168
  // R-prefixed arg → route-scan delegation candidate (statusline `route? R<N>`).
167
169
  // The rule text is pre-generated by the scan; promoting also resolves the
@@ -257,8 +257,8 @@ export async function run({ hasFlag }) {
257
257
  ? ' 내용: 조사·어미를 생략하지 않고, 명사구가 아니라 서술어로 문장을 끝맺으며,'
258
258
  : ' what it does: keeps particles and endings, ends sentences with a predicate,');
259
259
  console.log(lang === 'ko'
260
- ? ' 번역체 대신 자연스러운 한국어를 쓰도록 지시합니다. 코드와 주석에는 적용되지 않습니다.'
261
- : ' and asks for idiomatic Korean over translationese. Code and comments are exempt.');
260
+ ? ' 번역체 대신 자연스러운 한국어를 쓰도록 지시합니다. 기본 범위는 코드 주석과 문자열까지 포함하며, `korean lint scope prose` 로 산문만 검사하도록 좁힐 수 있습니다.'
261
+ : ' and asks for idiomatic Korean over translationese. The default scope includes code comments and strings; `korean lint scope prose` narrows it to prose.');
262
262
  console.log(lang === 'ko'
263
263
  ? ' 비용: 세션당 약 1.5k 토큰(턴마다가 아니라 세션 시작 시 1회, 이후 프롬프트 캐시에 포함).'
264
264
  : ' cost: ~1.5k tokens per session (once at session start, cached from the second request on).');
@@ -181,8 +181,8 @@ export async function run({ args, hasFlag }) {
181
181
  : ` ⚠ Could not register the write-time hook: ${hook.reason}`);
182
182
  } else {
183
183
  console.log(lang === 'ko'
184
- ? ` 쓰기 시점 검사: ${koreanLintMode(loadConfig())}, 범위 ${koreanLintScope(loadConfig())} (Write·Edit 로 쓴 한국어를 검사합니다)`
185
- : ` Write-time check: ${koreanLintMode(loadConfig())}, scope ${koreanLintScope(loadConfig())} (runs on Korean written via Write/Edit)`);
184
+ ? ` 쓰기 시점 검사: ${koreanLintMode(loadConfig())}, 범위 ${koreanLintScope(loadConfig())} (Write·Edit·Bash 로 쓴 한국어를 검사합니다)`
185
+ : ` Write-time check: ${koreanLintMode(loadConfig())}, scope ${koreanLintScope(loadConfig())} (runs on Korean written via Write, Edit or Bash)`);
186
186
  }
187
187
  } else {
188
188
  console.log(lang === 'ko'
@@ -73,7 +73,13 @@ function readJson(path) {
73
73
  * its /config parser documents. Returns null when unparseable.
74
74
  */
75
75
  export function parseWindow(raw) {
76
- if (typeof raw === 'number') return Number.isFinite(raw) ? Math.round(raw) : null;
76
+ if (typeof raw === 'number') {
77
+ if (!Number.isFinite(raw)) return null;
78
+ // Same bare-hundreds rule as the string form: `200` and `"200"` are one
79
+ // intent and must audit the same way.
80
+ const n = Math.round(raw);
81
+ return n < MIN_WINDOW ? n * 1_000 : n;
82
+ }
77
83
  if (typeof raw !== 'string') return null;
78
84
  const s = raw.trim().toLowerCase();
79
85
  const m = s.match(/^(\d+(?:\.\d+)?)\s*([km]?)$/);
package/src/cost.js CHANGED
@@ -248,6 +248,40 @@ export function estimateCost(totals, model) {
248
248
  };
249
249
  }
250
250
 
251
+ /**
252
+ * Price a set of sessions each at its own model and sum. One estimateCost over
253
+ * the summed totals priced every token at whichever session came first in the
254
+ * list — sorted by mtime, so a haiku scratch session touched earliest set the
255
+ * rate for a day of Fable work and understated the saving about tenfold.
256
+ * Sessions whose model has no known rate are skipped, as month-spend does.
257
+ */
258
+ export function estimateCostAcross(sessions) {
259
+ const acc = { actual: 0, noCacheCost: 0, scenario5mCost: 0, extraCostIf5mApplicable: false };
260
+ const tiers = new Map();
261
+ for (const s of sessions || []) {
262
+ if (!s?.totals) continue;
263
+ let c;
264
+ try { c = estimateCost(s.totals, s.model); } catch { continue; }
265
+ acc.actual += c.actual;
266
+ acc.noCacheCost += c.noCacheCost;
267
+ acc.scenario5mCost += c.scenario5mCost;
268
+ acc.extraCostIf5mApplicable = acc.extraCostIf5mApplicable || c.extraCostIf5mApplicable;
269
+ tiers.set(c.tier, (tiers.get(c.tier) || 0) + c.actual);
270
+ }
271
+ let tier = null;
272
+ for (const [t, usd] of tiers) if (tier === null || usd > tiers.get(tier)) tier = t;
273
+ return {
274
+ tier,
275
+ actual: round(acc.actual),
276
+ noCacheCost: round(acc.noCacheCost),
277
+ savings: round(acc.noCacheCost - acc.actual),
278
+ savingsRate: acc.noCacheCost > 0 ? (acc.noCacheCost - acc.actual) / acc.noCacheCost : 0,
279
+ scenario5mCost: round(acc.scenario5mCost),
280
+ extraCostIf5m: round(acc.scenario5mCost - acc.actual),
281
+ extraCostIf5mApplicable: acc.extraCostIf5mApplicable,
282
+ };
283
+ }
284
+
251
285
  function round(n) {
252
286
  return Math.round(n * 100) / 100;
253
287
  }
@@ -23,8 +23,8 @@
23
23
  * `claude --print --input-format stream-json` with and without the file as a
24
24
  * document block. The control turn cost 42,204 tokens, twice, to the token.
25
25
  *
26
- * kohjuho_resume_kr.pdf 7 pages +20,537 tokens 2,934 per page
27
- * xeoyoung_resume.pdf 5 pages +12,709 tokens 2,542 per page
26
+ * report-a.pdf 7 pages +20,537 tokens 2,934 per page
27
+ * report-b.pdf 5 pages +12,709 tokens 2,542 per page
28
28
  *
29
29
  * A PDF is read whole: the model answered from its contents. Converting one
30
30
  * to text is worth three to four times its own size.
@@ -38,7 +38,7 @@
38
38
  * outweigh the text many times over:
39
39
  *
40
40
  * aws-summit-seoul.pptx 2.1MB of slide XML ~540,429 tokens 23.8× the conversion
41
- * 우리은행이력서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
41
+ * 사업계획서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
42
42
  *
43
43
  * So the baseline for these formats is the body markup the converter read,
44
44
  * measured per file rather than assumed from a ratio. It is a real number for
@@ -458,7 +458,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
458
458
  : (ttl.gatewayObserved ? YELLOW : GRAY);
459
459
  const ttlSeconds = is1h ? 3600 : 300;
460
460
 
461
- const savings = cost?.savings ?? 0;
461
+ // noCache − actual goes negative in write-heavy sessions (cache writes bill
462
+ // 1.25×/2× with few reads yet); "$-0.42" is not a saving, so floor at zero.
463
+ const savings = Math.max(0, cost?.savings ?? 0);
462
464
 
463
465
  const c = (v) => (color ? v : '');
464
466
  // narrow shares the icon layout and swaps only the glyphs, so both modes take
@@ -218,7 +218,9 @@ function computePevSkip(entries) {
218
218
 
219
219
  function analyzeTranscript(transcriptPath, opts) {
220
220
  opts = opts || {};
221
- const entries = readJsonl(transcriptPath);
221
+ // The hook has already parsed the transcript for its own totals; reading a
222
+ // 65MB session a second time per tool call is the one avoidable cost here.
223
+ const entries = Array.isArray(opts.entries) ? opts.entries : readJsonl(transcriptPath);
222
224
  if (entries.length === 0) return null;
223
225
  const evidenceRate = computeEvidenceRate(entries);
224
226
  const pevSkip = computePevSkip(entries);
@@ -240,7 +242,10 @@ function writeState(state) {
240
242
  if (!state) return;
241
243
  try {
242
244
  if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
243
- fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + '\n');
245
+ // tmp + rename so the statusline never reads a half-written state file.
246
+ const tmp = STATE_PATH + '.' + process.pid + '.tmp';
247
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
248
+ fs.renameSync(tmp, STATE_PATH);
244
249
  } catch {
245
250
  // best-effort
246
251
  }
@@ -26,6 +26,31 @@ function isCacheMonitorHook(nh) {
26
26
  return cmd.includes(HOOK_MARKER) || cmd.includes('--hook-run');
27
27
  }
28
28
 
29
+ /**
30
+ * Drop our hook entries and keep everything else. Removal is per hook, not per
31
+ * matcher group: Claude Code merges hooks that share a matcher into one group,
32
+ * so filtering whole groups took another tool's hook with ours whenever that
33
+ * tool had been installed into the same `Bash|Edit|Write` group. A group goes
34
+ * only once nothing of anyone else's is left in it. Same rule as
35
+ * installer.js uninstallAll, which fixed this for the other hooks earlier.
36
+ */
37
+ export function withoutCacheMonitorHooks(groups) {
38
+ const kept = [];
39
+ for (const m of groups) {
40
+ if (!Array.isArray(m?.hooks)) { kept.push(m); continue; }
41
+ const hooks = m.hooks.filter((h) => !isCacheMonitorHook(h));
42
+ if (hooks.length === m.hooks.length) { kept.push(m); continue; }
43
+ if (hooks.length) kept.push({ ...m, hooks });
44
+ }
45
+ return kept;
46
+ }
47
+
48
+ function countCacheMonitorHooks(groups) {
49
+ let n = 0;
50
+ for (const m of groups) for (const h of (Array.isArray(m?.hooks) ? m.hooks : [])) if (isCacheMonitorHook(h)) n += 1;
51
+ return n;
52
+ }
53
+
29
54
  export async function installHook({ threshold = 0.7 } = {}) {
30
55
  let settings;
31
56
  try {
@@ -36,13 +61,17 @@ export async function installHook({ threshold = 0.7 } = {}) {
36
61
  }
37
62
 
38
63
  if (!settings.hooks) settings.hooks = {};
39
- if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
64
+ if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
65
+ // Someone else's data in a shape we do not understand; overwriting it with
66
+ // [] would discard it. Leave the file alone and say so.
67
+ console.log(`⚠ hooks.PostToolUse in ${SETTINGS_PATH} is not an array — hook not installed.`);
68
+ return;
69
+ }
70
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
40
71
 
41
- // Remove existing cache-monitor hook if present — matches both the current
42
- // subcommand form and the legacy copied-file form.
43
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
44
- (h) => !(h.hooks || []).some(isCacheMonitorHook),
45
- );
72
+ // Remove an existing cache-monitor hook — matches both the current subcommand
73
+ // form and the legacy copied-file form.
74
+ settings.hooks.PostToolUse = withoutCacheMonitorHooks(settings.hooks.PostToolUse);
46
75
 
47
76
  settings.hooks.PostToolUse.push({
48
77
  matcher: 'Bash|Edit|Write',
@@ -76,12 +105,10 @@ export async function uninstallHook() {
76
105
  return;
77
106
  }
78
107
 
79
- if (settings.hooks?.PostToolUse) {
80
- const before = settings.hooks.PostToolUse.length;
81
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
82
- (h) => !(h.hooks || []).some(isCacheMonitorHook),
83
- );
84
- const removed = before - settings.hooks.PostToolUse.length;
108
+ if (Array.isArray(settings.hooks?.PostToolUse)) {
109
+ const before = countCacheMonitorHooks(settings.hooks.PostToolUse);
110
+ settings.hooks.PostToolUse = withoutCacheMonitorHooks(settings.hooks.PostToolUse);
111
+ const removed = before;
85
112
 
86
113
  if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
87
114
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
package/src/hook.cjs CHANGED
@@ -100,6 +100,9 @@ try {
100
100
 
101
101
  const lines = content.trim().split('\n');
102
102
  const requests = new Map();
103
+ // Every parsed entry, handed to the harness analyzer below so it does not read
104
+ // and parse the same transcript a second time per tool call.
105
+ const entries = [];
103
106
 
104
107
  for (const line of lines) {
105
108
  let entry;
@@ -108,6 +111,7 @@ for (const line of lines) {
108
111
  } catch {
109
112
  continue;
110
113
  }
114
+ entries.push(entry);
111
115
 
112
116
  const msg = entry.message;
113
117
  if (!msg || !msg.usage || !msg.id) continue;
@@ -129,6 +133,22 @@ for (const line of lines) {
129
133
 
130
134
  if (requests.size === 0) process.exit(0);
131
135
 
136
+ // The model most requests ran on, skipping the `<synthetic>` placeholder Claude
137
+ // Code writes for local turns. Same rule as parser.js modelCounts; taking
138
+ // reqs[0] here was the CJS twin drifting from it.
139
+ function dominantModel(list) {
140
+ var counts = new Map();
141
+ for (var i = 0; i < list.length; i++) {
142
+ var m = list[i].model;
143
+ if (!m || m === 'unknown' || m.indexOf('<synthetic>') !== -1) continue;
144
+ counts.set(m, (counts.get(m) || 0) + 1);
145
+ }
146
+ var best = 'unknown';
147
+ var bestN = 0;
148
+ counts.forEach(function (n, m) { if (n > bestN) { best = m; bestN = n; } });
149
+ return best;
150
+ }
151
+
132
152
  const reqs = Array.from(requests.values());
133
153
  const totals = reqs.reduce(
134
154
  function (a, r) {
@@ -160,11 +180,22 @@ const record = {
160
180
  ephemeral1h: totals.ephemeral1h,
161
181
  output: totals.output,
162
182
  },
163
- model: reqs[0] ? reqs[0].model : 'unknown',
183
+ model: dominantModel(reqs),
164
184
  };
165
185
 
166
- // Append to stats file
186
+ // Append to stats file. One record per tool call and nothing reads it back
187
+ // yet, so keep it from growing without bound: past the cap, keep the tail.
188
+ var STATS_MAX_BYTES = 5 * 1024 * 1024;
189
+ var STATS_KEEP_LINES = 2000;
167
190
  try {
191
+ try {
192
+ if (fs.statSync(STATS_FILE).size > STATS_MAX_BYTES) {
193
+ var tail = fs.readFileSync(STATS_FILE, 'utf8').trim().split('\n').slice(-STATS_KEEP_LINES);
194
+ fs.writeFileSync(STATS_FILE, tail.join('\n') + '\n', 'utf8');
195
+ }
196
+ } catch (e) {
197
+ // no file yet, or unreadable — the append below creates or reports it
198
+ }
168
199
  fs.appendFileSync(STATS_FILE, JSON.stringify(record) + '\n', 'utf8');
169
200
  } catch (e) {
170
201
  dbg('hook:append-stats', e);
@@ -188,6 +219,7 @@ try {
188
219
  var state = harnessAnalyzer.analyzeTranscript(sessionFile, {
189
220
  sessionId: sessionId,
190
221
  cwd: cwd,
222
+ entries: entries,
191
223
  });
192
224
  if (state) harnessAnalyzer.writeState(state);
193
225
  } catch (e) {
package/src/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createReadStream } from 'node:fs';
1
+ import { createReadStream, openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { createInterface } from 'node:readline';
4
4
  import { join, isAbsolute } from 'node:path';
@@ -141,8 +141,96 @@ export async function parseSessionFile(filePath) {
141
141
  * @param {string} filePath absolute path to the session JSONL
142
142
  * @returns {Promise<Date|null>}
143
143
  */
144
+ // The statusline calls this every refresh for the live session, whose file
145
+ // only grows. Streaming the whole transcript to find its last user entry made
146
+ // a 65MB session cost a full re-read per tick, so the tail is read first in
147
+ // bounded chunks; the full scan below is the fallback when the last user entry
148
+ // sits further back than the cap (a long run of tool turns, or a corrupt tail).
149
+ const TAIL_CHUNK = 64 * 1024;
150
+ const TAIL_CAP = 4 * 1024 * 1024;
151
+
152
+ /**
153
+ * Walk a session JSONL backwards in bounded chunks, newest line first, and
154
+ * return the first non-null value `visit` produces. `hint` is a cheap substring
155
+ * test that skips parsing lines that cannot match.
156
+ *
157
+ * `found: false` means the cap was reached before the answer or the start of
158
+ * the file: the caller decides whether to fall back to a full scan or give up.
159
+ */
160
+ function scanTail(filePath, hint, visit) {
161
+ let fd = null;
162
+ try {
163
+ fd = openSync(filePath, 'r');
164
+ const size = fstatSync(fd).size;
165
+ if (size === 0) return { found: true, value: null };
166
+ let end = size;
167
+ let carry = '';
168
+ while (end > 0 && size - end < TAIL_CAP) {
169
+ const start = Math.max(0, end - TAIL_CHUNK);
170
+ const buf = Buffer.alloc(end - start);
171
+ readSync(fd, buf, 0, buf.length, start);
172
+ const text = buf.toString('utf8') + carry;
173
+ const lines = text.split('\n');
174
+ // The first piece may be a partial line; keep it for the next chunk
175
+ // unless this chunk reached the start of the file.
176
+ carry = start > 0 ? lines.shift() : '';
177
+ for (let i = lines.length - 1; i >= 0; i--) {
178
+ const line = lines[i];
179
+ if (!line || !line.includes(hint)) continue;
180
+ try {
181
+ const value = visit(JSON.parse(line));
182
+ if (value != null) return { found: true, value };
183
+ } catch { /* partial or malformed line — keep walking back */ }
184
+ }
185
+ end = start;
186
+ }
187
+ // Reached the start of the file without a match: there is none.
188
+ if (end === 0) return { found: true, value: null };
189
+ return { found: false, value: null };
190
+ } catch {
191
+ return { found: false, value: null };
192
+ } finally {
193
+ if (fd !== null) try { closeSync(fd); } catch { /* already closed */ }
194
+ }
195
+ }
196
+
197
+ function lastUserTsFromTail(filePath) {
198
+ const { found, value } = scanTail(filePath, '"type":"user"', (entry) =>
199
+ (entry.type === 'user' && entry.timestamp) || null);
200
+ return { found, ts: value };
201
+ }
202
+
203
+ /**
204
+ * Model id of the most recent assistant turn in a session JSONL.
205
+ *
206
+ * The UserPromptSubmit payload names the session but not its model, and the
207
+ * statusline snapshot is a single file shared by every session on the machine —
208
+ * with two sessions open, the last one to tick decides what it says. The
209
+ * transcript is the only per-session answer, so a caller that must not confuse
210
+ * two concurrent sessions reads it here first.
211
+ *
212
+ * Tail-only by design: this runs inside a prompt hook, so a session whose last
213
+ * 4MB carries no assistant turn returns null rather than re-reading the file.
214
+ * `<synthetic>` is skipped for the same reason session-records.js skips it —
215
+ * it marks a locally generated stub, not a model that answered.
216
+ *
217
+ * @param {string} filePath absolute path to the session JSONL
218
+ * @returns {string|null}
219
+ */
220
+ export function getSessionModel(filePath) {
221
+ if (!filePath) return null;
222
+ const { value } = scanTail(filePath, '"model"', (entry) => {
223
+ if (entry.type !== 'assistant') return null;
224
+ const model = entry.message && entry.message.model;
225
+ return typeof model === 'string' && model && model !== '<synthetic>' ? model : null;
226
+ });
227
+ return value;
228
+ }
229
+
144
230
  export async function getLastUserMessageTime(filePath) {
145
231
  let lastUserTs = null;
232
+ const tail = lastUserTsFromTail(filePath);
233
+ if (tail.found) return tail.ts ? new Date(tail.ts) : null;
146
234
  try {
147
235
  const rl = createInterface({
148
236
  input: createReadStream(filePath, { encoding: 'utf8' }),
@@ -25,14 +25,57 @@
25
25
  * A hint that fires on the wrong request costs more than one that stays quiet.
26
26
  */
27
27
 
28
- import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
28
+ import { categorize, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
29
29
  import { loadModelRules, budgetCapPhrase } from './model-rules.js';
30
30
  import { agentPhrase, agentPhraseEn } from './agents.js';
31
31
  import { userLanguage } from './config.js';
32
+ import { aliasForRole, resolveModelAlias } from './model-alias.js';
33
+ import { isRecognizedModelId, modelRank } from './cost.js';
34
+ import { loadRecentSnapshot } from './caps-cache.js';
35
+ import { getSessionModel } from './parser.js';
36
+
37
+ const PASTE_MIN_LINES = 8;
38
+ const LOG_SHAPE_RE = /^\s+at\s|\b(error|exception|traceback|warn(ing)?|fatal)\b|^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/im;
39
+ export function looksPasted(text) {
40
+ const lines = (String(text).match(/\n/g) || []).length;
41
+ return lines >= PASTE_MIN_LINES || LOG_SHAPE_RE.test(text);
42
+ }
32
43
 
33
44
  /** Shortest text worth classifying; below this a prompt is an ack, not a task. */
34
45
  const MIN_LEN = 8;
35
46
 
47
+ /**
48
+ * Price rank of whatever model is answering this session, or null when it
49
+ * cannot be established.
50
+ *
51
+ * Only a recognized id counts. `modelRank()` prices an unknown string as Sonnet
52
+ * so a cost estimate always has an answer, but here that guess would silence
53
+ * every T1 hint behind a gateway whose house alias (`prod-large`) names no
54
+ * Claude family — so an unrecognized id returns null and the hint keeps its
55
+ * previous behaviour instead.
56
+ *
57
+ * Sources, most trustworthy first: the session's own transcript, the gateway
58
+ * model environment, and finally the statusline snapshot, which is shared by
59
+ * every session on the machine and therefore only consulted last.
60
+ *
61
+ * @returns {number|null}
62
+ */
63
+ export function sessionModelRank({ env = process.env, transcriptPath = null, snapshot } = {}) {
64
+ const rankOf = (raw) => {
65
+ if (!raw) return null;
66
+ if (isRecognizedModelId(raw)) return modelRank(raw);
67
+ // An opaque gateway id can still be priced through profile-map.json.
68
+ const mapped = resolveModelAlias(raw, { env });
69
+ return isRecognizedModelId(mapped) ? modelRank(mapped) : null;
70
+ };
71
+ const fromTranscript = rankOf(getSessionModel(transcriptPath));
72
+ if (fromTranscript !== null) return fromTranscript;
73
+ const fromEnv = rankOf(aliasForRole('main', env));
74
+ if (fromEnv !== null) return fromEnv;
75
+ const snap = snapshot === undefined ? loadRecentSnapshot() : snapshot;
76
+ return rankOf(snap && snap.model);
77
+ }
78
+
36
79
  /**
37
80
  * @param {string} text the prompt just submitted
38
81
  * @param {object} [opts]
@@ -41,9 +84,12 @@ const MIN_LEN = 8;
41
84
  * @param {string} [opts.root] project root used to look for a subagent's .md.
42
85
  * The hint names the agent when that file exists and only its model tier when
43
86
  * it does not, so a caller that needs a predictable phrase pins this.
87
+ * @param {number|null} [opts.sessionRank] price rank of the model reading the
88
+ * hint (see sessionModelRank). null means unknown, and an unknown session
89
+ * model filters nothing.
44
90
  * @returns {string|null} the line to inject, or null to stay quiet
45
91
  */
46
- export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
92
+ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null } = {}) {
47
93
  const t = String(text || '').trim();
48
94
  if (t.length < MIN_LEN) return null;
49
95
  // Judgement and irreversible work stay on the top tier. This check comes
@@ -56,13 +102,29 @@ export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
56
102
 
57
103
  const cat = categorize(t, null);
58
104
  if (!cat) return null;
105
+ // Length alone makes a paste offline, where the tool mix confirms it. At
106
+ // prompt time a long text is as often a written spec as a pasted log, and a
107
+ // spec is not haiku work — so it must also look pasted: many lines, or the
108
+ // shape of a log or stack trace. Otherwise stay quiet rather than fall
109
+ // through to keyword scoring, which a long request trips by accident.
110
+ if (cat.id === 'paste' && !looksPasted(t)) return null;
59
111
 
60
112
  const all = rules || loadModelRules().rules || [];
61
113
  const matched = all.filter((r) => r && r.category === cat.id);
62
114
  if (!matched.length) return null;
115
+ // A rule only saves anything when its target tier is cheaper than the model
116
+ // reading the hint. A T1 rule states "delegate to model: sonnet", which in a
117
+ // Sonnet session instructs a delegation worth nothing — and contradicts the
118
+ // registry's own clause that a session already at the target tier does not
119
+ // delegate. So drop the tiers this session cannot profit from; if that leaves
120
+ // nothing (a haiku session), stay quiet.
121
+ const usable = sessionRank === null
122
+ ? matched
123
+ : matched.filter((r) => worthDelegating(r.tier, sessionRank));
124
+ if (!usable.length) return null;
63
125
 
64
- const t2 = matched.find((r) => r.tier === 'T2');
65
- const t1 = matched.find((r) => r.tier === 'T1');
126
+ const t2 = usable.find((r) => r.tier === 'T2');
127
+ const t1 = usable.find((r) => r.tier === 'T1');
66
128
  const ko = lang === 'ko';
67
129
  const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
68
130
  // `root` is threaded through to agentPhrase, which names the subagent only
package/src/route-scan.js CHANGED
@@ -53,8 +53,8 @@ export const MIN_DELEGABLE_OUT = 100;
53
53
  // delegating them defeats the harness's default-safe-path rule.
54
54
  // Measured 2026-09-20 on 1,276 local prompts: '퍼블리시' (npm publish, 15 hits)
55
55
  // and '이유' (root-cause asks) were slipping through as run/translate, and a
56
- // bare '머지' matched inside '나머지' ("나머지 진행하자" ×7) — hence the lookbehind.
57
- export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜 |이유|제출|배포|출시|퍼블리시|릴리스|릴리즈|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
56
+ // bare '머지' matched inside '나머지' ("나머지 진행하자" ×7), hence the lookbehind.
57
+ export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜(?=[\s?]|$)|이유|제출|배포|출시|퍼블리시|릴리[스즈](?!\s*노트)|(?<!나)머지|analyze|compare|evaluate|architect|refactor|submit|deploy|release|publish|merge/i;
58
58
 
59
59
  // Implementation asks phrased around a run/check verb ("설치와 동시에 설정되도록
60
60
  // 하자", "피드백 확인해주고 아이콘도 만들자"). Editing is not delegable, and at
@@ -62,7 +62,9 @@ export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개
62
62
  // classification does not use this: a write-dominant tool mix already excludes
63
63
  // such episodes in behaviorPool(). Same corpus: 21 of 1,276 prompts gated, all
64
64
  // but two of them implementation requests that had been read as run/check.
65
- export const EDIT_RE = /구현|추가하자|만들자|넣자|넣어줘|수정해|고쳐|바꿔|되도록|되게|리팩/;
65
+ // `되게` alone is also the colloquial intensifier ("되게 느린데") and `되도록`
66
+ // the adverb ("되도록 빨리"); only the causative shape means "make it so".
67
+ export const EDIT_RE = /구현|추가하자|만들자|넣자|넣어줘|수정해|고쳐|바꿔|(?:되도록|되게)\s*(?:하|해|만들)|리팩/;
66
68
  // A pattern must recur this often before we nag about it.
67
69
  export const MIN_RECURRENCE = 3;
68
70
 
@@ -185,7 +187,11 @@ function keywordScore(cat, text) {
185
187
 
186
188
  // Episodes that are not user-delegable requests: bare continuations, injected
187
189
  // notifications, image pastes. These are easy but there is nothing to route.
188
- const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)\b/i;
190
+ // `\b` is ASCII-only in JS, so after a Hangul syllable it never matches and
191
+ // every Korean ack in this list was silently ignored (measured 2026-09-21:
192
+ // "계속 진행", "응 다 정리해도돼" both entered tiering as episodes). The
193
+ // lookahead does what `\b` was meant to: the ack ends the word.
194
+ const SKIP_RE = /^(계속|이어서|continue|다음|proceed|진행|응|네|넵|ok|okay|yes|ㄱ+|고고)(?=\s|$|[.!?,~])/i;
189
195
  const SKIP_PREFIX = ['<task-notification', '<system', '[Image:', '<local-command'];
190
196
 
191
197
  // Single source of truth for the state dir (paths.js). A local copy used to
@@ -226,7 +232,7 @@ export function categorize(text, toolCounts) {
226
232
  return null;
227
233
  }
228
234
 
229
- function isSkippable(text) {
235
+ export function isSkippable(text) {
230
236
  if (!text) return true;
231
237
  if (SKIP_RE.test(text.trim())) return true;
232
238
  return SKIP_PREFIX.some((p) => text.startsWith(p));
@@ -384,6 +390,20 @@ export function tierOf(ep, category, th) {
384
390
  * Scan transcripts and build delegation candidates.
385
391
  * Returns the cache object (also written to disk).
386
392
  */
393
+ /**
394
+ * Whether a rule was already registered when a subagent run started. The
395
+ * ledger credits a rule with every matching run in the scan window, and a rule
396
+ * promoted today would otherwise claim two weeks of runs that happened before
397
+ * it existed — and keep claiming them, since the ledger is keyed by run path.
398
+ * A rule with no promotion date (older registries) is trusted as before.
399
+ */
400
+ export function ruleCouldHaveRouted(rule, run) {
401
+ if (!rule?.promotedAt || !Number.isFinite(run?.startedAt)) return true;
402
+ const promoted = Date.parse(rule.promotedAt);
403
+ if (!Number.isFinite(promoted)) return true;
404
+ return run.startedAt >= promoted;
405
+ }
406
+
387
407
  export async function runRouteScan({ days = 14 } = {}) {
388
408
  const files = await discoverSessionFiles({ days });
389
409
 
@@ -502,8 +522,13 @@ export async function runRouteScan({ days = 14 } = {}) {
502
522
  g.count += 1;
503
523
  for (const m of ep.models) g.models.add(m);
504
524
  if (!g.projectPath && ep.cwd) g.projectPath = ep.cwd;
505
- if (!g.example || (ep.text.length < g.example.length && ep.text.length > 10)) {
525
+ // Shortest text longer than 10 chars wins; a 3-char first episode used to
526
+ // be accepted unconditionally and then never replaced.
527
+ const usable = ep.text.length > 10;
528
+ if ((!g.example && (usable || !g.exampleUsable))
529
+ || (usable && (!g.exampleUsable || ep.text.length < g.example.length))) {
506
530
  g.example = ep.text.slice(0, 80).replace(/\s+/g, ' ');
531
+ g.exampleUsable = usable;
507
532
  }
508
533
  groups.set(key, g);
509
534
  }
@@ -600,6 +625,7 @@ export async function runRouteScan({ days = 14 } = {}) {
600
625
  // not against whatever the session's priciest model happened to be.
601
626
  const rule = ruleForRun(runTier, cat.id, projectDir);
602
627
  if (!rule) continue; // no rule routed this run — not our saving to claim
628
+ if (!ruleCouldHaveRouted(rule, run)) continue; // it did not exist yet
603
629
  // Priced below, after this scan's baselines have been written back to
604
630
  // the registry — see the note at the ledger write.
605
631
  if (!isRecognizedModelId(run.model)) continue;
@@ -808,9 +834,9 @@ export async function shouldRescan(cache, { days = 14, afterDelegation = false }
808
834
  */
809
835
  export function tierLabel(tier, lang = 'ko') {
810
836
  const ko = {
811
- T2: '단순 작업 — haiku급이면 충분',
812
- T1: '중간 난도 — sonnet급이면 충분',
813
- T0: '고난도 — 지금 모델 유지',
837
+ T2: '단순 작업, haiku급이면 충분',
838
+ T1: '중간 난도, sonnet급이면 충분',
839
+ T0: '고난도, 지금 모델 유지',
814
840
  };
815
841
  const en = {
816
842
  T2: 'simple — haiku-class is enough',
@@ -52,6 +52,12 @@ export function iconForceRequested({ cfg = {}, env = process.env } = {}) {
52
52
  * `icon` instead, so that is read as a fallback: absent means icons, since icons
53
53
  * are the default for a fresh install.
54
54
  */
55
+ /** Whether the config carries an explicit label choice (new or legacy key). */
56
+ export function hasStoredPreference(cfg = {}) {
57
+ if (cfg.labels === 'icon' || cfg.labels === 'narrow' || cfg.labels === 'text') return true;
58
+ return typeof cfg.icon === 'boolean';
59
+ }
60
+
55
61
  export function labelPreference(cfg = {}) {
56
62
  if (cfg.labels === 'icon' || cfg.labels === 'narrow' || cfg.labels === 'text') return cfg.labels;
57
63
  return cfg.icon === false ? 'text' : 'icon';
@@ -71,7 +77,8 @@ export function labelPreference(cfg = {}) {
71
77
  * `flag:--icon`, `config`.
72
78
  *
73
79
  * Precedence: explicit opt-out, then the IntelliJ guard (and the escape hatch
74
- * out of it, which applies only there), then `--icon`, then persisted config.
80
+ * out of it, which applies only there), then persisted config, then `--icon`
81
+ * (the installed command's default when nothing is stored).
75
82
  */
76
83
  export function resolveLabelMode({ hasFlag = () => false, cfg = {}, env = process.env } = {}) {
77
84
  // An explicit opt-out wins everywhere. Nothing below should talk a user out
@@ -90,6 +97,10 @@ export function resolveLabelMode({ hasFlag = () => false, cfg = {}, env = proces
90
97
  // is a character the IDE font actually has, so the line does not garble.
91
98
  return { mode: 'narrow', reason: 'intellij-narrow' };
92
99
  }
93
- if (hasFlag('--icon')) return { mode: 'icon', reason: 'flag:--icon' };
100
+ // The installed statusline command carries `--icon`, so the flag is present
101
+ // on every refresh. If it beat the config, `sprag mode text` and `narrow`
102
+ // could never take effect there — the mode command promises they apply on
103
+ // the next refresh. So the flag decides only when nothing is stored.
104
+ if (hasFlag('--icon') && !hasStoredPreference(cfg)) return { mode: 'icon', reason: 'flag:--icon' };
94
105
  return { mode: pref, reason: 'config' };
95
106
  }
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
29
29
  import { spawn } from 'node:child_process';
30
- import { join, dirname } from 'node:path';
30
+ import { join, dirname, sep } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { userDataDir } from './paths.js';
33
33
  import { debug } from './debug.js';
@@ -375,7 +375,9 @@ export function dismissUpdate(version) {
375
375
  * overwhelming majority of copies got here.
376
376
  */
377
377
  export function upgradeCommand() {
378
- const here = dirname(fileURLToPath(import.meta.url));
378
+ // Forward slashes on every platform, so the manager checks below hold on
379
+ // Windows too instead of reporting every install as npm.
380
+ const here = dirname(fileURLToPath(import.meta.url)).split(sep).join('/');
379
381
  // A legacy copy upgrades by moving to the canonical name. Installing the new
380
382
  // name alone would leave two copies fighting over the same `sprag` binary,
381
383
  // so the old one comes off first.
@@ -39,7 +39,8 @@ const KNOWN = {
39
39
  };
40
40
 
41
41
  function deriveShort(key) {
42
- // "five_hour" → "5H"; "seven_day_sonnet" → "7DS"; arbitrary key → uppercase initials
42
+ // "5_hour" → "5H"; "7_day_sonnet" → "7DS"; a key with no leading digit
43
+ // ("five_hour") falls through to uppercase initials ("FH")
43
44
  const m = key.match(/^(\d+)_?([a-z]+)/);
44
45
  if (m) {
45
46
  const num = m[1];