@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/README.md +52 -20
- package/dist/cli.js +248 -34
- package/dist/context-hook.js +111 -44
- package/dist/rm-hook.js +532 -25
- package/dist/statusline.js +84 -14
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +15 -6
- package/package.json +1 -1
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
|
|
204
|
-
let value = expand({ value: word.value.slice(
|
|
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(
|
|
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
|
|
218
|
-
if (SKIP_WORDS.has(rest[0].value) ||
|
|
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 (
|
|
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 (
|
|
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
|
|
306
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
|
|
417
|
-
|
|
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) =>
|
|
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
|
-
|
|
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(() => {
|