@delt/claude-jev-advisor 0.1.2 β†’ 0.2.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.md CHANGED
@@ -2,20 +2,23 @@
2
2
 
3
3
  Unofficial helpers for [Claude Code](https://claude.com/claude-code), installed as command hooks in `~/.claude/settings.json`. They use [TypeSafe](https://typesafe.ai) Jev for judgments that need to understand the conversation.
4
4
 
5
- The `context` helper sends parts of your conversation to TypeSafe's Jev API (`api.typesafe.ai`): your last three requests, each cut to its first 1,000 characters, and Claude's last reply, cut to its first and last 1,500 characters. The `rm` helper sends nothing.
5
+ What is sent to TypeSafe's Jev API (`api.typesafe.ai`):
6
+
7
+ - `context`: your last three requests (typed by you, or sent through a channel or from another Claude Code session), each cut to its first 1,000 characters, and Claude's last reply, cut to its first and last 1,500 characters.
8
+ - `rm`: only for a delete it would otherwise ask about, and only when the target is a file this session made or a folder git ignores. It sends the path, the `rm` command and its description, up to three tool calls of this session that name the target (each cut to 600 characters), and for a folder up to ten of its file names.
6
9
 
7
10
  Not affiliated with Anthropic or TypeSafe.
8
11
 
9
12
  | Helper | What it does | Systems |
10
13
  |---|---|---|
11
14
  | `context` | At the end of each turn, suggests `/compact` or `/clear` when the conversation is large and the work has reached a stopping point. Shown at the end of Claude Code's bottom row. | Any |
12
- | `rm` | Asks before a Bash `rm` deletes real files. Lets temp files through and refuses an `rm` whose targets it cannot work out. | Windows |
15
+ | `rm` | Asks before a Bash `rm` deletes real files. Lets temp files through and refuses an `rm` whose targets it cannot work out. Jev can lift the ask for a test file this session made or a folder git ignores. | Windows |
13
16
 
14
17
  ## Requirements
15
18
 
16
19
  - Node.js 18 or later
17
20
  - Claude Code (the bottom-row display uses Claude Code mods, an early-access feature that may change between releases)
18
- - A TypeSafe API key for the `context` helper, in `TYPESAFE_API_KEY` or in a key file (`--key-file`)
21
+ - A TypeSafe API key for the `context` helper and for the Jev check of the `rm` helper, in `TYPESAFE_API_KEY` or in a key file (`--key-file`). Without a key, `rm` asks about every real file.
19
22
  - Windows for the `rm` helper. On other systems `install` skips it and says why.
20
23
 
21
24
  ## Install
@@ -44,6 +47,7 @@ The hooks also reach Claude Code sessions that are already open; the bottom-row
44
47
  | `claude-jev-advisor uninstall [rm] [context]` | Removes this package's hooks and display from `settings.json` |
45
48
  | `claude-jev-advisor on [rm] [context]` / `off [rm] [context]` | Switches helpers on or off without touching `settings.json` |
46
49
  | `claude-jev-advisor status` | Shows what is registered and on, the display, whether a key is set, the thresholds and the last judgment |
50
+ | `claude-jev-advisor report [--days 7]` | Lists the `/compact` and `/clear` advice shown and what followed it, and the deletions Jev let through |
47
51
  | `claude-jev-advisor key` | Asks for the TypeSafe API key, checks it and saves it (needs an interactive terminal) |
48
52
  | `claude-jev-advisor help` | Prints the usage |
49
53
 
@@ -51,32 +55,33 @@ If you leave out the helper names, the command applies to all helpers. The displ
51
55
 
52
56
  ## The context helper
53
57
 
54
- When a turn ends, a `Stop` hook reads the size of the conversation from the session transcript. From 100k tokens it asks Jev two questions about the last three requests and the last reply:
58
+ When a turn ends, a `Stop` hook reads the size of the conversation from the session transcript. If the turn's last reply is not in the transcript yet, it waits up to 3 seconds for it. From 250k tokens it asks Jev two questions about the last three requests and the last reply:
55
59
 
56
- - has the work asked for reached a natural stopping point?
57
- - is the goal behind it finished, with nothing left to do next?
60
+ - has Claude finished or handed back the work asked for, so this is a natural break?
61
+ - does the reply close a whole stage of the work (a plan carried out and pushed, a release, a finished investigation, a design or plan saved as a document), so the next stage can start from the saved results?
58
62
 
59
- The answer is saved for the display. Nothing is added to what Claude sees, so it costs no Claude tokens. If Jev does not answer within 8 seconds, only the size is shown.
63
+ The answer is saved for the display. Nothing is added to what Claude sees, so it costs no Claude tokens. If Jev does not answer within 8 seconds, only the size is shown. While a subagent or a workflow runs in the background, the turn counts as work in progress and Jev is not asked; background shells and monitors do not count.
60
64
 
61
65
  | Situation | Shown |
62
66
  |---|---|
63
- | Under 100k, or no judgment (no key, Jev unreachable) | `52k` |
67
+ | Under 250k, or no judgment (no key, Jev unreachable) | `52k` |
64
68
  | Work in progress | `🟒 312k` |
65
- | Goal finished (from 100k) | `🟑 312k μƒˆλ‘­κ²Œ μ‹œμž‘ν•˜λŠ” 건 μ–΄λ– μ„Έμš”? /clear` |
66
- | Work unit finished (from 200k) | `🟑 312k μ§€κΈˆκΉŒμ§€ μ •λ¦¬ν•˜κ³  μ΄μ–΄κ°€λŠ” 건 μ–΄λ– μ„Έμš”? /compact` |
67
- | Work unit finished, under 200k | `🟒 150k` |
69
+ | A whole stage closed (from 250k) | `🟑 312k μƒˆλ‘­κ²Œ μ‹œμž‘ν•˜λŠ” 건 μ–΄λ– μ„Έμš”? /clear` |
70
+ | Work finished, stage goes on (from 250k) | `🟑 312k μ§€κΈˆκΉŒμ§€ μ •λ¦¬ν•˜κ³  μ΄μ–΄κ°€λŠ” 건 μ–΄λ– μ„Έμš”? /compact` |
68
71
  | Within 20% of auto-compact | `πŸ”΄ 790k 18%`, then ` Β· ` and the `/clear` or `/compact` advice above, or `μž‘μ—…μ΄ λλ‚˜λ©΄ μ •λ¦¬ν•˜κ³  μ΄μ–΄κ°€λŠ” 건 μ–΄λ– μ„Έμš”? /compact` while the work is still going |
69
72
 
70
73
  With `--lang en` the advice reads `Start fresh? /clear`, `Wrap up what you have and continue? /compact` and `When this work is done, wrap up and continue? /compact`.
71
74
 
72
- `/clear` is suggested only when Jev is confident the goal is finished; when in doubt it suggests `/compact`. `/compact` waits until 200k because compacting a smaller conversation costs more than it saves. The suggestion is hidden while a new request runs, and a judgment made before a `/compact` is dropped.
75
+ Judging starts at 250k because a conversation passes 100k after a request or two, and compacting a small conversation costs more than it saves. If you set `compactMinTokens` above `minTokens`, finished work between the two stays green. The suggestion is hidden while a new request runs, and a judgment made before a `/compact` is dropped.
73
76
 
74
77
  | `--display` | Where |
75
78
  |---|---|
76
79
  | `mod` (default) | At the end of the bottom row, after `⏡⏡ … mode on`. A small Claude Code mod in the package's `mod/` folder draws it. The mod is listed in `env.CLAUDE_CODE_PLUGIN_DIRS` and told the data folder through `pluginConfigs`. |
77
- | `statusline` | Claude Code's status line, the row above the bottom row. An existing status line of yours keeps running first, with our text after it, and is put back when you switch away or uninstall. On Windows that command is run through `cmd.exe`, so a status line that needs bash may not show. No red zone, because the auto-compact threshold is not known there. |
80
+ | `statusline` | Claude Code's status line, the row above the bottom row. An existing status line of yours keeps running first, with our text after it, and is put back when you switch away or uninstall. On Windows that command runs in Git Bash when it is installed and in PowerShell otherwise, as Claude Code runs it; it gets 2 seconds. No red zone, because the auto-compact threshold is not known there. |
78
81
  | `message` | A `Stop says: …` line in the transcript when there is advice. Claude does not see it. No red zone. |
79
82
 
83
+ `claude-jev-advisor report` shows each piece of advice with the time, the session, the size, Jev's answers, the start of the last request and what followed: compacted or cleared within the next three requests, auto-compacted, or kept going.
84
+
80
85
  ## The rm helper
81
86
 
82
87
  It runs before every Bash tool call (`PreToolUse`, matcher `Bash`, 15-second timeout) and looks only at `rm`, `rmdir` and `xargs`.
@@ -87,7 +92,7 @@ It runs before every Bash tool call (`PreToolUse`, matcher `Bash`, 15-second tim
87
92
  | A path with a `.superpowers` folder in it | No decision |
88
93
  | A path that does not exist | No decision |
89
94
  | A git-ignored path inside a build folder (`target`, `build`, `dist`, `out`, `node_modules`, `coverage`, `__pycache__`, `.pytest_cache`, `.gradle`, `.next`, `.nuxt`, `.turbo`, `.cache`, `bin`, `obj`) | No decision |
90
- | Any other existing file or folder | `ask`, with the reason `μ‹€μ œ 파일 μ‚­μ œ: <paths>` ("deleting real files") |
95
+ | Any other existing file or folder | `ask`, with the reason `μ‹€μ œ 파일 μ‚­μ œ: <paths>` ("deleting real files"), unless Jev lifts it (below) |
91
96
  | A target it cannot work out | `deny`, with a hint to rewrite the command using literal paths |
92
97
 
93
98
  A target cannot be worked out when it uses:
@@ -113,9 +118,20 @@ What its decisions do depends on Claude Code's permission mode:
113
118
  | `bypassPermissions` | A prompt. Claude Code's documentation does not say this; it was seen in a test on 2026-10-03. | Blocked | Runs |
114
119
  | Other modes | A prompt (`dontAsk` refuses the call instead) | Blocked | Claude Code's normal permission handling |
115
120
 
121
+ ### When Jev can lift the ask
122
+
123
+ The facts are checked by code; Jev only judges what the target is for. Jev is asked only when every target of the command is one of these (at most five):
124
+
125
+ | Target | Checked first | Jev is asked |
126
+ |---|---|---|
127
+ | A file or folder this session made | Not tracked by git; the first call of this session that names it created it: a Write that made a new file, or a shell command whose `>` redirect, `touch`, `mkdir` or `curl -o` makes it (deletes do not count, and a Read, an Edit or any other shell command first means it was already there); made after the session started; for a folder, every file inside too, and at most 50 files | Is it a throwaway made only to try something out? |
128
+ | A folder git ignores, outside the build folders above | It is a folder (a single ignored file such as `.env` still asks) | Is it generated output or a cache that is made again? |
129
+
130
+ When Jev answers 0.8 or more for every target, the hook makes no decision (the last column of the table above), and a line like `[jev-advisor] Jevκ°€ 이 μ„Έμ…˜μ˜ μ‹œν—˜ 파일둜 νŒλ‹¨ν•΄ 묻지 μ•Šκ³  μ§€μ›λ‹ˆλ‹€: out.json (0.93)` is shown. Otherwise the ask stays, with Jev's answer added: `μ‹€μ œ 파일 μ‚­μ œ: …\out.json Β· Jev: μ‹œν—˜μš© 파일일 ν™•λ₯  0.42`. Without a key, when Jev fails, or with `"jev": false`, every real file asks as before. A `deny` is never sent to Jev.
131
+
116
132
  `install rm` also replaces an older personal hook at `~/.claude/hooks/rm-guard/rm-guard.mjs` if one is registered. Its files are left in place.
117
133
 
118
- If a hook of this package fails in any way, it prints nothing and exits 0, so it never blocks Claude Code.
134
+ If a hook of this package fails in any way, it prints nothing and exits 0, so it never blocks Claude Code. A failure during the Jev check of `rm` leaves the ask in place.
119
135
 
120
136
  ## Uninstall
121
137
 
@@ -134,26 +150,28 @@ A key saved by `install` or `key` stays in `~/.claude/claude-jev-advisor/jev-key
134
150
 
135
151
  | Path | Contents |
136
152
  |---|---|
137
- | `~/.claude/claude-jev-advisor/config.json` | Switches and settings. A missing or broken file reads as the defaults. A value of the wrong type falls back to its default, so only `"enabled": false` turns a helper off. |
153
+ | `~/.claude/claude-jev-advisor/config.json` | Switches and settings that differ from the defaults. A missing or broken file reads as the defaults. A value of the wrong type falls back to its default, so only `"enabled": false` turns a helper off. |
138
154
  | `~/.claude/claude-jev-advisor/state/<session>.json` | The last judgment of each open session, read by the display. Removed when the session ends. |
139
- | `~/.claude/claude-jev-advisor/log/YYYY-MM.jsonl` | One line per judgment: the size, what was sent to Jev (parts of your conversation), the answers and the result. Never the key. |
155
+ | `~/.claude/claude-jev-advisor/log/YYYY-MM.jsonl` | One line per context judgment and per `rm` Jev check: the size or the command, what was sent to Jev (parts of your conversation), the answers and the result. Never the key. |
140
156
  | `~/.claude/claude-jev-advisor/statusline-before.json` | Your own status line while `--display statusline` is in use |
141
157
  | `~/.claude/claude-jev-advisor/jev-key.env` | Your TypeSafe API key, when you typed it in `install` or `key` |
142
158
  | `~/.claude/backups/settings.json.*-before-claude-jev-advisor` | Copies of `settings.json` from before each change |
143
159
 
144
- Default config:
160
+ Defaults:
145
161
 
146
162
  ```json
147
163
  {
148
164
  "lang": "ko",
149
165
  "keyFile": null,
150
166
  "display": "mod",
151
- "context": { "enabled": true, "minTokens": 100000, "compactMinTokens": 200000, "redRemainingPct": 20, "unitDoneYes": 0.7, "goalDoneYes": 0.8 },
167
+ "context": { "enabled": true, "minTokens": 250000, "compactMinTokens": 250000, "redRemainingPct": 20, "unitDoneYes": 0.6, "phaseDoneYes": 0.6 },
152
168
  "rm": { "enabled": true, "jev": true, "throwawayYes": 0.8, "maxDirFiles": 50 }
153
169
  }
154
170
  ```
155
171
 
156
- `minTokens` is where judging starts, `compactMinTokens` where `/compact` is suggested, `redRemainingPct` where the red zone starts, and `unitDoneYes` / `goalDoneYes` are the Jev probabilities needed for "unit finished" and "goal finished". The `rm` settings other than `enabled` are reserved for a later version.
172
+ `minTokens` is where judging starts, `compactMinTokens` where `/compact` is suggested, `redRemainingPct` where the red zone starts, and `unitDoneYes` / `phaseDoneYes` are the Jev probabilities needed for "work finished" (`/compact`) and "stage closed" (`/clear`). For `rm`, `jev` switches the Jev check, `throwawayYes` is the probability needed to lift an ask, and `maxDirFiles` the most files a folder of this session may hold.
173
+
174
+ `config.json` keeps only the values you changed, with `"version": 2`. A config written by 0.1.x, which saved every value, has its old default thresholds (`minTokens` 100000, `compactMinTokens` 200000, `unitDoneYes` 0.7) read as unset, so the new defaults apply.
157
175
 
158
176
  ## Development
159
177
 
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import os from "os";
5
- import path9 from "path";
5
+ import path10 from "path";
6
6
  import { fileURLToPath } from "url";
7
7
 
8
8
  // src/cli/prompt.ts
@@ -71,21 +71,23 @@ function terminalPrompt(terminal = PROCESS_TERMINAL) {
71
71
  }
72
72
 
73
73
  // src/cli/run.ts
74
- import fs7 from "fs";
75
- import path8 from "path";
74
+ import fs9 from "fs";
75
+ import path9 from "path";
76
76
 
77
77
  // src/config.ts
78
78
  import fs from "fs";
79
79
  import path2 from "path";
80
80
 
81
81
  // src/config-shape.ts
82
+ var CONFIG_VERSION = 2;
82
83
  var DEFAULT_CONFIG = {
83
84
  lang: "ko",
84
85
  keyFile: null,
85
86
  display: "mod",
86
- context: { enabled: true, minTokens: 1e5, compactMinTokens: 2e5, redRemainingPct: 20, unitDoneYes: 0.7, goalDoneYes: 0.8 },
87
+ context: { enabled: true, minTokens: 25e4, compactMinTokens: 25e4, redRemainingPct: 20, unitDoneYes: 0.6, phaseDoneYes: 0.6 },
87
88
  rm: { enabled: true, jev: true, throwawayYes: 0.8, maxDirFiles: 50 }
88
89
  };
90
+ var LEGACY_CONTEXT_DEFAULTS = { minTokens: 1e5, compactMinTokens: 2e5, unitDoneYes: 0.7 };
89
91
  var LANGS = ["ko", "en"];
90
92
  var DISPLAYS = ["mod", "statusline", "message"];
91
93
  function isRecord(value) {
@@ -99,16 +101,33 @@ function mergeSection(defaults, raw) {
99
101
  }
100
102
  return out;
101
103
  }
104
+ function withoutLegacyDefaults(context) {
105
+ if (!isRecord(context)) return context;
106
+ return Object.fromEntries(Object.entries(context).filter(([key, value]) => LEGACY_CONTEXT_DEFAULTS[key] !== value));
107
+ }
102
108
  function normalizeConfig(raw) {
103
109
  const r = isRecord(raw) ? raw : {};
104
110
  return {
105
111
  lang: typeof r.lang === "string" && LANGS.includes(r.lang) ? r.lang : DEFAULT_CONFIG.lang,
106
112
  keyFile: typeof r.keyFile === "string" && r.keyFile !== "" ? r.keyFile : null,
107
113
  display: typeof r.display === "string" && DISPLAYS.includes(r.display) ? r.display : DEFAULT_CONFIG.display,
108
- context: mergeSection(DEFAULT_CONFIG.context, r.context),
114
+ context: mergeSection(DEFAULT_CONFIG.context, r.version === CONFIG_VERSION ? r.context : withoutLegacyDefaults(r.context)),
109
115
  rm: mergeSection(DEFAULT_CONFIG.rm, r.rm)
110
116
  };
111
117
  }
118
+ function changedValues(values, defaults) {
119
+ return Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== defaults[key]));
120
+ }
121
+ function configToSave(config) {
122
+ const { context, rm, ...top } = config;
123
+ const { context: defaultContext, rm: defaultRm, ...defaultTop } = DEFAULT_CONFIG;
124
+ const out = { version: CONFIG_VERSION, ...changedValues(top, defaultTop) };
125
+ const changedContext = changedValues(context, defaultContext);
126
+ const changedRm = changedValues(rm, defaultRm);
127
+ if (Object.keys(changedContext).length) out.context = changedContext;
128
+ if (Object.keys(changedRm).length) out.rm = changedRm;
129
+ return out;
130
+ }
112
131
 
113
132
  // src/paths.ts
114
133
  import path from "path";
@@ -118,6 +137,7 @@ var configPath = (home) => path.join(dataDir(home), "config.json");
118
137
  var settingsPath = (home) => path.join(claudeDir(home), "settings.json");
119
138
  var backupsDir = (home) => path.join(claudeDir(home), "backups");
120
139
  var stateDir = (home) => path.join(dataDir(home), "state");
140
+ var logDir = (home) => path.join(dataDir(home), "log");
121
141
  var statusLineBeforePath = (home) => path.join(dataDir(home), "statusline-before.json");
122
142
  var savedKeyPath = (home) => path.join(dataDir(home), "jev-key.env");
123
143
 
@@ -132,7 +152,7 @@ function readConfig(home) {
132
152
  function writeConfig(home, config) {
133
153
  const file = configPath(home);
134
154
  fs.mkdirSync(path2.dirname(file), { recursive: true });
135
- fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}
155
+ fs.writeFileSync(file, `${JSON.stringify(configToSave(config), null, 2)}
136
156
  `);
137
157
  }
138
158
  function updateConfig(home, change) {
@@ -247,8 +267,8 @@ function withoutModDisplay(settings, delimiter) {
247
267
  const dirs = env?.[PLUGIN_DIRS_ENV];
248
268
  if (env && typeof dirs === "string") {
249
269
  const all = dirs.split(delimiter);
250
- const kept = all.filter((dir) => !MOD_DIR_PATTERN.test(forwardSlashes2(dir)));
251
- if (kept.length !== all.length) {
270
+ if (all.some((dir) => MOD_DIR_PATTERN.test(forwardSlashes2(dir)))) {
271
+ const kept = all.filter((dir) => dir !== "" && !MOD_DIR_PATTERN.test(forwardSlashes2(dir)));
252
272
  const { [PLUGIN_DIRS_ENV]: _ours, ...otherEnv } = env;
253
273
  const nextEnv = kept.length ? { ...env, [PLUGIN_DIRS_ENV]: kept.join(delimiter) } : otherEnv;
254
274
  next = Object.keys(nextEnv).length ? { ...next, env: nextEnv } : withoutKey(next, "env");
@@ -264,11 +284,11 @@ function withoutModDisplay(settings, delimiter) {
264
284
  function withModDisplay(settings, modDir, dataDirPath, delimiter) {
265
285
  const base = withoutModDisplay(settings, delimiter);
266
286
  const env = isRecord2(base.env) ? base.env : {};
267
- const dirs = typeof env[PLUGIN_DIRS_ENV] === "string" && env[PLUGIN_DIRS_ENV] !== "" ? `${env[PLUGIN_DIRS_ENV]}${delimiter}` : "";
287
+ const dirs = typeof env[PLUGIN_DIRS_ENV] === "string" ? env[PLUGIN_DIRS_ENV].split(delimiter).filter(Boolean) : [];
268
288
  const configs = isRecord2(base.pluginConfigs) ? base.pluginConfigs : {};
269
289
  return {
270
290
  ...base,
271
- env: { ...env, [PLUGIN_DIRS_ENV]: `${dirs}${forwardSlashes2(modDir)}` },
291
+ env: { ...env, [PLUGIN_DIRS_ENV]: [...dirs, forwardSlashes2(modDir)].join(delimiter) },
272
292
  pluginConfigs: { ...configs, [MOD_PLUGIN_NAME]: { options: { dataDir: forwardSlashes2(dataDirPath) } } }
273
293
  };
274
294
  }
@@ -548,10 +568,42 @@ async function promptForKey(prompt, check) {
548
568
  }
549
569
  }
550
570
 
551
- // src/status.ts
552
- import fs6 from "fs";
571
+ // src/report.ts
572
+ import fs7 from "fs";
553
573
  import path7 from "path";
554
574
 
575
+ // src/context/transcript.ts
576
+ import fs6 from "fs";
577
+ var TAIL_START_BYTES = 1024 * 1024;
578
+ var TAIL_MAX_BYTES = 8 * 1024 * 1024;
579
+ var REQUEST_ORIGINS = /* @__PURE__ */ new Set(["human", "channel", "peer"]);
580
+ function isRecord3(value) {
581
+ return typeof value === "object" && value !== null && !Array.isArray(value);
582
+ }
583
+ function parseLines(text) {
584
+ const entries = [];
585
+ for (const line of text.split("\n")) {
586
+ if (!line.trim()) continue;
587
+ try {
588
+ const value = JSON.parse(line);
589
+ if (isRecord3(value)) entries.push(value);
590
+ } catch {
591
+ }
592
+ }
593
+ return entries;
594
+ }
595
+ function messageText(content) {
596
+ if (typeof content === "string") return content;
597
+ if (!Array.isArray(content)) return "";
598
+ return content.filter((block) => isRecord3(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
599
+ }
600
+ function isRequest(entry) {
601
+ return entry.type === "user" && entry.isSidechain !== true && isRecord3(entry.origin) && REQUEST_ORIGINS.has(String(entry.origin.kind));
602
+ }
603
+ function requestText(entry) {
604
+ return messageText(isRecord3(entry.message) ? entry.message.content : void 0).trim();
605
+ }
606
+
555
607
  // src/display/line.ts
556
608
  var PHRASES = {
557
609
  ko: {
@@ -565,7 +617,7 @@ var PHRASES = {
565
617
  compactLater: "When this work is done, wrap up and continue? /compact"
566
618
  }
567
619
  };
568
- function isRecord3(value) {
620
+ function isRecord4(value) {
569
621
  return typeof value === "object" && value !== null && !Array.isArray(value);
570
622
  }
571
623
  function formatSize(tokens) {
@@ -574,6 +626,11 @@ function formatSize(tokens) {
574
626
  function remainingPct(size, threshold) {
575
627
  return Math.max(0, Math.round((threshold - size) / threshold * 100));
576
628
  }
629
+ function adviceKind(size, judgment, config) {
630
+ if (size === null || size < config.context.minTokens || !judgment || judgment.phase === "working") return null;
631
+ if (judgment.clear) return "clear";
632
+ return size >= config.context.compactMinTokens ? "compact" : null;
633
+ }
577
634
  function adviceLine({ size, threshold, judgment, config }) {
578
635
  if (size === null) return "";
579
636
  const k = formatSize(size);
@@ -586,41 +643,181 @@ function adviceLine({ size, threshold, judgment, config }) {
586
643
  return `${head} \xB7 ${counted.clear ? phrases.clear : phrases.compact}`;
587
644
  }
588
645
  if (!counted) return k;
589
- if (counted.phase === "working") return `\u{1F7E2} ${k}`;
590
- if (counted.clear) return `\u{1F7E1} ${k} ${phrases.clear}`;
591
- if (size >= config.context.compactMinTokens) return `\u{1F7E1} ${k} ${phrases.compact}`;
592
- return `\u{1F7E2} ${k}`;
646
+ const kind = adviceKind(size, counted, config);
647
+ return kind ? `\u{1F7E1} ${k} ${phrases[kind]}` : `\u{1F7E2} ${k}`;
593
648
  }
594
649
  function parseState(raw) {
595
- if (!isRecord3(raw) || typeof raw.sessionId !== "string" || typeof raw.at !== "number") return null;
650
+ if (!isRecord4(raw) || typeof raw.sessionId !== "string" || typeof raw.at !== "number") return null;
596
651
  const j = raw.judgment;
597
- const judgment = isRecord3(j) && (j.phase === "working" || j.phase === "unit_done") && typeof j.clear === "boolean" ? { phase: j.phase, clear: j.clear } : null;
652
+ const judgment = isRecord4(j) && (j.phase === "working" || j.phase === "unit_done") && typeof j.clear === "boolean" ? { phase: j.phase, clear: j.clear } : null;
598
653
  return { sessionId: raw.sessionId, at: raw.at, size: typeof raw.size === "number" ? raw.size : null, judgment };
599
654
  }
600
655
 
656
+ // src/report.ts
657
+ var FOLLOW_REQUESTS = 3;
658
+ var DAY_MS = 24 * 60 * 60 * 1e3;
659
+ function isRecord5(value) {
660
+ return typeof value === "object" && value !== null && !Array.isArray(value);
661
+ }
662
+ function monthFiles(home, since, now) {
663
+ const files = [];
664
+ for (let d = new Date(since.getFullYear(), since.getMonth(), 1); d <= now; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) {
665
+ files.push(path7.join(logDir(home), `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}.jsonl`));
666
+ }
667
+ return files;
668
+ }
669
+ function readLogRecords(home, since, now) {
670
+ const records = [];
671
+ for (const file of monthFiles(home, since, now)) {
672
+ let text;
673
+ try {
674
+ text = fs7.readFileSync(file, "utf8");
675
+ } catch {
676
+ continue;
677
+ }
678
+ for (const record of parseLines(text)) {
679
+ const at = typeof record.at === "string" ? Date.parse(record.at) : NaN;
680
+ if (at >= since.getTime() && at <= now.getTime()) records.push(record);
681
+ }
682
+ }
683
+ return records;
684
+ }
685
+ function adviceOf(record, config) {
686
+ if ("advice" in record) return record.advice === "clear" || record.advice === "compact" ? record.advice : null;
687
+ const state = parseState({ sessionId: record.sessionId, at: 0, size: record.size, judgment: record.judgment });
688
+ return state ? adviceKind(state.size, state.judgment, config) : null;
689
+ }
690
+ function outcomeAfter(entries, at, endedByClear) {
691
+ let requests = 0;
692
+ for (const entry of entries) {
693
+ const time = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
694
+ if (!(time > at)) continue;
695
+ if (entry.type === "system" && entry.subtype === "compact_boundary") {
696
+ const trigger = isRecord5(entry.compactMetadata) ? entry.compactMetadata.trigger : void 0;
697
+ return { kind: trigger === "auto" ? "auto_compact" : "compact" };
698
+ }
699
+ if (isRequest(entry) && requestText(entry)) {
700
+ requests += 1;
701
+ if (requests >= FOLLOW_REQUESTS) return { kind: "kept", requests };
702
+ }
703
+ }
704
+ return endedByClear ? { kind: "clear" } : { kind: "kept", requests };
705
+ }
706
+ function adviceRows(home, now, days) {
707
+ const config = readConfig(home);
708
+ const records = readLogRecords(home, new Date(now.getTime() - days * DAY_MS), now);
709
+ const cleared = new Set(records.filter((r) => r.event === "session_end" && r.reason === "clear").map((r) => r.sessionId));
710
+ const transcripts = /* @__PURE__ */ new Map();
711
+ const entriesOf = (file) => {
712
+ if (typeof file !== "string") return null;
713
+ if (!transcripts.has(file)) {
714
+ try {
715
+ transcripts.set(file, parseLines(fs7.readFileSync(file, "utf8")));
716
+ } catch {
717
+ transcripts.set(file, null);
718
+ }
719
+ }
720
+ return transcripts.get(file) ?? null;
721
+ };
722
+ const rows = [];
723
+ for (const record of records) {
724
+ if (record.helper !== "context" || record.event !== "stop" || typeof record.sessionId !== "string" || typeof record.size !== "number") continue;
725
+ const advice = adviceOf(record, config);
726
+ if (!advice) continue;
727
+ const at = new Date(record.at);
728
+ const entries = entriesOf(record.transcriptPath);
729
+ const jev = isRecord5(record.jev) ? record.jev : {};
730
+ const state = isRecord5(jev.state) ? jev.state : {};
731
+ const requests = Array.isArray(state.recent_requests) ? state.recent_requests : [];
732
+ const last = requests[requests.length - 1];
733
+ rows.push({
734
+ at,
735
+ sessionId: record.sessionId,
736
+ size: record.size,
737
+ advice,
738
+ answers: isRecord5(jev.answers) ? jev.answers : null,
739
+ request: typeof last === "string" ? last : "",
740
+ outcome: entries ? outcomeAfter(entries, at.getTime(), cleared.has(record.sessionId)) : { kind: "unknown" }
741
+ });
742
+ }
743
+ return rows;
744
+ }
745
+ function localTime(date) {
746
+ const p = (n) => String(n).padStart(2, "0");
747
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}`;
748
+ }
749
+ function outcomeText(outcome) {
750
+ switch (outcome.kind) {
751
+ case "compact":
752
+ return "compacted";
753
+ case "clear":
754
+ return "cleared";
755
+ case "auto_compact":
756
+ return "auto-compacted";
757
+ case "kept":
758
+ return outcome.requests >= FOLLOW_REQUESTS ? `kept going, ${FOLLOW_REQUESTS}+ requests` : `kept going, ${outcome.requests} request${outcome.requests === 1 ? "" : "s"}`;
759
+ default:
760
+ return "transcript not found";
761
+ }
762
+ }
763
+ function answersText(answers) {
764
+ if (!answers) return "";
765
+ return Object.entries(answers).filter(([, p]) => typeof p === "number").map(([id, p]) => `${id.replace(/_done$/, "")} ${p.toFixed(2)}`).join(" ");
766
+ }
767
+ function rmLines(home, now, days) {
768
+ const records = readLogRecords(home, new Date(now.getTime() - days * DAY_MS), now).filter((r) => r.helper === "rm");
769
+ const passed = records.filter((r) => r.decision === "pass");
770
+ const lines = [`rm deletions Jev let through: ${passed.length} (asked anyway after a Jev check: ${records.length - passed.length})`];
771
+ for (const record of passed) {
772
+ const targets = Array.isArray(record.targets) ? record.targets.filter(isRecord5) : [];
773
+ const listed = targets.map((t) => `${String(t.shown)} (${typeof t.p === "number" ? t.p.toFixed(2) : "?"})`).join(", ");
774
+ lines.push(` ${localTime(new Date(record.at))} ${String(record.sessionId ?? "").slice(0, 8)} ${listed}`);
775
+ }
776
+ return lines;
777
+ }
778
+ function reportLines(home, now, days) {
779
+ const rows = adviceRows(home, now, days);
780
+ const lines = [`Advice shown in the last ${days} day${days === 1 ? "" : "s"}: ${rows.length}`];
781
+ for (const row of rows) {
782
+ const request = row.request.replace(/\s+/g, " ").trim().slice(0, 40);
783
+ lines.push(` ${localTime(row.at)} ${row.sessionId.slice(0, 8)} ${formatSize(row.size)} /${row.advice} ${answersText(row.answers)} "${request}" -> ${outcomeText(row.outcome)}`);
784
+ }
785
+ for (const advice of ["clear", "compact"]) {
786
+ const mine = rows.filter((r) => r.advice === advice);
787
+ if (!mine.length) continue;
788
+ const count = (kind) => mine.filter((r) => r.outcome.kind === kind).length;
789
+ lines.push(
790
+ `/${advice}: ${mine.length} - compacted ${count("compact")}, cleared ${count("clear")}, auto-compacted ${count("auto_compact")}, kept going ${count("kept")}, unknown ${count("unknown")}`
791
+ );
792
+ }
793
+ return [...lines, ...rmLines(home, now, days)];
794
+ }
795
+
601
796
  // src/status.ts
