sprag-cli 3.56.0 → 3.58.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/README.ko.md +60 -40
- package/README.md +63 -41
- package/bin/cli.js +65 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +14 -26
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/commands/install.js +24 -13
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +315 -158
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/prompt.js +28 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +78 -26
- package/src/savings-ledger.js +33 -5
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/statusline-base.js +37 -0
- package/src/stdin-payload.js +36 -5
package/src/harness.js
CHANGED
|
@@ -214,16 +214,38 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
|
|
|
214
214
|
const block = harnessClaudeMdBlock(scope);
|
|
215
215
|
if (existsSync(cmPath)) {
|
|
216
216
|
const existing = readFileSync(cmPath, 'utf8');
|
|
217
|
-
|
|
217
|
+
const hasBegin = existing.includes(HARNESS_BLOCK_BEGIN);
|
|
218
|
+
const blockRe = new RegExp(
|
|
219
|
+
`${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
|
|
220
|
+
'm',
|
|
221
|
+
);
|
|
222
|
+
const hasCompleteBlock = blockRe.test(existing);
|
|
223
|
+
if (hasBegin && !hasCompleteBlock) {
|
|
224
|
+
// A begin marker with no end marker after it: the extent of our block is
|
|
225
|
+
// unknowable, so replacing or appending would either eat the user's text
|
|
226
|
+
// or leave a second block beside the broken one. Touch nothing.
|
|
227
|
+
result.skipped.push(`${cmPath} (harness block has a begin marker but no end marker — fix it manually)`);
|
|
228
|
+
result.brokenBlock = true;
|
|
229
|
+
} else if (hasCompleteBlock) {
|
|
218
230
|
// Already has a harness block — replace it in-place, preserving the
|
|
219
|
-
// user's other content above/below.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const next = existing.replace(
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
// user's other content above/below. This holds with or without --force:
|
|
232
|
+
// --force used to fall through to the append branch, which added a second
|
|
233
|
+
// block on every run.
|
|
234
|
+
// A replacer function, not the string: `$&` or `$1` in a block would be
|
|
235
|
+
// read as a replacement pattern.
|
|
236
|
+
const next = existing.replace(blockRe, () => block);
|
|
237
|
+
if (next === existing) {
|
|
238
|
+
result.skipped.push(`${cmPath} (harness block already up to date)`);
|
|
239
|
+
} else {
|
|
240
|
+
if (force) {
|
|
241
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
242
|
+
const bak = `${cmPath}.bak-${stamp}`;
|
|
243
|
+
writeFileSync(bak, existing);
|
|
244
|
+
result.backedUp.push(bak);
|
|
245
|
+
}
|
|
246
|
+
writeFileSync(cmPath, next);
|
|
247
|
+
result.wrote.push(cmPath + ' (block updated in place)');
|
|
248
|
+
}
|
|
227
249
|
} else {
|
|
228
250
|
// Backup as safety net, then APPEND the harness block to existing
|
|
229
251
|
// content (do not clobber). Users keep all their prior CLAUDE.md content;
|
|
@@ -295,7 +317,16 @@ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false,
|
|
|
295
317
|
|
|
296
318
|
if (existsSync(cmPath)) {
|
|
297
319
|
const existing = readFileSync(cmPath, 'utf8');
|
|
298
|
-
|
|
320
|
+
const beginIdx = existing.indexOf(HARNESS_BLOCK_BEGIN);
|
|
321
|
+
const uninitRe = new RegExp(
|
|
322
|
+
`\\n*${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
|
|
323
|
+
'm',
|
|
324
|
+
);
|
|
325
|
+
if (beginIdx !== -1 && !uninitRe.test(existing)) {
|
|
326
|
+
// Begin marker without an end marker: nothing to cut safely, and writing
|
|
327
|
+
// the file back unchanged would still report a removal.
|
|
328
|
+
result.skipped.push(`${cmPath} (harness block has a begin marker but no end marker — fix it manually)`);
|
|
329
|
+
} else if (beginIdx !== -1) {
|
|
299
330
|
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
300
331
|
const bak = `${cmPath}.bak-${stamp}`;
|
|
301
332
|
writeFileSync(bak, existing);
|
|
@@ -575,7 +606,7 @@ export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project',
|
|
|
575
606
|
* has explicitly disabled harness display, or when there's no CLAUDE.md and
|
|
576
607
|
* no .claude/ at all (silent in non-init'd projects so we don't nag).
|
|
577
608
|
*/
|
|
578
|
-
export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
609
|
+
export function harnessStatusForStatusline(cfg, { root, liveWindow = null } = {}) {
|
|
579
610
|
if (cfg && cfg.harness && cfg.harness.enabled === false) return null;
|
|
580
611
|
const projectRoot = root || findProjectRoot();
|
|
581
612
|
const status = harnessStatus(projectRoot);
|
|
@@ -620,7 +651,7 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
|
620
651
|
// exempt — the setting cannot change anything for them.
|
|
621
652
|
if (!warning) {
|
|
622
653
|
try {
|
|
623
|
-
warning = compactWindowWarningForStatusline(projectRoot, cfg);
|
|
654
|
+
warning = compactWindowWarningForStatusline(projectRoot, cfg, { liveWindow });
|
|
624
655
|
} catch { /* settings unreadable — stay silent */ }
|
|
625
656
|
}
|
|
626
657
|
// Below session-quality warnings: a promoted delegation rule whose
|
package/src/history.js
CHANGED
|
@@ -50,7 +50,8 @@ function hms(d = new Date()) {
|
|
|
50
50
|
|
|
51
51
|
function loadState() {
|
|
52
52
|
try {
|
|
53
|
-
|
|
53
|
+
const s = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
54
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? s : { chip: null, ts: null };
|
|
54
55
|
} catch {
|
|
55
56
|
return { chip: null, ts: null };
|
|
56
57
|
}
|
|
@@ -218,7 +219,10 @@ export function recordChip(chip, contextHints = {}) {
|
|
|
218
219
|
ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
|
|
219
220
|
}
|
|
220
221
|
appendDayLine(en, ko, now, tips);
|
|
221
|
-
|
|
222
|
+
// The state file also holds one `cap_<window>` slot per rate-limit window
|
|
223
|
+
// (recordCapTransition). Saving the chip alone wiped them, so the next render
|
|
224
|
+
// saw a window at 90% as newly crossed and logged the cap warning again.
|
|
225
|
+
saveState({ ...state, chip: current, ts: now.toISOString() });
|
|
222
226
|
return true;
|
|
223
227
|
}
|
|
224
228
|
|
package/src/hook-manager.js
CHANGED
|
@@ -9,21 +9,26 @@
|
|
|
9
9
|
* removes the copy, the staleness, and the orphan in one move.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { rm } from 'node:fs/promises';
|
|
13
|
+
import { mkdirSync } from 'node:fs';
|
|
13
14
|
import { join } from 'node:path';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
15
|
import { CLI_NAME } from './cli-name.js';
|
|
16
|
+
import { claudeUserDir } from './paths.js';
|
|
17
|
+
import { isOurSubcommand, isLegacyCacheMonitorCommand, readSettings, writeSettings } from './installer.js';
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
// Resolved per call, not at import: the paths follow the current home directory.
|
|
20
|
+
const settingsPath = () => join(claudeUserDir(), 'settings.json');
|
|
18
21
|
// Legacy copied-file location — removed on install/uninstall so machines that
|
|
19
22
|
// installed an older version don't keep a dead hook script around.
|
|
20
|
-
const
|
|
21
|
-
const HOOK_MARKER = 'cache-monitor-hook';
|
|
23
|
+
const legacyHookDest = () => join(claudeUserDir(), 'cache-monitor-hook.cjs');
|
|
22
24
|
|
|
25
|
+
// Ours means the current subcommand form (`<cli> --hook-run ...`) or the exact
|
|
26
|
+
// legacy copied-file form. A command that only mentions either string, such as
|
|
27
|
+
// `echo "sprag --hook-run"`, belongs to somebody else.
|
|
23
28
|
function isCacheMonitorHook(nh) {
|
|
24
29
|
const cmd = nh?.command;
|
|
25
30
|
if (typeof cmd !== 'string') return false;
|
|
26
|
-
return cmd
|
|
31
|
+
return isOurSubcommand(cmd, '--hook-run') || isLegacyCacheMonitorCommand(cmd);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -51,21 +56,31 @@ function countCacheMonitorHooks(groups) {
|
|
|
51
56
|
return n;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
function fail(message) {
|
|
60
|
+
console.log(message);
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export async function installHook({ threshold = 0.7 } = {}) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
const SETTINGS_PATH = settingsPath();
|
|
67
|
+
// Only a missing file starts from an empty object. A file that exists but
|
|
68
|
+
// cannot be parsed (or is not an object) is the user's configuration;
|
|
69
|
+
// replacing it with a fresh object would wipe everything in it.
|
|
70
|
+
const read = readSettings(SETTINGS_PATH);
|
|
71
|
+
if (read.state === 'unusable') {
|
|
72
|
+
return fail(`✗ ${SETTINGS_PATH} is ${read.reason} — hook not installed, file left untouched.`);
|
|
61
73
|
}
|
|
74
|
+
const settings = read.settings;
|
|
62
75
|
|
|
63
|
-
if (
|
|
76
|
+
if (settings.hooks === undefined || settings.hooks === null) settings.hooks = {};
|
|
77
|
+
if (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
|
|
78
|
+
return fail(`✗ hooks in ${SETTINGS_PATH} is not an object — hook not installed, file left untouched.`);
|
|
79
|
+
}
|
|
64
80
|
if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
|
|
65
81
|
// Someone else's data in a shape we do not understand; overwriting it with
|
|
66
82
|
// [] would discard it. Leave the file alone and say so.
|
|
67
|
-
|
|
68
|
-
return;
|
|
83
|
+
return fail(`✗ hooks.PostToolUse in ${SETTINGS_PATH} is not an array — hook not installed, file left untouched.`);
|
|
69
84
|
}
|
|
70
85
|
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
71
86
|
|
|
@@ -84,10 +99,12 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
84
99
|
],
|
|
85
100
|
});
|
|
86
101
|
|
|
87
|
-
|
|
102
|
+
mkdirSync(claudeUserDir(), { recursive: true });
|
|
103
|
+
const writeProblem = writeSettings(SETTINGS_PATH, settings);
|
|
104
|
+
if (writeProblem) return fail(`✗ Hook not installed: ${writeProblem}`);
|
|
88
105
|
|
|
89
106
|
// Clean up the legacy copy left by older versions.
|
|
90
|
-
await rm(
|
|
107
|
+
await rm(legacyHookDest(), { force: true }).catch(() => {});
|
|
91
108
|
|
|
92
109
|
console.log(`✓ Hook installed (PostToolUse → ${CLI_NAME} --hook-run)`);
|
|
93
110
|
console.log(` Settings updated: ${SETTINGS_PATH}`);
|
|
@@ -96,29 +113,34 @@ export async function installHook({ threshold = 0.7 } = {}) {
|
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
export async function uninstallHook() {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
settings = JSON.parse(raw);
|
|
103
|
-
} catch {
|
|
116
|
+
const SETTINGS_PATH = settingsPath();
|
|
117
|
+
const read = readSettings(SETTINGS_PATH);
|
|
118
|
+
if (read.state === 'absent') {
|
|
104
119
|
console.log('No settings.json found, nothing to uninstall.');
|
|
105
120
|
return;
|
|
106
121
|
}
|
|
122
|
+
if (read.state === 'unusable') {
|
|
123
|
+
return fail(`✗ ${SETTINGS_PATH} is ${read.reason} — nothing removed, file left untouched.`);
|
|
124
|
+
}
|
|
125
|
+
const settings = read.settings;
|
|
107
126
|
|
|
108
127
|
if (Array.isArray(settings.hooks?.PostToolUse)) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
128
|
+
const removed = countCacheMonitorHooks(settings.hooks.PostToolUse);
|
|
129
|
+
if (removed > 0) {
|
|
130
|
+
settings.hooks.PostToolUse = withoutCacheMonitorHooks(settings.hooks.PostToolUse);
|
|
131
|
+
if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
|
|
132
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
133
|
+
|
|
134
|
+
const writeProblem = writeSettings(SETTINGS_PATH, settings);
|
|
135
|
+
if (writeProblem) return fail(`✗ Hook not removed: ${writeProblem}`);
|
|
136
|
+
console.log(`✓ Removed ${removed} hook(s) from settings.json`);
|
|
137
|
+
} else {
|
|
138
|
+
console.log('No cache-monitor hook found in settings.');
|
|
139
|
+
}
|
|
118
140
|
} else {
|
|
119
141
|
console.log('No cache-monitor hook found in settings.');
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
// The legacy copied hook file is dead weight either way.
|
|
123
|
-
await rm(
|
|
145
|
+
await rm(legacyHookDest(), { force: true }).catch(() => {});
|
|
124
146
|
}
|