sprag-cli 3.56.0 → 3.57.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 +1 -1
- package/README.md +1 -1
- package/bin/cli.js +58 -17
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +2 -2
- 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/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 +286 -153
- 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/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/stdin-payload.js +18 -4
package/src/doc2md.cjs
CHANGED
|
@@ -307,13 +307,19 @@ function managedPython() {
|
|
|
307
307
|
: path.join(dir, 'bin', 'python');
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
// Each spec carries an upper bound at the next major line. An open spec takes
|
|
311
|
+
// whatever is newest on the day someone first opens a document, so a breaking
|
|
312
|
+
// release (or a bad one) would reach every new machine with nothing in this
|
|
313
|
+
// repository having changed. The bounds are the lines verified here:
|
|
314
|
+
// markitdown 0.1.7, python-pptx 1.0.2, python-docx 1.2.0, openpyxl 3.1.5.
|
|
315
|
+
// pip gets them as plain argv, with no shell in between, so `<` is safe.
|
|
316
|
+
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]>=0.1,<0.2';
|
|
311
317
|
|
|
312
318
|
// Editing libraries, installed alongside the converter. Reading is doc2md's
|
|
313
319
|
// own job; editing is the agent's, done per-request with a short script
|
|
314
320
|
// against a COPY of the document. These are the libraries those scripts need,
|
|
315
321
|
// pre-installed so "swap the chart on slide 23" does not stall on pip.
|
|
316
|
-
const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
322
|
+
const EDIT_LIBS = ['python-pptx>=1.0,<2', 'python-docx>=1.1,<2', 'openpyxl>=3.1,<4'];
|
|
317
323
|
|
|
318
324
|
/**
|
|
319
325
|
* Build the managed venv and install markitdown into it.
|
|
@@ -999,15 +1005,22 @@ function decideForWrite(context, opts = {}) {
|
|
|
999
1005
|
return null;
|
|
1000
1006
|
}
|
|
1001
1007
|
|
|
1002
|
-
/**
|
|
1008
|
+
/**
|
|
1009
|
+
* The decision rendered as the JSON Claude Code expects on stdout.
|
|
1010
|
+
*
|
|
1011
|
+
* Only a refusal answers the permission question. A note about a document
|
|
1012
|
+
* that could not be converted says nothing about whether the read should be
|
|
1013
|
+
* allowed, and answering `allow` for it approved the call without the prompt
|
|
1014
|
+
* the user would otherwise have seen, sensitive-pattern files included. The
|
|
1015
|
+
* note goes out as context instead, which is also the only form that reaches
|
|
1016
|
+
* the model: an `allow` reason is shown to the user alone.
|
|
1017
|
+
*/
|
|
1003
1018
|
function formatHookOutput(decision) {
|
|
1004
1019
|
if (!decision) return null;
|
|
1005
1020
|
return JSON.stringify({
|
|
1006
|
-
hookSpecificOutput:
|
|
1007
|
-
hookEventName: 'PreToolUse',
|
|
1008
|
-
|
|
1009
|
-
permissionDecisionReason: decision.reason,
|
|
1010
|
-
},
|
|
1021
|
+
hookSpecificOutput: decision.deny
|
|
1022
|
+
? { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: decision.reason }
|
|
1023
|
+
: { hookEventName: 'PreToolUse', additionalContext: decision.reason },
|
|
1011
1024
|
});
|
|
1012
1025
|
}
|
|
1013
1026
|
|
|
@@ -1016,6 +1029,7 @@ module.exports = {
|
|
|
1016
1029
|
MAX_SOURCE_BYTES,
|
|
1017
1030
|
INSTALL_HINT,
|
|
1018
1031
|
MARKITDOWN_SPEC,
|
|
1032
|
+
EDIT_LIBS,
|
|
1019
1033
|
managedPython,
|
|
1020
1034
|
installConverter,
|
|
1021
1035
|
ensureConverterInstalled,
|
package/src/fig2md.cjs
CHANGED
|
@@ -32,6 +32,11 @@ const { spawnSync } = require('node:child_process');
|
|
|
32
32
|
// cmd.exe, where `^` is the escape character and would be eaten before npm
|
|
33
33
|
// ever saw it. The range is the same one caret means for a 0.x package.
|
|
34
34
|
const FIG_PARSER_SPEC = 'openfig-core@0.4.x';
|
|
35
|
+
// `--ignore-scripts`: the parser and its three dependencies are plain
|
|
36
|
+
// JavaScript and ship no install script, so nothing is lost, and a release of
|
|
37
|
+
// any of them that adds one cannot run code on the machine of someone who only
|
|
38
|
+
// opened a .fig file.
|
|
39
|
+
const FIG_INSTALL_ARGS = ['install', '--no-audit', '--no-fund', '--silent', '--ignore-scripts', FIG_PARSER_SPEC];
|
|
35
40
|
|
|
36
41
|
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
37
42
|
function managedFigDir(userDataDir) {
|
|
@@ -63,7 +68,7 @@ function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
|
63
68
|
// needs the shell. Elsewhere the shell is avoided, since the package spec
|
|
64
69
|
// would then go through shell quoting for no benefit.
|
|
65
70
|
const isWindows = process.platform === 'win32';
|
|
66
|
-
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm',
|
|
71
|
+
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm', FIG_INSTALL_ARGS, {
|
|
67
72
|
cwd: dir,
|
|
68
73
|
encoding: 'utf8',
|
|
69
74
|
timeout: 300_000,
|
|
@@ -184,6 +189,7 @@ async function convertFig(filePath, userDataDir) {
|
|
|
184
189
|
|
|
185
190
|
module.exports = {
|
|
186
191
|
FIG_PARSER_SPEC,
|
|
192
|
+
FIG_INSTALL_ARGS,
|
|
187
193
|
managedFigDir,
|
|
188
194
|
installFigParser,
|
|
189
195
|
convertFig,
|
|
@@ -304,9 +304,9 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
|
304
304
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
305
305
|
* CLAUDE.md, permission issue, etc. → null).
|
|
306
306
|
*/
|
|
307
|
-
function buildHarnessSeg(c, isIcon, g) {
|
|
307
|
+
function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
|
|
308
308
|
try {
|
|
309
|
-
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
309
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig(), { liveWindow });
|
|
310
310
|
if (!harnessInfo) return null;
|
|
311
311
|
const icon = isIcon ? g.harness : 'H';
|
|
312
312
|
if (harnessInfo.warning) {
|
|
@@ -491,7 +491,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
491
491
|
|
|
492
492
|
// Delegation savings — a DIFFERENT number from "Cache saved" above, which
|
|
493
493
|
// covers the prompt cache only. This one is what running work on a cheaper
|
|
494
|
-
// tier saved,
|
|
494
|
+
// tier saved, the lifetime total of the delegation ledger. Hidden
|
|
495
495
|
// when zero or absent: a permanent "$0" is noise for direct-API users and
|
|
496
496
|
// for anyone who has not delegated yet.
|
|
497
497
|
// "Routing saved" says what earned the money — work that ran on a cheaper
|
|
@@ -760,7 +760,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
760
760
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
761
761
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
762
762
|
// a missing section at a glance and know to run `harness init`.
|
|
763
|
-
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
763
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g, ctxLive?.size ?? null);
|
|
764
764
|
|
|
765
765
|
// Version / upgrade chip. Read from a cache written by a detached background
|
|
766
766
|
// check — this render path never touches the network.
|
package/src/formatters/table.js
CHANGED
|
@@ -14,8 +14,10 @@ const C = {
|
|
|
14
14
|
yellow: '\x1b[33m',
|
|
15
15
|
cyan: '\x1b[36m',
|
|
16
16
|
};
|
|
17
|
-
// Wrap text with ANSI codes
|
|
18
|
-
|
|
17
|
+
// Wrap text with ANSI codes. Set per report by formatReport: the caller knows
|
|
18
|
+
// about `--no-color` and whether stdout is a terminal, which this module cannot
|
|
19
|
+
// see. NO_COLOR alone is the answer for a caller that passes nothing.
|
|
20
|
+
let colorOk = !process.env.NO_COLOR;
|
|
19
21
|
const r = (s) => colorOk ? `${C.red}${s}${C.reset}` : s;
|
|
20
22
|
const rb = (s) => colorOk ? `${C.bold}${C.red}${s}${C.reset}` : s;
|
|
21
23
|
const rbl = (s) => colorOk ? `${C.blink}${C.bold}${C.red}${s}${C.reset}` : s;
|
|
@@ -28,6 +30,11 @@ function pad(str, len, align = 'left') {
|
|
|
28
30
|
return s.padEnd(len);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// A loss reads as "-$3", not "$-3". The digits are left as cost.js rounded them.
|
|
34
|
+
function usd(n) {
|
|
35
|
+
return n < 0 ? `-$${Math.abs(n)}` : `$${n}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
function pct(n) {
|
|
32
39
|
return (n * 100).toFixed(1) + '%';
|
|
33
40
|
}
|
|
@@ -93,7 +100,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
93
100
|
}
|
|
94
101
|
for (const spike of spikes) {
|
|
95
102
|
const m = spike.metrics;
|
|
96
|
-
const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
103
|
+
const ratioLabel = spike.ratio && spike.byRatio !== false ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
97
104
|
lines.push(
|
|
98
105
|
r(` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] `) +
|
|
99
106
|
rb(`total input ${formatContextSize(m.totalInput)}`) +
|
|
@@ -140,7 +147,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
140
147
|
lines.push('');
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
|
-
lines.push(rbl(' ▶
|
|
150
|
+
lines.push(rbl(' ▶ How to handle it: ') + rb(`${CLI_NAME} last`));
|
|
144
151
|
lines.push('');
|
|
145
152
|
return lines;
|
|
146
153
|
}
|
|
@@ -169,12 +176,13 @@ function renderCapWarnSection(caps) {
|
|
|
169
176
|
lines.push(rb(` ${CLI_NAME} handoff`));
|
|
170
177
|
lines.push(r(' (writes a HANDOFF-*.md so a fresh session can pick up.)'));
|
|
171
178
|
lines.push('');
|
|
172
|
-
lines.push(rbl(' ▶
|
|
179
|
+
lines.push(rbl(' ▶ How to handle it: ') + rb(`${CLI_NAME} last`));
|
|
173
180
|
lines.push('');
|
|
174
181
|
return lines;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
184
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }, { color } = {}) {
|
|
185
|
+
colorOk = color ?? !process.env.NO_COLOR;
|
|
178
186
|
const lines = [];
|
|
179
187
|
|
|
180
188
|
// Header
|
|
@@ -240,17 +248,17 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
240
248
|
const costW = [24, 12];
|
|
241
249
|
const costA = ['left', 'right'];
|
|
242
250
|
lines.push(' ' + tableTop(costW));
|
|
243
|
-
lines.push(' ' + tableRow(['Actual cost',
|
|
244
|
-
lines.push(' ' + tableRow(['Without cache',
|
|
251
|
+
lines.push(' ' + tableRow(['Actual cost', usd(cost.actual)], costW, costA));
|
|
252
|
+
lines.push(' ' + tableRow(['Without cache', usd(cost.noCacheCost)], costW, costA));
|
|
245
253
|
lines.push(' ' + tableSep(costW));
|
|
246
|
-
lines.push(' ' + tableRow(['Savings',
|
|
254
|
+
lines.push(' ' + tableRow(['Savings', `${usd(cost.savings)} (${pct(cost.savingsRate)})`], costW, costA));
|
|
247
255
|
// Only worth asking of someone who has 1h writes to lose. Everyone else got
|
|
248
256
|
// a `+$0` that read as an endorsement of the 5m bucket they were already
|
|
249
257
|
// stuck in.
|
|
250
258
|
if (cost.extraCostIf5mApplicable === false) {
|
|
251
259
|
lines.push(' ' + tableRow(['Already 5m-only', ttl.gatewayObserved ? 'gateway' : 'yes'], costW, costA));
|
|
252
260
|
} else {
|
|
253
|
-
lines.push(' ' + tableRow(['Extra cost if 5m-only',
|
|
261
|
+
lines.push(' ' + tableRow(['Extra cost if 5m-only', cost.extraCostIf5m < 0 ? usd(cost.extraCostIf5m) : `+${usd(cost.extraCostIf5m)}`], costW, costA));
|
|
254
262
|
}
|
|
255
263
|
lines.push(' ' + tableBot(costW));
|
|
256
264
|
if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
|
package/src/gateway-auth.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
import { execSync } from 'node:child_process';
|
|
24
|
-
import { join } from 'node:path';
|
|
24
|
+
import { join, resolve, dirname } from 'node:path';
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
import { debug } from './debug.js';
|
|
27
27
|
|
|
@@ -40,6 +40,29 @@ export function gatewayBase(env = process.env) {
|
|
|
40
40
|
return base;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Claude Code 가 신뢰 확인을 받아 둔 폴더인지 판정합니다. 기록은
|
|
45
|
+
* ~/.claude.json 의 projects[<절대 경로>].hasTrustDialogAccepted 에 있고, 상위
|
|
46
|
+
* 폴더를 신뢰했으면 하위 폴더에도 적용됩니다. 기록을 읽지 못하면 신뢰하지 않은
|
|
47
|
+
* 것으로 봅니다.
|
|
48
|
+
*/
|
|
49
|
+
export function isTrustedProject(cwd = process.cwd(), { home = homedir() } = {}) {
|
|
50
|
+
let projects;
|
|
51
|
+
try {
|
|
52
|
+
projects = JSON.parse(readFileSync(join(home, '.claude.json'), 'utf8'))?.projects;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (!projects || typeof projects !== 'object') return false;
|
|
57
|
+
let dir = resolve(cwd);
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (projects[dir]?.hasTrustDialogAccepted === true) return true;
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) return false;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
/**
|
|
44
67
|
* Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
|
|
45
68
|
* 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
|
|
@@ -50,9 +73,14 @@ export function gatewayBase(env = process.env) {
|
|
|
50
73
|
* 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
|
|
51
74
|
*/
|
|
52
75
|
export function keyFromApiKeyHelper(cwd = process.cwd()) {
|
|
76
|
+
// 프로젝트 설정의 헬퍼는 그 저장소가 넣어 둔 셸 명령입니다. Claude Code 는 신뢰
|
|
77
|
+
// 확인을 거친 폴더에서만 그것을 실행하므로, 같은 기록을 보고 같은 조건으로만
|
|
78
|
+
// 실행합니다. 그러지 않으면 내려받은 저장소 안에서 이 CLI 를 돌리는 것만으로
|
|
79
|
+
// 그 저장소의 명령이 실행됩니다.
|
|
53
80
|
const candidates = [
|
|
54
|
-
|
|
55
|
-
|
|
81
|
+
...(isTrustedProject(cwd)
|
|
82
|
+
? [join(cwd, '.claude', 'settings.local.json'), join(cwd, '.claude', 'settings.json')]
|
|
83
|
+
: []),
|
|
56
84
|
join(homedir(), '.claude', 'settings.json'),
|
|
57
85
|
];
|
|
58
86
|
for (const p of candidates) {
|
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
|
}
|