@delt/claude-jev-advisor 0.1.1 → 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/dist/rm-hook.js CHANGED
@@ -200,10 +200,10 @@ function expand(word, vars) {
200
200
  return known && !/[$`]/.test(out) ? out : null;
201
201
  }
202
202
  function assign(word, vars, home) {
203
- const name = ASSIGNMENT.exec(word.value)[1];
204
- let value = expand({ value: word.value.slice(name.length + 1), dynamic: word.dynamic }, vars);
203
+ const name2 = ASSIGNMENT.exec(word.value)[1];
204
+ let value = expand({ value: word.value.slice(name2.length + 1), dynamic: word.dynamic }, vars);
205
205
  if (value !== null && /^~(?:[\\/]|$)/.test(value)) value = home + value.slice(1);
206
- vars.set(name, value);
206
+ vars.set(name2, value);
207
207
  }
208
208
  function commandName(word) {
209
209
  if (word.dynamic) return null;
@@ -214,17 +214,17 @@ function stripPrefixes(words) {
214
214
  for (; ; ) {
215
215
  while (rest.length && ASSIGNMENT.test(rest[0].value)) rest = rest.slice(1);
216
216
  if (!rest.length) return rest;
217
- const name = commandName(rest[0]);
218
- if (SKIP_WORDS.has(rest[0].value) || name === "command" || name === "builtin" || name === "exec" || name === "nohup") {
217
+ const name2 = commandName(rest[0]);
218
+ if (SKIP_WORDS.has(rest[0].value) || name2 === "command" || name2 === "builtin" || name2 === "exec" || name2 === "nohup") {
219
219
  rest = rest.slice(1);
220
220
  continue;
221
221
  }
222
- if (name === "sudo" || name === "env") {
222
+ if (name2 === "sudo" || name2 === "env") {
223
223
  rest = rest.slice(1);
224
224
  while (rest.length && !rest[0].dynamic && rest[0].value.startsWith("-")) rest = rest.slice(1);
225
225
  continue;
226
226
  }
227
- if (name === "timeout") {
227
+ if (name2 === "timeout") {
228
228
  rest = rest.slice(1);
229
229
  while (rest.length && !rest[0].dynamic && rest[0].value.startsWith("-")) rest = rest.slice(1);
230
230
  rest = rest.slice(1);
@@ -267,7 +267,7 @@ function classify(target, ctx) {
267
267
  }
268
268
  const resolved = toWindowsPath(w, ctx.cwd, ctx.home, ctx.tmpdirs[0]);
269
269
  if (resolved.unresolvable) return { unresolvable: resolved.unresolvable };
270
- if (resolved.unknown) return { real: target.value };
270
+ if (resolved.unknown) return { real: { shown: target.value, path: null } };
271
271
  const p = resolved.path;
272
272
  const shown = pattern === null ? p : `${p}\\${pattern}`;
273
273
  const narrowPattern = pattern !== null && !/^[*?.]+$/.test(pattern);
@@ -275,7 +275,7 @@ function classify(target, ctx) {
275
275
  if (segments(p).includes(".superpowers")) return {};
276
276
  if (pattern === null && !ctx.probe.exists(p)) return {};
277
277
  if (segments(p).some((s) => BUILD_DIRS.has(s)) && ctx.probe.isIgnored(p)) return {};
278
- return { real: shown };
278
+ return { real: { shown, path: pattern === null ? p : null } };
279
279
  }
280
280
  function deny(why) {
281
281
  return {
@@ -302,23 +302,23 @@ function decide({ command, cwd, home, tmpdirs, env = {}, probe }) {
302
302
  } else if (HEADER_WORDS.has(rest[0].value)) {
303
303
  if (rest[0].value !== "case" && rest[1]) vars.set(rest[1].value, null);
304
304
  } else if (!CLOSING_WORDS.has(rest[0].value)) {
305
- const name = commandName(rest[0]);
306
- if (name !== null && DECLARERS.has(name)) {
305
+ const name2 = commandName(rest[0]);
306
+ if (name2 !== null && DECLARERS.has(name2)) {
307
307
  for (const w of rest.slice(1)) if (ASSIGNMENT.test(w.value)) assign(w, vars, home);
308
- } else if (name === "cd" || name === "pushd" || name === "popd") {
308
+ } else if (name2 === "cd" || name2 === "pushd" || name2 === "popd") {
309
309
  const arg = rest[1];
310
310
  const value = arg ? expand(arg, vars) : null;
311
- if (name === "popd" || arg && (value === null || value === "-")) setCwd(null);
311
+ if (name2 === "popd" || arg && (value === null || value === "-")) setCwd(null);
312
312
  else if (!arg) setCwd(home);
313
313
  else setCwd(toWindowsPath({ ...arg, value }, ctx.cwd, home, tmpdirs[0]).path ?? null);
314
- } else if (name === "xargs") {
314
+ } else if (name2 === "xargs") {
315
315
  if (rest.slice(1).some((t) => {
316
316
  const n = commandName(t);
317
317
  return n === "rm" || n === "rmdir";
318
318
  })) {
319
319
  return deny("xargs feeding rm");
320
320
  }
321
- } else if (name === "rm" || name === "rmdir") {
321
+ } else if (name2 === "rm" || name2 === "rmdir") {
322
322
  let options = true;
323
323
  for (const t of rest.slice(1)) {
324
324
  if (options && !t.dynamic && t.value === "--") {
@@ -336,9 +336,9 @@ function decide({ command, cwd, home, tmpdirs, env = {}, probe }) {
336
336
  if (after === ")" && stack.length) setCwd(stack.pop() ?? null);
337
337
  }
338
338
  if (!real.length) return null;
339
- const listed = real.slice(0, MAX_LISTED).join(", ");
339
+ const listed = real.slice(0, MAX_LISTED).map((t) => t.shown).join(", ");
340
340
  const more = real.length > MAX_LISTED ? ` \uC678 ${real.length - MAX_LISTED}\uAC1C` : "";
341
- return { decision: "ask", reason: `\uC2E4\uC81C \uD30C\uC77C \uC0AD\uC81C: ${listed}${more}` };
341
+ return { decision: "ask", reason: `\uC2E4\uC81C \uD30C\uC77C \uC0AD\uC81C: ${listed}${more}`, targets: real };
342
342
  }
343
343
  function hookOutput({ decision, reason }) {
344
344
  return JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: decision, permissionDecisionReason: reason } });
@@ -359,20 +359,22 @@ var realProbe = {
359
359
  };
360
360
 
361
361
  // src/rm/run.ts
362
- import path4 from "path";
362
+ import path7 from "path";
363
363
 
364
364
  // src/config.ts
365
365
  import fs2 from "fs";
366
366
  import path3 from "path";
367
367
 
368
368
  // src/config-shape.ts
369
+ var CONFIG_VERSION = 2;
369
370
  var DEFAULT_CONFIG = {
370
371
  lang: "ko",
371
372
  keyFile: null,
372
373
  display: "mod",
373
- context: { enabled: true, minTokens: 1e5, compactMinTokens: 2e5, redRemainingPct: 20, unitDoneYes: 0.7, goalDoneYes: 0.8 },
374
+ context: { enabled: true, minTokens: 25e4, compactMinTokens: 25e4, redRemainingPct: 20, unitDoneYes: 0.6, phaseDoneYes: 0.6 },
374
375
  rm: { enabled: true, jev: true, throwawayYes: 0.8, maxDirFiles: 50 }
375
376
  };
377
+ var LEGACY_CONTEXT_DEFAULTS = { minTokens: 1e5, compactMinTokens: 2e5, unitDoneYes: 0.7 };
376
378
  var LANGS = ["ko", "en"];
377
379
  var DISPLAYS = ["mod", "statusline", "message"];
378
380
  function isRecord(value) {
@@ -386,13 +388,17 @@ function mergeSection(defaults, raw) {
386
388
  }
387
389
  return out;
388
390
  }
391
+ function withoutLegacyDefaults(context) {
392
+ if (!isRecord(context)) return context;
393
+ return Object.fromEntries(Object.entries(context).filter(([key, value]) => LEGACY_CONTEXT_DEFAULTS[key] !== value));
394
+ }
389
395
  function normalizeConfig(raw) {
390
396
  const r = isRecord(raw) ? raw : {};
391
397
  return {
392
398
  lang: typeof r.lang === "string" && LANGS.includes(r.lang) ? r.lang : DEFAULT_CONFIG.lang,
393
399
  keyFile: typeof r.keyFile === "string" && r.keyFile !== "" ? r.keyFile : null,
394
400
  display: typeof r.display === "string" && DISPLAYS.includes(r.display) ? r.display : DEFAULT_CONFIG.display,
395
- context: mergeSection(DEFAULT_CONFIG.context, r.context),
401
+ context: mergeSection(DEFAULT_CONFIG.context, r.version === CONFIG_VERSION ? r.context : withoutLegacyDefaults(r.context)),
396
402
  rm: mergeSection(DEFAULT_CONFIG.rm, r.rm)
397
403
  };
398
404
  }
@@ -402,6 +408,7 @@ import path2 from "path";
402
408
  var claudeDir = (home) => path2.join(home, ".claude");
403
409
  var dataDir = (home) => path2.join(claudeDir(home), "claude-jev-advisor");
404
410
  var configPath = (home) => path2.join(dataDir(home), "config.json");
411
+ var logDir = (home) => path2.join(dataDir(home), "log");
405
412
 
406
413
  // src/config.ts
407
414
  function readConfig(home) {
@@ -412,17 +419,517 @@ function readConfig(home) {
412
419
  }
413
420
  }
414
421
 
422
+ // src/context/files.ts
423
+ import fs3 from "fs";
424
+ import path4 from "path";
425
+ function logFile(home, now) {
426
+ return path4.join(logDir(home), `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}.jsonl`);
427
+ }
428
+ function appendLog(home, now, record, secrets = []) {
429
+ const file = logFile(home, now);
430
+ fs3.mkdirSync(path4.dirname(file), { recursive: true });
431
+ let line = JSON.stringify({ at: now.toISOString(), ...record });
432
+ for (const secret of secrets) {
433
+ if (!secret) continue;
434
+ for (const form of [secret, JSON.stringify(secret).slice(1, -1)]) line = line.split(form).join("[redacted]");
435
+ }
436
+ fs3.appendFileSync(file, `${line}
437
+ `);
438
+ }
439
+
440
+ // src/jev.ts
441
+ import fs4 from "fs";
442
+ var JEV_URL = "https://api.typesafe.ai/v1/systemone";
443
+ var JEV_TIMEOUT_MS = 8e3;
444
+ var KEY_PREFIX = "TYPESAFE_API_KEY=";
445
+ function readJevKey(env, keyFile) {
446
+ const fromEnv = env.TYPESAFE_API_KEY?.trim();
447
+ if (fromEnv) return fromEnv;
448
+ if (!keyFile) return null;
449
+ let text;
450
+ try {
451
+ text = fs4.readFileSync(keyFile, "utf8");
452
+ } catch {
453
+ return null;
454
+ }
455
+ const line = text.replace(/^\uFEFF/, "").split(/\r?\n/).find((l) => l.startsWith(KEY_PREFIX));
456
+ const key = line?.slice(KEY_PREFIX.length).trim().replace(/^["']|["']$/g, "");
457
+ return key || null;
458
+ }
459
+ function jevKeys(env, keyFile) {
460
+ return [...new Set([readJevKey(env, keyFile), readJevKey({}, keyFile)].filter((key) => Boolean(key)))];
461
+ }
462
+ function withoutKeys(text, keys) {
463
+ return keys.reduce((out, key) => out.split(key).join("[redacted]"), text);
464
+ }
465
+ async function callJev(request, opts) {
466
+ const now = opts.now ?? Date.now;
467
+ const fetchFn = opts.fetchFn ?? fetch;
468
+ const limit = opts.timeoutMs ?? JEV_TIMEOUT_MS;
469
+ const redact = (text) => text.split(opts.key).join("[redacted]");
470
+ const started = now();
471
+ const controller = new AbortController();
472
+ let timer;
473
+ const expired = new Promise((_resolve, reject) => {
474
+ timer = setTimeout(() => {
475
+ const err = new Error(`Jev did not answer within ${limit} ms`);
476
+ controller.abort(err);
477
+ reject(err);
478
+ }, limit);
479
+ });
480
+ let requestId = null;
481
+ const exchange = (async () => {
482
+ const res = await fetchFn(JEV_URL, {
483
+ method: "POST",
484
+ headers: { Authorization: `Bearer ${opts.key}`, "Content-Type": "application/json" },
485
+ body: JSON.stringify({ model: "jev-latest", ...request }),
486
+ signal: controller.signal
487
+ });
488
+ requestId = res.headers.get("request-id") ?? res.headers.get("x-request-id");
489
+ return { res, text: await res.text() };
490
+ })();
491
+ exchange.catch(() => {
492
+ });
493
+ try {
494
+ const { res, text } = await Promise.race([exchange, expired]);
495
+ if (!res.ok) return { error: redact(`HTTP ${res.status}: ${text}`).slice(0, 300), requestId, ms: now() - started };
496
+ const body = JSON.parse(text);
497
+ const answers = {};
498
+ for (const [id, answer] of Object.entries(body.answers ?? {})) {
499
+ if (typeof answer?.noul === "number") answers[id] = answer.noul;
500
+ }
501
+ return { answers, model: typeof body.model === "string" ? body.model : null, requestId, ms: now() - started };
502
+ } catch (err) {
503
+ return { error: redact(String(err?.message ?? err)).slice(0, 300), requestId, ms: now() - started };
504
+ } finally {
505
+ clearTimeout(timer);
506
+ }
507
+ }
508
+
509
+ // src/rm/facts.ts
510
+ import fs6 from "fs";
511
+ import path5 from "path";
512
+ import { spawnSync as spawnSync2 } from "child_process";
513
+
514
+ // src/context/transcript.ts
515
+ import fs5 from "fs";
516
+ var TAIL_START_BYTES = 1024 * 1024;
517
+ var TAIL_MAX_BYTES = 8 * 1024 * 1024;
518
+ function isRecord2(value) {
519
+ return typeof value === "object" && value !== null && !Array.isArray(value);
520
+ }
521
+ function parseLines(text) {
522
+ const entries = [];
523
+ for (const line of text.split("\n")) {
524
+ if (!line.trim()) continue;
525
+ try {
526
+ const value = JSON.parse(line);
527
+ if (isRecord2(value)) entries.push(value);
528
+ } catch {
529
+ }
530
+ }
531
+ return entries;
532
+ }
533
+
534
+ // src/rm/facts.ts
535
+ var SESSION_LOG_MAX_BYTES = 32 * 1024 * 1024;
536
+ var CALLS_PER_TARGET = 3;
537
+ var win2 = path5.win32;
538
+ var DELETING = /\b(?:rm|rmdir|del|erase|rd|ri|Remove-Item|xargs)\b/i;
539
+ var GIT_LOCATION_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"];
540
+ var CHANGES_FOLDER = /(^|[\s;&|(])(?:cd|pushd|popd|chdir|Set-Location|sl)(?=\s|$)/i;
541
+ function isRecord3(value) {
542
+ return typeof value === "object" && value !== null && !Array.isArray(value);
543
+ }
544
+ function callOf(tool, input, result, cwd) {
545
+ const text = (key) => typeof input[key] === "string" ? input[key] : "";
546
+ switch (tool) {
547
+ case "Write":
548
+ return { tool, file: text("file_path"), text: text("content"), kind: result === "create" ? "create" : "change", cwd };
549
+ case "Edit":
550
+ return { tool, file: text("file_path"), text: text("new_string"), kind: "change", cwd };
551
+ case "MultiEdit": {
552
+ const edits = Array.isArray(input.edits) ? input.edits : [];
553
+ return { tool, file: text("file_path"), text: edits.map((e) => isRecord3(e) && typeof e.new_string === "string" ? e.new_string : "").join("\n"), kind: "change", cwd };
554
+ }
555
+ case "NotebookEdit":
556
+ return { tool, file: text("notebook_path"), text: text("new_source"), kind: "change", cwd };
557
+ case "Read":
558
+ return { tool, file: text("file_path"), text: "", kind: "read", cwd };
559
+ case "Bash":
560
+ case "PowerShell":
561
+ return { tool, file: null, text: text("command"), kind: "shell", cwd };
562
+ default:
563
+ return null;
564
+ }
565
+ }
566
+ function resultTypes(entries) {
567
+ const types = /* @__PURE__ */ new Map();
568
+ for (const entry of entries) {
569
+ if (entry.type !== "user" || !isRecord3(entry.toolUseResult) || typeof entry.toolUseResult.type !== "string" || !isRecord3(entry.message)) continue;
570
+ const content = entry.message.content;
571
+ const block = Array.isArray(content) ? content[0] : void 0;
572
+ if (isRecord3(block) && typeof block.tool_use_id === "string") types.set(block.tool_use_id, entry.toolUseResult.type);
573
+ }
574
+ return types;
575
+ }
576
+ function toolCalls(entries) {
577
+ const results = resultTypes(entries);
578
+ const calls = [];
579
+ let message;
580
+ let moved = false;
581
+ for (const entry of entries) {
582
+ if (entry.type !== "assistant" || entry.isSidechain === true || !isRecord3(entry.message) || !Array.isArray(entry.message.content)) continue;
583
+ if (entry.message.id === void 0 || entry.message.id !== message) moved = false;
584
+ message = entry.message.id;
585
+ const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
586
+ for (const block of entry.message.content) {
587
+ if (!isRecord3(block) || block.type !== "tool_use" || typeof block.name !== "string" || !isRecord3(block.input)) continue;
588
+ const call = callOf(block.name, block.input, typeof block.id === "string" ? results.get(block.id) : void 0, moved ? null : cwd);
589
+ if (!call) continue;
590
+ calls.push(call);
591
+ if (call.kind === "shell" && CHANGES_FOLDER.test(call.text)) moved = true;
592
+ }
593
+ }
594
+ return calls;
595
+ }
596
+ function readSessionLog(file, maxBytes = SESSION_LOG_MAX_BYTES) {
597
+ let text;
598
+ try {
599
+ if (fs6.statSync(file).size > maxBytes) return null;
600
+ text = fs6.readFileSync(file, "utf8");
601
+ } catch {
602
+ return null;
603
+ }
604
+ const entries = parseLines(text);
605
+ const first = entries.find((e) => typeof e.timestamp === "string" && !Number.isNaN(Date.parse(e.timestamp)));
606
+ return first ? { startedAt: Date.parse(first.timestamp), calls: toolCalls(entries) } : null;
607
+ }
608
+ function normalized(text) {
609
+ return text.replace(/\\/g, "/").replace(/(^|[\s"'=(>])\/([A-Za-z])\//g, "$1$2:/").toLowerCase();
610
+ }
611
+ var escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
612
+ function shellRefs(target, cwd) {
613
+ const refs = [`(?<![\\w.-])${escaped(normalized(target))}(?![\\w.-])`];
614
+ const relative = cwd === null ? "" : win2.relative(cwd, target);
615
+ if (relative && !relative.startsWith("..") && !win2.isAbsolute(relative)) {
616
+ refs.push(`(?<![\\w.\\-/:])(?:\\./)?${escaped(normalized(relative))}(?![\\w.-])`);
617
+ }
618
+ return refs;
619
+ }
620
+ function namesTarget(call, target, folder) {
621
+ const wanted = normalized(target);
622
+ if (call.file !== null) {
623
+ const file = normalized(call.file);
624
+ return file === wanted || folder && file.startsWith(`${wanted}/`);
625
+ }
626
+ if (DELETING.test(call.text)) return false;
627
+ const text = normalized(call.text);
628
+ return shellRefs(target, call.cwd).some((ref) => new RegExp(ref).test(text));
629
+ }
630
+ function shellCommands(text) {
631
+ const commands = [];
632
+ let current = { words: [], writes: [] };
633
+ let word = null;
634
+ let redirect = null;
635
+ const endWord = () => {
636
+ if (word === null) return;
637
+ if (redirect === null) current.words.push(word);
638
+ else if (redirect === ">" || redirect === "&>") current.writes.push(word);
639
+ word = null;
640
+ redirect = null;
641
+ };
642
+ const endCommand = () => {
643
+ endWord();
644
+ commands.push(current);
645
+ current = { words: [], writes: [] };
646
+ };
647
+ for (let i = 0; i < text.length; i++) {
648
+ const c = text[i];
649
+ if (c === "'" || c === '"') {
650
+ const close = text.indexOf(c, i + 1);
651
+ const stop = close === -1 ? text.length : close;
652
+ word = (word ?? "") + text.slice(i + 1, stop);
653
+ i = stop;
654
+ } else if (c === "\\") {
655
+ word = (word ?? "") + (text[i + 1] ?? "");
656
+ i += 1;
657
+ } else if (c === "#" && word === null) {
658
+ while (i + 1 < text.length && text[i + 1] !== "\n") i += 1;
659
+ } else if (c === " " || c === " " || c === "\r") {
660
+ endWord();
661
+ } else if (c === "\n" || c === ";" || c === "|" || c === "(" || c === ")" || c === "&" && text[i + 1] !== ">") {
662
+ endCommand();
663
+ } else if (c === ">" || c === "<" || c === "&") {
664
+ if (word !== null && /^\d+$/.test(word)) word = null;
665
+ endWord();
666
+ let op = c;
667
+ while (text[i + 1] === ">" || text[i + 1] === "&" || text[i + 1] === "|") op += text[++i];
668
+ redirect = op;
669
+ } else {
670
+ word = (word ?? "") + c;
671
+ }
672
+ }
673
+ endCommand();
674
+ return commands;
675
+ }
676
+ var pathWord = (word) => word.replace(/\\/g, "/").replace(/^\/([A-Za-z])\//, "$1:/").replace(/^\.\//, "").replace(/\/+$/, "").toLowerCase();
677
+ function createsTarget(call, target) {
678
+ if (call.kind !== "shell") return call.kind === "create";
679
+ const full = pathWord(target);
680
+ const relative = call.cwd === null ? "" : win2.relative(call.cwd, target);
681
+ const short = relative && !relative.startsWith("..") && !win2.isAbsolute(relative) ? pathWord(relative) : null;
682
+ let moved = false;
683
+ const isTarget = (word) => word !== void 0 && (pathWord(word) === full || !moved && pathWord(word) === short);
684
+ for (const { words, writes } of shellCommands(stripHeredocs(call.text))) {
685
+ if (writes.some(isTarget)) return true;
686
+ const start = words.findIndex((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
687
+ const args = start === -1 ? [] : words.slice(start);
688
+ const name2 = win2.basename((args[0] ?? "").replace(/\//g, "\\")).replace(/\.exe$/i, "").toLowerCase();
689
+ if ((name2 === "touch" || name2 === "mkdir") && args.slice(1).some(isTarget)) return true;
690
+ if (name2 === "curl" && args.some((w, i) => (w === "-o" || w === "--output") && isTarget(args[i + 1]))) return true;
691
+ if (name2 === "cd" || name2 === "pushd" || name2 === "popd") moved = true;
692
+ }
693
+ return false;
694
+ }
695
+ function gatherFacts(target, session, probe, maxFiles) {
696
+ const info = probe.info(target);
697
+ if (!info) return null;
698
+ const naming = session.calls.filter((call) => namesTarget(call, target, info.folder));
699
+ return {
700
+ path: target,
701
+ folder: info.folder,
702
+ bornAt: info.bornAt,
703
+ listing: info.folder ? probe.list(target, maxFiles) : null,
704
+ tracked: probe.tracked(target),
705
+ ignored: info.folder && probe.ignored(target),
706
+ // Write, Edit and sed -i replace a file, which resets its Windows birth time, so the birth time alone cannot tell
707
+ // a new file from an edited one: the first call that named the target must be the one that visibly created it.
708
+ existedBefore: naming.length === 0 || !createsTarget(naming[0], target),
709
+ createdBy: naming.length <= CALLS_PER_TARGET ? naming : [naming[0], ...naming.slice(1 - CALLS_PER_TARGET)]
710
+ };
711
+ }
712
+ function candidateKind(facts, startedAt) {
713
+ const fresh = (bornAt2) => bornAt2 >= startedAt;
714
+ const freshInside = !facts.folder || facts.listing !== null && !facts.listing.more && facts.listing.entries.every((e) => fresh(e.bornAt));
715
+ if (!facts.tracked && !facts.existedBefore && facts.createdBy.length > 0 && fresh(facts.bornAt) && freshInside) return "session";
716
+ if (facts.folder && facts.ignored && !facts.listing?.repo) return "ignored";
717
+ return null;
718
+ }
719
+ var bornAt = (stat) => stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
720
+ function git(args, extraEnv = {}) {
721
+ const env = { ...process.env, LC_ALL: "C", ...extraEnv };
722
+ for (const name2 of GIT_LOCATION_ENV) delete env[name2];
723
+ return spawnSync2("git", args, { encoding: "utf8", timeout: 3e3, windowsHide: true, env });
724
+ }
725
+ var realFactProbe = {
726
+ info(p) {
727
+ try {
728
+ const stat = fs6.lstatSync(p);
729
+ return { folder: stat.isDirectory(), bornAt: bornAt(stat) };
730
+ } catch {
731
+ return null;
732
+ }
733
+ },
734
+ list(dir, limit) {
735
+ const entries = [];
736
+ let seen = 0;
737
+ let more = false;
738
+ let repo = false;
739
+ const walk = (folder, prefix) => {
740
+ for (const item of fs6.readdirSync(folder, { withFileTypes: true })) {
741
+ if (more) return;
742
+ if (seen >= limit) {
743
+ more = true;
744
+ return;
745
+ }
746
+ seen += 1;
747
+ if (item.name === ".git") repo = true;
748
+ const full = path5.join(folder, item.name);
749
+ const name2 = prefix ? `${prefix}/${item.name}` : item.name;
750
+ if (item.isDirectory()) walk(full, name2);
751
+ else entries.push({ name: name2, bornAt: bornAt(fs6.lstatSync(full)) });
752
+ }
753
+ };
754
+ try {
755
+ walk(dir, "");
756
+ return { entries, more, repo };
757
+ } catch {
758
+ return null;
759
+ }
760
+ },
761
+ tracked(p) {
762
+ if (fs6.existsSync(path5.join(p, ".git"))) return true;
763
+ const r = git(["-C", win2.dirname(p), "ls-files", "--", p], { GIT_ICASE_PATHSPECS: "1" });
764
+ if (r.status === 0) return r.stdout.trim() !== "";
765
+ return !(r.status === 128 && /not a git repository/i.test(r.stderr ?? ""));
766
+ },
767
+ ignored(p) {
768
+ if (fs6.existsSync(path5.join(p, ".git"))) return false;
769
+ return git(["-C", win2.dirname(p), "check-ignore", "-q", "--", p]).status === 0;
770
+ }
771
+ };
772
+
773
+ // src/rm/judge.ts
774
+ import path6 from "path";
775
+ var CALL_EXCERPT_CHARS = 600;
776
+ var SAMPLE_FILES = 10;
777
+ var RM_QUESTIONS = {
778
+ session: {
779
+ type: "noul",
780
+ instructions: "Is `target` a throwaway file or folder that Claude made during this session only to try something out, so that deleting it with `command` loses nothing the user wants? `created_by` lists the tool calls of this session that name it; `git` says it is not tracked by git.",
781
+ criteria: {
782
+ true: [
783
+ "A scratch or probe file for a one-off check: captured output, a dump or log, a quick test script, a sample input made up for a trial.",
784
+ "Nothing in the tool calls treats it as part of the work the user asked for."
785
+ ],
786
+ false: [
787
+ "Part of the work: source code, tests in the project test folders, documentation, configuration, migrations, or data or output the user asked for.",
788
+ "It is unclear what it is for, or it may still be wanted."
789
+ ]
790
+ }
791
+ },
792
+ ignored: {
793
+ type: "noul",
794
+ instructions: "Is `target` a folder of generated output or cache that a build, install or tool run makes again, so that deleting it with `command` loses nothing? `git` says git ignores it; `sample` lists some of its entries.",
795
+ criteria: {
796
+ true: "Build output, compiled files, bundles, a dependency or package cache, test or coverage output, or a tool cache that is recreated on the next run.",
797
+ false: "Hand-made or downloaded content, user data, local configuration, secrets, logs worth keeping, or anything that cannot simply be made again; or it is unclear."
798
+ }
799
+ }
800
+ };
801
+ var PASS = {
802
+ ko: {
803
+ session: "Jev\uAC00 \uC774 \uC138\uC158\uC758 \uC2DC\uD5D8 \uD30C\uC77C\uB85C \uD310\uB2E8\uD574 \uBB3B\uC9C0 \uC54A\uACE0 \uC9C0\uC6C1\uB2C8\uB2E4",
804
+ ignored: "Jev\uAC00 \uB2E4\uC2DC \uB9CC\uB4E4 \uC218 \uC788\uB294 \uD3F4\uB354\uB85C \uD310\uB2E8\uD574 \uBB3B\uC9C0 \uC54A\uACE0 \uC9C0\uC6C1\uB2C8\uB2E4",
805
+ mixed: "Jev\uAC00 \uC9C0\uC6CC\uB3C4 \uB418\uB294 \uB300\uC0C1\uC73C\uB85C \uD310\uB2E8\uD574 \uBB3B\uC9C0 \uC54A\uACE0 \uC9C0\uC6C1\uB2C8\uB2E4"
806
+ },
807
+ en: {
808
+ session: "Jev judged it a throwaway test file of this session, so it is deleted without asking",
809
+ ignored: "Jev judged it a folder that is made again, so it is deleted without asking",
810
+ mixed: "Jev judged these safe to delete, so they are deleted without asking"
811
+ }
812
+ };
813
+ var NOTE = {
814
+ ko: { session: "\uC2DC\uD5D8\uC6A9 \uD30C\uC77C\uC77C \uD655\uB960", ignored: "\uB2E4\uC2DC \uB9CC\uB4E4 \uC218 \uC788\uC744 \uD655\uB960", none: "\uD310\uB2E8 \uBABB \uD568" },
815
+ en: { session: "chance it is a test file", ignored: "chance it can be made again", none: "no answer" }
816
+ };
817
+ function rmJevRequest(kind, facts, command, description, clean = (text) => text) {
818
+ const target = { path: facts.path, kind: facts.folder ? "folder" : "file" };
819
+ if (facts.listing) {
820
+ target.files = facts.listing.more ? `more than ${facts.listing.entries.length}` : facts.listing.entries.length;
821
+ target.sample = facts.listing.entries.slice(0, SAMPLE_FILES).map((e) => e.name);
822
+ }
823
+ const state = { target, command: clean(command), description: clean(description) };
824
+ if (kind === "session") {
825
+ state.created_by = facts.createdBy.map((c) => ({ tool: c.tool, input: clean(c.file ? `${c.file}
826
+ ${c.text}` : c.text).slice(0, CALL_EXCERPT_CHARS) }));
827
+ state.git = "untracked";
828
+ } else {
829
+ state.git = "ignored";
830
+ }
831
+ return { state, questions: { ok: RM_QUESTIONS[kind] } };
832
+ }
833
+ var name = (item) => path6.win32.basename(item.path);
834
+ var prob = (p) => p.toFixed(2);
835
+ function lifts(items, threshold) {
836
+ return items.length > 0 && items.every((item) => item.p !== null && item.p >= threshold);
837
+ }
838
+ function passMessage(lang, items) {
839
+ const kinds = new Set(items.map((i) => i.kind));
840
+ const phrase = PASS[lang][kinds.size === 1 ? items[0].kind : "mixed"];
841
+ return `[jev-advisor] ${phrase}: ${items.map((i) => `${name(i)} (${prob(i.p ?? 0)})`).join(", ")}`;
842
+ }
843
+ function askNote(lang, items) {
844
+ if (items.length === 1) {
845
+ const [item] = items;
846
+ return ` \xB7 Jev: ${item.p === null ? NOTE[lang].none : `${NOTE[lang][item.kind]} ${prob(item.p)}`}`;
847
+ }
848
+ return ` \xB7 Jev: ${items.map((i) => `${name(i)} ${i.p === null ? "?" : prob(i.p)}`).join(", ")}`;
849
+ }
850
+
415
851
  // src/rm/run.ts
416
- function runRmHook(raw, deps) {
417
- if (!readConfig(deps.home).rm.enabled) return null;
852
+ var MAX_JEV_TARGETS = 5;
853
+ var FACTS_BUDGET_MS = 5e3;
854
+ var JEV_PATH_BUDGET_MS = 12e3;
855
+ var MIN_JEV_MS = 1e3;
856
+ async function judgeTargets(targets, input, command, key, keys, config, deps, elapsed) {
857
+ if (targets.length === 0 || targets.length > MAX_JEV_TARGETS || targets.some((t) => t.path === null)) return null;
858
+ if (typeof input.transcript_path !== "string") return null;
859
+ const session = readSessionLog(input.transcript_path);
860
+ if (!session) return null;
861
+ const found = [];
862
+ for (const target of targets) {
863
+ if (elapsed() > FACTS_BUDGET_MS) return null;
864
+ const facts = gatherFacts(target.path, session, deps.facts ?? realFactProbe, config.rm.maxDirFiles);
865
+ const kind = facts ? candidateKind(facts, session.startedAt) : null;
866
+ if (!facts || !kind) return null;
867
+ found.push({ target, facts, kind });
868
+ }
869
+ const timeoutMs = Math.min(JEV_TIMEOUT_MS, JEV_PATH_BUDGET_MS - elapsed());
870
+ if (timeoutMs < MIN_JEV_MS) return null;
871
+ const description = typeof input.tool_input?.description === "string" ? input.tool_input.description : "";
872
+ const answered = await Promise.all(
873
+ found.map(async ({ kind, facts }) => {
874
+ const request = rmJevRequest(kind, facts, command, description, (text) => withoutKeys(text, keys));
875
+ return { request, result: await callJev(request, { key, fetchFn: deps.fetchFn, timeoutMs }) };
876
+ })
877
+ );
878
+ return {
879
+ items: found.map(({ target, kind }, i) => {
880
+ const { result } = answered[i];
881
+ const p = "error" in result ? null : result.answers.ok;
882
+ return { path: target.path, shown: target.shown, kind, p: typeof p === "number" ? p : null };
883
+ }),
884
+ calls: answered.map(({ request, result }) => ({ state: request.state, ...result }))
885
+ };
886
+ }
887
+ async function runRmHook(raw, deps) {
888
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
889
+ const started = now().getTime();
890
+ const config = readConfig(deps.home);
891
+ if (!config.rm.enabled) return null;
418
892
  const input = JSON.parse(raw);
419
893
  if (typeof input !== "object" || input === null) return null;
420
894
  const command = input.tool_input?.command;
421
895
  if (input.tool_name !== "Bash" || typeof command !== "string") return null;
422
- const tmpdirs = [...new Set([deps.tmpdir, deps.env.TEMP, deps.env.TMP].filter((t) => Boolean(t)).map((t) => path4.win32.resolve(t)))];
896
+ const tmpdirs = [...new Set([deps.tmpdir, deps.env.TEMP, deps.env.TMP].filter((t) => Boolean(t)).map((t) => path7.win32.resolve(t)))];
423
897
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : deps.cwd;
424
898
  const result = decide({ command, cwd, home: deps.home, tmpdirs, env: deps.env, probe: deps.probe });
425
- return result ? hookOutput(result) : null;
899
+ if (!result) return null;
900
+ if (result.decision !== "ask" || !result.targets || !config.rm.jev) return hookOutput(result);
901
+ const key = readJevKey(deps.env, config.keyFile);
902
+ if (!key) return hookOutput(result);
903
+ const keys = jevKeys(deps.env, config.keyFile);
904
+ let judged = null;
905
+ try {
906
+ judged = await judgeTargets(result.targets, input, command, key, keys, config, deps, () => now().getTime() - started);
907
+ } catch {
908
+ judged = null;
909
+ }
910
+ if (!judged) return hookOutput(result);
911
+ const pass = lifts(judged.items, config.rm.throwawayYes);
912
+ try {
913
+ appendLog(
914
+ deps.home,
915
+ now(),
916
+ {
917
+ helper: "rm",
918
+ event: "pre_tool_use",
919
+ sessionId: typeof input.session_id === "string" ? input.session_id : null,
920
+ transcriptPath: input.transcript_path,
921
+ command,
922
+ cwd,
923
+ decision: pass ? "pass" : "ask",
924
+ targets: judged.items,
925
+ jev: judged.calls
926
+ },
927
+ keys
928
+ );
929
+ } catch {
930
+ }
931
+ if (pass) return JSON.stringify({ systemMessage: passMessage(config.lang, judged.items) });
932
+ return hookOutput({ ...result, reason: `${result.reason}${askNote(config.lang, judged.items)}` });
426
933
  }
427
934
 
428
935
  // src/rm/hook.ts
@@ -430,7 +937,7 @@ async function main() {
430
937
  let raw = "";
431
938
  process.stdin.setEncoding("utf8");
432
939
  for await (const chunk of process.stdin) raw += chunk;
433
- const out = runRmHook(raw, { home: os.homedir(), env: process.env, tmpdir: os.tmpdir(), cwd: process.cwd(), probe: realProbe });
940
+ const out = await runRmHook(raw, { home: os.homedir(), env: process.env, tmpdir: os.tmpdir(), cwd: process.cwd(), probe: realProbe });
434
941
  if (out) process.stdout.write(out);
435
942
  }
436
943
  main().catch(() => {