797
+ import fs8 from "fs";
798
+ import path8 from "path";
602
799
  var SCRIPT_IN_COMMAND = /^node "([^"]+)"/;
603
- function isRecord4(value) {
800
+ function isRecord6(value) {
604
801
  return typeof value === "object" && value !== null && !Array.isArray(value);
605
802
  }
606
803
  function displaySetUp(settings, config) {
607
804
  if (config.display === "message") return true;
608
805
  if (config.display === "statusline") return isOurStatusLine(settings.statusLine);
609
- const env = isRecord4(settings.env) ? settings.env : {};
806
+ const env = isRecord6(settings.env) ? settings.env : {};
610
807
  const dirs = typeof env[PLUGIN_DIRS_ENV] === "string" ? env[PLUGIN_DIRS_ENV] : "";
611
- const hasDir = dirs.split(path7.delimiter).some((dir) => MOD_DIR_PATTERN.test(dir.replace(/\\/g, "/")));
612
- return hasDir && isRecord4(settings.pluginConfigs) && MOD_PLUGIN_NAME in settings.pluginConfigs;
808
+ const hasDir = dirs.split(path8.delimiter).some((dir) => MOD_DIR_PATTERN.test(dir.replace(/\\/g, "/")));
809
+ return hasDir && isRecord6(settings.pluginConfigs) && MOD_PLUGIN_NAME in settings.pluginConfigs;
613
810
  }
614
811
  function newestState(home) {
615
812
  let newest = null;
616
813
  try {
617
- for (const name of fs6.readdirSync(stateDir(home))) {
814
+ for (const name of fs8.readdirSync(stateDir(home))) {
618
815
  if (!name.endsWith(".json")) continue;
619
- const file = path7.join(stateDir(home), name);
620
- const mtime = fs6.statSync(file).mtimeMs;
816
+ const file = path8.join(stateDir(home), name);
817
+ const mtime = fs8.statSync(file).mtimeMs;
621
818
  if (!newest || mtime > newest.mtime) newest = { file, mtime };
622
819
  }
623
- return newest ? parseState(JSON.parse(fs6.readFileSync(newest.file, "utf8"))) : null;
820
+ return newest ? parseState(JSON.parse(fs8.readFileSync(newest.file, "utf8"))) : null;
624
821
  } catch {
625
822
  return null;
626
823
  }
@@ -630,7 +827,8 @@ function contextLines(home, settings, config, env) {
630
827
  const lines = [
631
828
  ` display: ${config.display}${displaySetUp(settings, config) ? "" : ` (not set up in settings.json - run "claude-jev-advisor install context --display ${config.display}")`}`,
632
829
  ` key: ${readJevKey(env, config.keyFile) ? "set" : "missing"}`,
633
- ` thresholds: judge from ${formatSize(c.minTokens)}, /compact from ${formatSize(c.compactMinTokens)}, red at ${c.redRemainingPct}% left`
830
+ ` thresholds: judge from ${formatSize(c.minTokens)}, /compact from ${formatSize(c.compactMinTokens)}, red at ${c.redRemainingPct}% left`,
831
+ ` Jev: work done from ${c.unitDoneYes}, stage done (/clear) from ${c.phaseDoneYes}`
634
832
  ];
635
833
  const last = newestState(home);
636
834
  if (last) {
@@ -639,6 +837,11 @@ function contextLines(home, settings, config, env) {
639
837
  }
640
838
  return lines;
641
839
  }
840
+ function rmJevLine(config, env) {
841
+ if (!config.rm.jev) return " jev: off";
842
+ const key = readJevKey(env, config.keyFile) ? "" : " (no key - every real file asks)";
843
+ return ` jev: on, lifts the ask from ${config.rm.throwawayYes}${key}`;
844
+ }
642
845
  function statusLines(home, env = process.env) {
643
846
  const lines = [`settings: ${settingsPath(home)}`, `config: ${configPath(home)}`];
644
847
  let settings;
@@ -658,11 +861,12 @@ function statusLines(home, env = process.env) {
658
861
  lines.push(`${feature}: installed, ${state}`);
659
862
  for (const c of commands) {
660
863
  const script = SCRIPT_IN_COMMAND.exec(c.command)?.[1];
661
- if (script && !fs6.existsSync(script)) {
864
+ if (script && !fs8.existsSync(script)) {
662
865
  lines.push(` broken: ${c.event} hook points to a missing file (${script}) - run "claude-jev-advisor install ${feature}" again, or "claude-jev-advisor uninstall ${feature}"`);
663
866
  }
664
867
  }
665
868
  if (feature === "context") lines.push(...contextLines(home, settings, config, env));
869
+ if (feature === "rm") lines.push(rmJevLine(config, env));
666
870
  }
667
871
  if (findCommands(settings, LEGACY_RM_GUARD).length) {
668
872
  lines.push('legacy rm-guard hook (~/.claude/hooks/rm-guard) is still registered - "claude-jev-advisor install rm" replaces it');
@@ -681,6 +885,7 @@ var USAGE = [
681
885
  " off [rm] [context] switch helpers off (open sessions too)",
682
886
  " status show what is registered and switched on",
683
887
  " key ask for the TypeSafe API key, check it and save it",
888
+ " report [--days 7] list the /compact and /clear advice shown and what followed",
684
889
  "",
685
890
  "Options for install: --lang ko|en --key-file <path> --display mod|statusline|message",
686
891
  'The display is set up when context is installed: "install context --display <mode>" switches it.',
@@ -688,6 +893,7 @@ var USAGE = [
688
893
  'Run "claude-jev-advisor uninstall" before "npm rm -g": npm does not run uninstall scripts.'
689
894
  ].join("\n");
690
895
  var INSTALL_OPTIONS = ["lang", "key-file", "display"];
896
+ var REPORT_DAYS = 7;
691
897
  var LANGS2 = ["ko", "en"];
692
898
  var DISPLAYS2 = ["mod", "statusline", "message"];
693
899
  function parse(argv) {
@@ -711,7 +917,12 @@ function parse(argv) {
711
917
  }
712
918
  function checkOptions(command, options) {
713
919
  const names = Object.keys(options);
714
- if (command !== "install") return names.length ? `options are only for install (got --${names[0]})` : null;
920
+ if (command === "report") {
921
+ const unknown2 = names.find((n) => n !== "days");
922
+ if (unknown2) return `unknown option --${unknown2}`;
923
+ return options.days === void 0 || /^[1-9]\d{0,3}$/.test(options.days) ? null : "--days takes a whole number of days from 1";
924
+ }
925
+ if (command !== "install") return names.length ? `options are only for install and report (got --${names[0]})` : null;
715
926
  const unknown = names.find((n) => !INSTALL_OPTIONS.includes(n));
716
927
  if (unknown) return `unknown option --${unknown}`;
717
928
  if (options.lang !== void 0 && !LANGS2.includes(options.lang)) return "--lang takes ko or en";
@@ -728,7 +939,7 @@ async function askAndSaveKey(io, prompt) {
728
939
  const file = saveJevKey(io.home, key);
729
940
  updateConfig(io.home, (c) => ({ ...c, keyFile: file }));
730
941
  io.out(`Saved the key to ${file}`);
731
- if (before && path8.resolve(before) !== path8.resolve(file)) io.out(`The config now uses this file instead of ${before}.`);
942
+ if (before && path9.resolve(before) !== path9.resolve(file)) io.out(`The config now uses this file instead of ${before}.`);
732
943
  if ((io.env ?? process.env).TYPESAFE_API_KEY?.trim()) io.out("TYPESAFE_API_KEY is set in your environment; the hooks use it before the saved key.");
733
944
  }
734
945
  async function ensureKey(io, keyFileGiven) {
@@ -763,7 +974,7 @@ async function runCli(argv, io) {
763
974
  platform: io.platform,
764
975
  now: io.now(),
765
976
  lang: options.lang,
766
- keyFile: options["key-file"] === void 0 || path8.isAbsolute(options["key-file"]) ? options["key-file"] : path8.resolve(options["key-file"]),
977
+ keyFile: options["key-file"] === void 0 || path9.isAbsolute(options["key-file"]) ? options["key-file"] : path9.resolve(options["key-file"]),
767
978
  display: options.display
768
979
  });
769
980
  for (const f of r.installed) io.out(`installed ${f}`);
@@ -784,7 +995,7 @@ async function runCli(argv, io) {
784
995
  if (!r.removed.length) io.out("nothing to remove");
785
996
  for (const f of r.removed) io.out(`removed ${f}`);
786
997
  if (r.backup) io.out(`backup: ${r.backup}`);
787
- if (r.removed.includes("context") && fs7.existsSync(savedKeyPath(io.home))) io.out(`kept your saved TypeSafe key at ${savedKeyPath(io.home)}`);
998
+ if (r.removed.includes("context") && fs9.existsSync(savedKeyPath(io.home))) io.out(`kept your saved TypeSafe key at ${savedKeyPath(io.home)}`);
788
999
  return 0;
789
1000
  }
790
1001
  case "key": {
@@ -803,6 +1014,9 @@ async function runCli(argv, io) {
803
1014
  case "status":
804
1015
  for (const line of statusLines(io.home)) io.out(line);
805
1016
  return 0;
1017
+ case "report":
1018
+ for (const line of reportLines(io.home, io.now(), options.days === void 0 ? REPORT_DAYS : Number(options.days))) io.out(line);
1019
+ return 0;
806
1020
  case "help":
807
1021
  case "--help":
808
1022
  case "-h":
@@ -822,7 +1036,7 @@ async function runCli(argv, io) {
822
1036
  // src/cli.ts
823
1037
  runCli(process.argv.slice(2), {
824
1038
  home: os.homedir(),
825
- distDir: path9.dirname(fileURLToPath(import.meta.url)),
1039
+ distDir: path10.dirname(fileURLToPath(import.meta.url)),
826
1040
  platform: process.platform,
827
1041
  now: () => /* @__PURE__ */ new Date(),
828
1042
  out: (line) => console.log(line),