sprag-cli 3.50.1 → 3.51.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 +2 -2
- package/package.json +1 -1
- package/src/caps-cache.js +6 -2
- package/src/commands/harness.js +3 -1
- package/src/commands/install.js +2 -2
- package/src/commands/korean.js +2 -2
- package/src/compact-window.js +7 -1
- package/src/cost.js +34 -0
- package/src/formatters/statusline.js +3 -1
- package/src/harness-analyzer.cjs +7 -2
- package/src/hook-manager.js +39 -12
- package/src/hook.cjs +34 -2
- package/src/parser.js +48 -1
- package/src/route-inject.js +13 -0
- package/src/route-scan.js +35 -9
- package/src/statusline-mode.js +13 -2
- package/src/update-check.js +4 -2
- package/src/window-labels.js +2 -1
package/bin/cli.js
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
sessionMetrics,
|
|
53
53
|
diagnoseSession,
|
|
54
54
|
} from '../src/stats.js';
|
|
55
|
-
import {
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "3.51.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
|
-
|
|
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
|
}
|
package/src/commands/harness.js
CHANGED
|
@@ -161,7 +161,9 @@ export async function run({ args, hasFlag }) {
|
|
|
161
161
|
}
|
|
162
162
|
process.exit(1);
|
|
163
163
|
}
|
|
164
|
-
|
|
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
|
package/src/commands/install.js
CHANGED
|
@@ -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.
|
|
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).');
|
package/src/commands/korean.js
CHANGED
|
@@ -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
|
|
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'
|
package/src/compact-window.js
CHANGED
|
@@ -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')
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
package/src/harness-analyzer.cjs
CHANGED
|
@@ -218,7 +218,9 @@ function computePevSkip(entries) {
|
|
|
218
218
|
|
|
219
219
|
function analyzeTranscript(transcriptPath, opts) {
|
|
220
220
|
opts = opts || {};
|
|
221
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/hook-manager.js
CHANGED
|
@@ -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))
|
|
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
|
|
42
|
-
//
|
|
43
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse
|
|
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
|
|
81
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse
|
|
82
|
-
|
|
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
|
|
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,55 @@ 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
|
+
function lastUserTsFromTail(filePath) {
|
|
153
|
+
let fd = null;
|
|
154
|
+
try {
|
|
155
|
+
fd = openSync(filePath, 'r');
|
|
156
|
+
const size = fstatSync(fd).size;
|
|
157
|
+
if (size === 0) return { found: true, ts: null };
|
|
158
|
+
let end = size;
|
|
159
|
+
let carry = '';
|
|
160
|
+
while (end > 0 && size - end < TAIL_CAP) {
|
|
161
|
+
const start = Math.max(0, end - TAIL_CHUNK);
|
|
162
|
+
const buf = Buffer.alloc(end - start);
|
|
163
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
164
|
+
const text = buf.toString('utf8') + carry;
|
|
165
|
+
const lines = text.split('\n');
|
|
166
|
+
// The first piece may be a partial line; keep it for the next chunk
|
|
167
|
+
// unless this chunk reached the start of the file.
|
|
168
|
+
carry = start > 0 ? lines.shift() : '';
|
|
169
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
170
|
+
const line = lines[i];
|
|
171
|
+
if (!line || !line.includes('"type":"user"')) continue;
|
|
172
|
+
try {
|
|
173
|
+
const entry = JSON.parse(line);
|
|
174
|
+
if (entry.type === 'user' && entry.timestamp) return { found: true, ts: entry.timestamp };
|
|
175
|
+
} catch { /* partial or malformed line — keep walking back */ }
|
|
176
|
+
}
|
|
177
|
+
end = start;
|
|
178
|
+
}
|
|
179
|
+
// Reached the start of the file without a user entry: there is none.
|
|
180
|
+
if (end === 0) return { found: true, ts: null };
|
|
181
|
+
return { found: false, ts: null };
|
|
182
|
+
} catch {
|
|
183
|
+
return { found: false, ts: null };
|
|
184
|
+
} finally {
|
|
185
|
+
if (fd !== null) try { closeSync(fd); } catch { /* already closed */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
144
189
|
export async function getLastUserMessageTime(filePath) {
|
|
145
190
|
let lastUserTs = null;
|
|
191
|
+
const tail = lastUserTsFromTail(filePath);
|
|
192
|
+
if (tail.found) return tail.ts ? new Date(tail.ts) : null;
|
|
146
193
|
try {
|
|
147
194
|
const rl = createInterface({
|
|
148
195
|
input: createReadStream(filePath, { encoding: 'utf8' }),
|
package/src/route-inject.js
CHANGED
|
@@ -30,6 +30,13 @@ import { loadModelRules, budgetCapPhrase } from './model-rules.js';
|
|
|
30
30
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
31
31
|
import { userLanguage } from './config.js';
|
|
32
32
|
|
|
33
|
+
const PASTE_MIN_LINES = 8;
|
|
34
|
+
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;
|
|
35
|
+
export function looksPasted(text) {
|
|
36
|
+
const lines = (String(text).match(/\n/g) || []).length;
|
|
37
|
+
return lines >= PASTE_MIN_LINES || LOG_SHAPE_RE.test(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
/** Shortest text worth classifying; below this a prompt is an ack, not a task. */
|
|
34
41
|
const MIN_LEN = 8;
|
|
35
42
|
|
|
@@ -56,6 +63,12 @@ export function routeHint(text, { rules, lang = userLanguage(), root } = {}) {
|
|
|
56
63
|
|
|
57
64
|
const cat = categorize(t, null);
|
|
58
65
|
if (!cat) return null;
|
|
66
|
+
// Length alone makes a paste offline, where the tool mix confirms it. At
|
|
67
|
+
// prompt time a long text is as often a written spec as a pasted log, and a
|
|
68
|
+
// spec is not haiku work — so it must also look pasted: many lines, or the
|
|
69
|
+
// shape of a log or stack trace. Otherwise stay quiet rather than fall
|
|
70
|
+
// through to keyword scoring, which a long request trips by accident.
|
|
71
|
+
if (cat.id === 'paste' && !looksPasted(t)) return null;
|
|
59
72
|
|
|
60
73
|
const all = rules || loadModelRules().rules || [];
|
|
61
74
|
const matched = all.filter((r) => r && r.category === cat.id);
|
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)
|
|
57
|
-
export const ESCALATE_RE = /설계|아키텍처|리팩토링|원인 분석|개선할|검토해보|비교|왜
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '단순
|
|
812
|
-
T1: '중간
|
|
813
|
-
T0: '
|
|
837
|
+
T2: '단순 작업, haiku급이면 충분',
|
|
838
|
+
T1: '중간 난도, sonnet급이면 충분',
|
|
839
|
+
T0: '고난도, 지금 모델 유지',
|
|
814
840
|
};
|
|
815
841
|
const en = {
|
|
816
842
|
T2: 'simple — haiku-class is enough',
|
package/src/statusline-mode.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/update-check.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/window-labels.js
CHANGED
|
@@ -39,7 +39,8 @@ const KNOWN = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
function deriveShort(key) {
|
|
42
|
-
// "
|
|
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];
|