@kurtel/cli 0.1.8 → 0.1.12

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.
@@ -0,0 +1,138 @@
1
+ import { repoRoot, repoFullName, loadIndex, loadMemoryCache, memoryEnabled, loadMemoryState, saveMemoryState, queueTelemetry, } from "../memory/store.js";
2
+ import { compileCapsule, compileZoneCapsule, findSimilarRoutes } from "../memory/capsule.js";
3
+ import { syncInBackground } from "../memory/sync.js";
4
+ function readStdin() {
5
+ return new Promise((resolve) => {
6
+ let data = "";
7
+ process.stdin.setEncoding("utf8");
8
+ process.stdin.on("data", (c) => (data += c));
9
+ process.stdin.on("end", () => resolve(data));
10
+ process.stdin.on("error", () => resolve(data));
11
+ // garde-fou: si rien n'arrive, on rend la main vite
12
+ setTimeout(() => resolve(data), 2000).unref?.();
13
+ });
14
+ }
15
+ function emitContext(event, context) {
16
+ process.stdout.write(JSON.stringify({
17
+ hookSpecificOutput: { hookEventName: event, additionalContext: context },
18
+ }));
19
+ }
20
+ export async function hookCommand(event) {
21
+ try {
22
+ const raw = await readStdin();
23
+ let input = {};
24
+ try {
25
+ input = raw ? JSON.parse(raw) : {};
26
+ }
27
+ catch { /* stdin non-JSON */ }
28
+ const root = repoRoot(input.cwd ?? process.cwd());
29
+ if (!memoryEnabled(root))
30
+ return; // off = silence total, coût zéro
31
+ switch (event) {
32
+ case "session-start": return onSessionStart(root, input);
33
+ case "user-prompt-submit": return onPrompt(root, input);
34
+ case "post-tool-use": return onPostToolUse(root, input);
35
+ case "session-end": return onSessionEnd(root, input);
36
+ default: return;
37
+ }
38
+ }
39
+ catch {
40
+ /* jamais d'erreur visible: un hook cassé est pire que pas de hook */
41
+ }
42
+ finally {
43
+ process.exitCode = 0;
44
+ }
45
+ }
46
+ // ── SessionStart: lancer le sync en arrière-plan + une ligne d'état ─────────
47
+ function onSessionStart(root, input) {
48
+ syncInBackground(root); // pull patterns + flush télémétrie, détaché
49
+ const index = loadIndex(root);
50
+ const cache = loadMemoryCache(root);
51
+ if (!index && !cache.patterns.length)
52
+ return; // rien à dire → rien dit
53
+ const bits = [];
54
+ if (index)
55
+ bits.push(`codebase index: ${index.files_indexed} files, ${index.routes.length} routes (commit ${index.commit.slice(0, 8)})`);
56
+ if (cache.patterns.length)
57
+ bits.push(`${cache.patterns.length} team patterns loaded`);
58
+ emitContext("SessionStart", `[Kurtel memory active — ${bits.join(", ")}. Relevant context will be injected per task.]`);
59
+ }
60
+ // ── UserPromptSubmit: LA capsule — intention → zones → patterns ─────────────
61
+ function onPrompt(root, input) {
62
+ const prompt = input.prompt ?? "";
63
+ if (prompt.length < 8 || prompt.startsWith("/"))
64
+ return; // slash commands & micro-prompts: silence
65
+ const index = loadIndex(root);
66
+ const cache = loadMemoryCache(root);
67
+ const capsule = compileCapsule(index, cache.patterns, prompt);
68
+ if (!capsule)
69
+ return;
70
+ emitContext("UserPromptSubmit", capsule.text);
71
+ // mémorise les zones couvertes (pour ne pas répéter au PostToolUse)
72
+ const sid = input.session_id ?? "unknown";
73
+ const state = loadMemoryState(root);
74
+ state.session_zones[sid] = [...new Set([...(state.session_zones[sid] ?? []), ...capsule.zones])];
75
+ saveMemoryState(root, state);
76
+ if (capsule.injectedPatternIds.length) {
77
+ queueTelemetry(root, {
78
+ ts: new Date().toISOString(),
79
+ session_id: sid,
80
+ repo: repoFullName(root),
81
+ kind: "patterns_injected",
82
+ pattern_ids: capsule.injectedPatternIds,
83
+ });
84
+ }
85
+ }
86
+ // ── PostToolUse: dérive d'intention + anti-duplication de routes ────────────
87
+ const ROUTE_WRITE = /\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]+)["'`]|@(?:Get|Post|Put|Patch|Delete)\s*\(\s*["'`]([^"'`]+)["'`]|@\w+\.(?:get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/;
88
+ function onPostToolUse(root, input) {
89
+ if (!["Edit", "Write", "MultiEdit"].includes(input.tool_name ?? ""))
90
+ return;
91
+ const ti = input.tool_input ?? {};
92
+ const filePathRaw = (ti.file_path ?? ti.path ?? "");
93
+ if (!filePathRaw)
94
+ return;
95
+ const filePath = filePathRaw.replace(/\\/g, "/").replace(root.replace(/\\/g, "/") + "/", "");
96
+ const index = loadIndex(root);
97
+ const cache = loadMemoryCache(root);
98
+ const sid = input.session_id ?? "unknown";
99
+ const state = loadMemoryState(root);
100
+ const seen = new Set(state.session_zones[sid] ?? []);
101
+ const messages = [];
102
+ // 1. Anti-duplication: l'agent vient-il d'écrire une route qui existe déjà ?
103
+ const written = (ti.new_string ?? ti.content ?? "");
104
+ const rm = written.match(ROUTE_WRITE);
105
+ if (rm && index) {
106
+ const newPath = rm[2] ?? rm[3] ?? rm[4] ?? "";
107
+ const similar = newPath ? findSimilarRoutes(index, newPath).filter((r) => r.file !== filePath) : [];
108
+ if (similar.length) {
109
+ messages.push(`[Kurtel duplicate-route check] The route you just wrote ("${newPath}") looks similar to existing routes:\n` +
110
+ similar.map((r) => `- ${r.method} ${r.path} → ${r.file}:${r.line}`).join("\n") +
111
+ `\nVerify you are not re-implementing existing work; prefer extending the existing handler.`);
112
+ queueTelemetry(root, {
113
+ ts: new Date().toISOString(), session_id: sid, repo: repoFullName(root),
114
+ kind: "duplicate_flagged", detail: newPath,
115
+ });
116
+ }
117
+ }
118
+ // 2. Dérive d'intention: nouvelle zone du graphe → micro-capsule de zone.
119
+ const zone = filePath.includes("/") ? filePath.split("/").slice(0, 2).join("/") : filePath;
120
+ if (!seen.has(zone)) {
121
+ const zc = compileZoneCapsule(index, cache.patterns, filePath);
122
+ if (zc) {
123
+ messages.push(zc.text);
124
+ queueTelemetry(root, {
125
+ ts: new Date().toISOString(), session_id: sid, repo: repoFullName(root),
126
+ kind: "zone_entered", detail: zone, pattern_ids: zc.injectedPatternIds,
127
+ });
128
+ }
129
+ state.session_zones[sid] = [...seen, zone];
130
+ saveMemoryState(root, state);
131
+ }
132
+ if (messages.length)
133
+ emitContext("PostToolUse", messages.join("\n\n"));
134
+ }
135
+ // ── SessionEnd / Stop: flush télémétrie en arrière-plan ─────────────────────
136
+ function onSessionEnd(root, _input) {
137
+ syncInBackground(root);
138
+ }
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { c, symbols } from "../ui/colors.js";
4
+ import { loadConfig } from "../lib/config.js";
5
+ import { repoRoot } from "../memory/store.js";
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // `kurtel install claude-code`
8
+ // 1. Slash commands → .claude/commands/kurtel/*.md (/kurtel:onboard, etc.)
9
+ // 2. Hooks → .claude/settings.json (merge JSON structuré, idempotent —
10
+ // la leçon Graphify: jamais de manipulation de chaîne sur les settings)
11
+ // 3. Vérifie le login; réutilise le token du CLI (zéro re-auth)
12
+ // `kurtel uninstall claude-code` retire proprement nos entrées, rien d'autre.
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ const KURTEL_HOOK_MARKER = "kurtel hook"; // identifie NOS hooks dans settings.json
15
+ const HOOK_EVENTS = [
16
+ { event: "SessionStart", sub: "session-start" },
17
+ { event: "UserPromptSubmit", sub: "user-prompt-submit" },
18
+ { event: "PostToolUse", sub: "post-tool-use" },
19
+ { event: "SessionEnd", sub: "session-end" },
20
+ ];
21
+ // ── Slash commands (markdown, format Claude Code custom commands) ───────────
22
+ const SLASH_COMMANDS = {
23
+ "onboard.md": `---
24
+ description: Index this codebase and activate Kurtel memory (architecture audit + route inventory)
25
+ allowed-tools: Bash(kurtel onboard:*), Read
26
+ ---
27
+ Run \`kurtel onboard --json\` with the Bash tool, then:
28
+ 1. Parse the JSON output.
29
+ 2. Present the architecture snapshot to the user: domains, god nodes (with edge counts — explain these are high-coupling hotspots), and the number of inventoried routes.
30
+ 3. Tell them the full report is at the path in \`report_path\` and that Kurtel memory is now active: relevant context (existing routes, team conventions) will be injected automatically per task.
31
+ 4. If \`uploaded\` is false, mention that cloud sync failed and they can retry with \`kurtel memory sync\` (memory still works locally).
32
+ Do not re-run indexing if the command fails twice; show the error instead.
33
+ `,
34
+ "memory.md": `---
35
+ description: Toggle or inspect Kurtel memory (on / off / status / sync / patterns)
36
+ allowed-tools: Bash(kurtel memory:*)
37
+ ---
38
+ The user said: "$ARGUMENTS"
39
+ - If it contains "off" or "disable" → run \`kurtel memory off\`
40
+ - If it contains "on" or "enable" → run \`kurtel memory on\`
41
+ - If it contains "sync" → run \`kurtel memory sync\`
42
+ - If it contains "pattern" → run \`kurtel memory patterns --json\` and present the patterns as a readable list (rule, confidence, zones, evidence count), sorted by confidence
43
+ - Otherwise → run \`kurtel memory status --json\` and present a one-line summary
44
+ Relay the result conversationally. Never paste raw JSON to the user.
45
+ `,
46
+ "status.md": `---
47
+ description: Show Kurtel memory status for this repo
48
+ allowed-tools: Bash(kurtel memory status:*)
49
+ ---
50
+ Run \`kurtel memory status --json\` and summarize in 2-3 lines: memory active or not, index freshness (suggest \`/kurtel:onboard\` if none), number of team patterns loaded and last sync time.
51
+ `,
52
+ };
53
+ function isKurtelEntry(h) {
54
+ return typeof h.command === "string" && h.command.includes(KURTEL_HOOK_MARKER);
55
+ }
56
+ function mergeHooks(settings) {
57
+ const hooks = settings.hooks ?? {};
58
+ for (const { event, sub } of HOOK_EVENTS) {
59
+ const matchers = hooks[event] ?? [];
60
+ // retirer toute ancienne entrée kurtel (idempotence), sans toucher au reste
61
+ for (const m of matchers)
62
+ m.hooks = m.hooks.filter((h) => !isKurtelEntry(h));
63
+ const cleaned = matchers.filter((m) => m.hooks.length > 0);
64
+ const entry = { type: "command", command: `kurtel hook ${sub}`, timeout: 10 };
65
+ if (event === "PostToolUse") {
66
+ cleaned.push({ matcher: "Edit|Write|MultiEdit", hooks: [entry] });
67
+ }
68
+ else {
69
+ cleaned.push({ hooks: [entry] });
70
+ }
71
+ hooks[event] = cleaned;
72
+ }
73
+ settings.hooks = hooks;
74
+ return settings;
75
+ }
76
+ function removeHooks(settings) {
77
+ if (!settings.hooks)
78
+ return settings;
79
+ for (const event of Object.keys(settings.hooks)) {
80
+ const matchers = settings.hooks[event]
81
+ .map((m) => ({ ...m, hooks: m.hooks.filter((h) => !isKurtelEntry(h)) }))
82
+ .filter((m) => m.hooks.length > 0);
83
+ if (matchers.length)
84
+ settings.hooks[event] = matchers;
85
+ else
86
+ delete settings.hooks[event];
87
+ }
88
+ if (!Object.keys(settings.hooks).length)
89
+ delete settings.hooks;
90
+ return settings;
91
+ }
92
+ function readSettings(file) {
93
+ try {
94
+ if (!existsSync(file))
95
+ return {};
96
+ return JSON.parse(readFileSync(file, "utf8"));
97
+ }
98
+ catch (e) {
99
+ throw new Error(`.claude/settings.json exists but is not valid JSON — fix it first (${e instanceof Error ? e.message : e}).`);
100
+ }
101
+ }
102
+ // ── Commandes ───────────────────────────────────────────────────────────────
103
+ export async function installClaudeCodeCommand() {
104
+ const root = repoRoot();
105
+ const claudeDir = join(root, ".claude");
106
+ const cmdDir = join(claudeDir, "commands", "kurtel");
107
+ const settingsFile = join(claudeDir, "settings.json");
108
+ console.log("");
109
+ // 1. Login check (réutilise le token CLI — pas de re-auth).
110
+ const config = loadConfig();
111
+ if (config.loggedIn && config.token) {
112
+ console.log(`${symbols.check} Using your Kurtel session ${c.dim(`(${config.account ?? "account"})`)}`);
113
+ }
114
+ else {
115
+ console.log(`${c.yellow(symbols.warn)} ${c.dim("Not signed in — memory will work locally; run")} ${c.indigo("kurtel login")} ${c.dim("to sync patterns.")}`);
116
+ }
117
+ // 2. Slash commands.
118
+ if (!existsSync(cmdDir))
119
+ mkdirSync(cmdDir, { recursive: true });
120
+ for (const [name, content] of Object.entries(SLASH_COMMANDS)) {
121
+ writeFileSync(join(cmdDir, name), content, "utf8");
122
+ }
123
+ console.log(`${symbols.check} Slash commands installed: ${c.indigo("/kurtel:onboard")}, ${c.indigo("/kurtel:memory")}, ${c.indigo("/kurtel:status")}`);
124
+ // 3. Hooks (merge structuré, idempotent).
125
+ let settings;
126
+ try {
127
+ settings = readSettings(settingsFile);
128
+ }
129
+ catch (e) {
130
+ console.log(`${c.red(symbols.cross)} ${e instanceof Error ? e.message : String(e)}`);
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ settings = mergeHooks(settings);
135
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
136
+ console.log(`${symbols.check} Hooks wired into ${c.indigo(".claude/settings.json")} ${c.dim("(SessionStart, UserPromptSubmit, PostToolUse, SessionEnd)")}`);
137
+ console.log("");
138
+ console.log(`${c.dim("Next: open Claude Code in this repo and run")} ${c.indigo("/kurtel:onboard")} ${c.dim("to index the codebase.")}`);
139
+ console.log(`${c.dim("Memory is")} ${c.indigo("on")} ${c.dim("by default — toggle with")} ${c.indigo("/kurtel:memory off")}${c.dim(".")}`);
140
+ console.log("");
141
+ }
142
+ export async function uninstallClaudeCodeCommand() {
143
+ const root = repoRoot();
144
+ const settingsFile = join(root, ".claude", "settings.json");
145
+ if (existsSync(settingsFile)) {
146
+ try {
147
+ const settings = removeHooks(readSettings(settingsFile));
148
+ writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
149
+ console.log(`${symbols.check} Kurtel hooks removed from .claude/settings.json ${c.dim("(other hooks untouched)")}`);
150
+ }
151
+ catch (e) {
152
+ console.log(`${c.red(symbols.cross)} ${e instanceof Error ? e.message : String(e)}`);
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ }
157
+ console.log(`${c.dim("You can delete")} ${c.indigo(".claude/commands/kurtel/")} ${c.dim("to remove the slash commands.")}`);
158
+ }
@@ -0,0 +1,89 @@
1
+ import { c, symbols } from "../ui/colors.js";
2
+ import { Spinner } from "../ui/spinner.js";
3
+ import { repoRoot, loadIndex, loadMemoryCache, memoryEnabled, setMemoryEnabled, } from "../memory/store.js";
4
+ import { syncNow } from "../memory/sync.js";
5
+ function ago(iso) {
6
+ if (!iso)
7
+ return "never";
8
+ const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
9
+ if (m < 1)
10
+ return "just now";
11
+ if (m < 60)
12
+ return `${m}m ago`;
13
+ const h = Math.floor(m / 60);
14
+ if (h < 24)
15
+ return `${h}h ago`;
16
+ return `${Math.floor(h / 24)}d ago`;
17
+ }
18
+ export async function memoryCommand(action, opts = {}) {
19
+ const root = repoRoot();
20
+ switch (action) {
21
+ case "on":
22
+ setMemoryEnabled(root, true);
23
+ console.log(`${symbols.check} Kurtel memory ${c.indigo("enabled")} for this repo.`);
24
+ return;
25
+ case "off":
26
+ setMemoryEnabled(root, false);
27
+ console.log(`${symbols.check} Kurtel memory ${c.yellow("disabled")} for this repo ${c.dim("(hooks stay installed, injection is skipped)")}.`);
28
+ return;
29
+ case "sync": {
30
+ const spin = opts.quiet ? null : new Spinner("Syncing memory…").start();
31
+ const res = await syncNow(root);
32
+ spin?.succeed(`Synced · ${res.pulled} patterns pulled · ${res.flushed} telemetry events flushed`);
33
+ return;
34
+ }
35
+ case "patterns": {
36
+ const cache = loadMemoryCache(root);
37
+ if (opts.json) {
38
+ process.stdout.write(JSON.stringify(cache.patterns));
39
+ return;
40
+ }
41
+ if (!cache.patterns.length) {
42
+ console.log(c.dim("No patterns yet. They are learned from merged PRs and pulled with `kurtel memory sync`."));
43
+ return;
44
+ }
45
+ console.log("");
46
+ for (const p of [...cache.patterns].sort((a, b) => b.score - a.score)) {
47
+ const bar = "█".repeat(Math.round(p.score * 10)).padEnd(10, "░");
48
+ const zones = p.zones.length ? p.zones.join(", ") : "global";
49
+ console.log(`${c.indigo(bar)} ${c.dim(String(Math.round(p.score * 100)).padStart(3) + "%")} ${p.pinned ? c.yellow("★ ") : " "}${c.white(p.rule)}`);
50
+ console.log(` ${c.gray("zones")} ${c.dim(zones)} ${c.gray("· evidence")} ${c.dim(String(p.evidence.length) + " PRs")}`);
51
+ }
52
+ console.log("");
53
+ return;
54
+ }
55
+ case undefined:
56
+ case "status": {
57
+ const enabled = memoryEnabled(root);
58
+ const index = loadIndex(root);
59
+ const cache = loadMemoryCache(root);
60
+ if (opts.json) {
61
+ process.stdout.write(JSON.stringify({
62
+ enabled,
63
+ index: index ? { files: index.files_indexed, routes: index.routes.length, commit: index.commit, generated_at: index.generated_at } : null,
64
+ patterns: cache.patterns.length,
65
+ synced_at: cache.patterns_synced_at,
66
+ pending_telemetry: cache.pending_telemetry.length,
67
+ }));
68
+ return;
69
+ }
70
+ console.log("");
71
+ console.log(`${c.gray("memory")} ${enabled ? c.indigo("● active") : c.yellow("○ disabled")}`);
72
+ if (index) {
73
+ console.log(`${c.gray("index")} ${c.white(`${index.files_indexed} files · ${index.routes.length} routes`)} ${c.dim(`(${ago(index.generated_at)}, commit ${index.commit.slice(0, 8)})`)}`);
74
+ }
75
+ else {
76
+ console.log(`${c.gray("index")} ${c.dim("none — run `kurtel onboard`")}`);
77
+ }
78
+ console.log(`${c.gray("patterns")} ${c.white(String(cache.patterns.length))} ${c.dim(`(synced ${ago(cache.patterns_synced_at)})`)}`);
79
+ if (cache.pending_telemetry.length) {
80
+ console.log(`${c.gray("pending")} ${c.dim(`${cache.pending_telemetry.length} telemetry events (flushed on next sync)`)}`);
81
+ }
82
+ console.log("");
83
+ return;
84
+ }
85
+ default:
86
+ console.log(`${c.red(symbols.cross)} Unknown action ${c.white(action)}. Try ${c.indigo("status")}, ${c.indigo("on")}, ${c.indigo("off")}, ${c.indigo("sync")}, or ${c.indigo("patterns")}.`);
87
+ process.exitCode = 1;
88
+ }
89
+ }
@@ -0,0 +1,72 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { c, symbols } from "../ui/colors.js";
4
+ import { Spinner } from "../ui/spinner.js";
5
+ import { buildIndex, renderReport } from "../memory/indexer.js";
6
+ import { repoRoot, saveIndex, reportPath, loadMemoryCache } from "../memory/store.js";
7
+ import { syncIndexUp, syncNow } from "../memory/sync.js";
8
+ export async function onboardCommand(opts = {}) {
9
+ const root = repoRoot();
10
+ // 1. Index structurel — local, déterministe, 0 token.
11
+ const spin = opts.json ? null : new Spinner("Indexing codebase (local, deterministic)…").start();
12
+ const index = buildIndex(root, (n) => {
13
+ spin?.update(`Indexing codebase… ${n} files`);
14
+ });
15
+ saveIndex(root, index);
16
+ spin?.succeed(`Indexed ${index.files_indexed} files · ${index.routes.length} routes · ${index.god_nodes.length} god nodes`);
17
+ // 2. Rapport d'audit — le livrable du jour 0.
18
+ const report = renderReport(index);
19
+ const rp = reportPath(root);
20
+ const dir = join(root, ".kurtel");
21
+ if (!existsSync(dir))
22
+ mkdirSync(dir, { recursive: true });
23
+ writeFileSync(rp, report, "utf8");
24
+ // 3. Sync: pull des patterns + push du digest pour l'app web.
25
+ let uploaded = false;
26
+ let patterns = 0;
27
+ if (!opts.local) {
28
+ const spin2 = opts.json ? null : new Spinner("Syncing memory with Kurtel cloud…").start();
29
+ uploaded = await syncIndexUp(root);
30
+ try {
31
+ const r = await syncNow(root);
32
+ patterns = loadMemoryCache(root).patterns.length;
33
+ void r;
34
+ }
35
+ catch { /* offline ok */ }
36
+ if (spin2) {
37
+ if (uploaded)
38
+ spin2.succeed(`Memory synced · ${patterns} team patterns pulled`);
39
+ else
40
+ spin2.fail("Cloud sync failed (offline or not signed in) — memory works locally; run `kurtel memory sync` later.");
41
+ }
42
+ }
43
+ if (opts.json) {
44
+ // Sortie machine pour le slash command /kurtel:onboard — l'agent lit ce JSON
45
+ // et présente le rapport à l'utilisateur dans la session.
46
+ process.stdout.write(JSON.stringify({
47
+ ok: true,
48
+ repo: index.repo,
49
+ files_indexed: index.files_indexed,
50
+ routes: index.routes.length,
51
+ god_nodes: index.god_nodes,
52
+ domains: index.domains.slice(0, 10),
53
+ report_path: rp,
54
+ uploaded,
55
+ patterns_loaded: patterns,
56
+ }));
57
+ return;
58
+ }
59
+ // Affichage humain
60
+ console.log("");
61
+ console.log(`${c.indigoBold("Architecture snapshot")}`);
62
+ console.log(`${c.gray("repo")} ${c.white(index.repo)}`);
63
+ console.log(`${c.gray("domains")} ${c.white(index.domains.slice(0, 6).map((d) => d.name).join(", "))}`);
64
+ if (index.god_nodes.length) {
65
+ console.log(`${c.gray("hotspots")} ${index.god_nodes.slice(0, 3).map((g) => `${c.indigo(g.id)} ${c.dim(`(${g.degree} edges)`)}`).join(" ")}`);
66
+ }
67
+ console.log(`${c.gray("routes")} ${c.white(String(index.routes.length))} ${c.dim("inventoried — duplicates will be flagged")}`);
68
+ console.log("");
69
+ console.log(`${symbols.check} Full report: ${c.indigo(rp)}`);
70
+ console.log(`${c.dim("Memory is now")} ${c.indigo("active")}${c.dim(" — context is injected per task in Claude Code.")}`);
71
+ console.log("");
72
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,10 @@ import { runsCommand } from "./commands/runs.js";
11
11
  import { logsCommand, statusCommand, stopCommand } from "./commands/runtime.js";
12
12
  import { configCommand } from "./commands/config.js";
13
13
  import { initCommand, doctorCommand } from "./commands/project.js";
14
+ import { onboardCommand } from "./commands/onboard.js";
15
+ import { memoryCommand } from "./commands/memory.js";
16
+ import { installClaudeCodeCommand, uninstallClaudeCodeCommand } from "./commands/installClaudeCode.js";
17
+ import { hookCommand } from "./commands/hook.js";
14
18
  const program = new Command();
15
19
  program
16
20
  .name("kurtel")
@@ -86,6 +90,46 @@ program
86
90
  .command("doctor")
87
91
  .description("Check your environment")
88
92
  .action(() => doctorCommand());
93
+ // ── Memory & Claude Code integration ─────────────────────────────────────────
94
+ program
95
+ .command("install")
96
+ .description("Install a Kurtel integration (claude-code)")
97
+ .argument("<target>", "Integration target: claude-code")
98
+ .action(async (target) => {
99
+ if (target === "claude-code")
100
+ return installClaudeCodeCommand();
101
+ console.log(`${c.red(symbols.cross)} Unknown target ${c.white(target)}. Try ${c.indigo("claude-code")}.`);
102
+ process.exitCode = 1;
103
+ });
104
+ program
105
+ .command("uninstall")
106
+ .description("Remove a Kurtel integration (claude-code)")
107
+ .argument("<target>", "Integration target: claude-code")
108
+ .action(async (target) => {
109
+ if (target === "claude-code")
110
+ return uninstallClaudeCodeCommand();
111
+ console.log(`${c.red(symbols.cross)} Unknown target ${c.white(target)}.`);
112
+ process.exitCode = 1;
113
+ });
114
+ program
115
+ .command("onboard")
116
+ .alias("setup")
117
+ .description("Index this codebase and activate Kurtel memory")
118
+ .option("--json", "Machine-readable output (used by /kurtel:onboard)", false)
119
+ .option("--local", "Skip cloud upload — index stays on disk", false)
120
+ .action(async (opts) => onboardCommand(opts));
121
+ program
122
+ .command("memory")
123
+ .description("Inspect or toggle Kurtel memory (status | on | off | sync | patterns)")
124
+ .argument("[action]", "status | on | off | sync | patterns", "status")
125
+ .option("--json", "Machine-readable output", false)
126
+ .option("--quiet", "No spinner/log output (used by background sync)", false)
127
+ .action(async (action, opts) => memoryCommand(action, opts));
128
+ // Appelée par Claude Code via les hooks — pas par un humain. Cachée du help.
129
+ program
130
+ .command("hook", { hidden: true })
131
+ .argument("<event>", "session-start | user-prompt-submit | post-tool-use | session-end")
132
+ .action(async (event) => hookCommand(event));
89
133
  async function main() {
90
134
  try {
91
135
  await program.parseAsync(process.argv);
@@ -0,0 +1,49 @@
1
+ import { apiUrl, loadConfig } from "../lib/config.js";
2
+ import { AuthError } from "../lib/api.js";
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // REMPLACE src/memory/api.ts du livrable précédent.
5
+ // Changement: repo passé en query param (?repo=owner/name) au lieu d'un segment
6
+ // de path — un "/" encodé dans un segment dynamique Next.js/Vercel est fragile.
7
+ // Endpoints implémentés côté kurtel-app:
8
+ // GET /api/memory/patterns?repo=...&since=...
9
+ // PUT /api/memory/index?repo=...
10
+ // POST /api/memory/telemetry?repo=...
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ async function authed(method, path, body) {
13
+ const token = loadConfig().token;
14
+ if (!token)
15
+ throw new AuthError("Not signed in. Run `kurtel login`.");
16
+ const res = await fetch(`${apiUrl()}${path}`, {
17
+ method,
18
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
19
+ body: body === undefined ? undefined : JSON.stringify(body),
20
+ });
21
+ const text = await res.text();
22
+ let data = {};
23
+ try {
24
+ data = text ? JSON.parse(text) : {};
25
+ }
26
+ catch { /* non-JSON */ }
27
+ if (res.status === 401)
28
+ throw new AuthError("Your session is invalid or expired. Run `kurtel login`.");
29
+ if (!res.ok)
30
+ throw new Error(data?.error ?? `error ${res.status}`);
31
+ return data;
32
+ }
33
+ const q = (repo, extra = {}) => "?" + new URLSearchParams({ repo, ...extra }).toString();
34
+ /** Pull delta de la mémoire darwinienne (repo_skills côté backend). */
35
+ export function pullPatterns(repo, since) {
36
+ return authed("GET", `/api/memory/patterns${q(repo, since ? { since } : {})}`);
37
+ }
38
+ /** Push de la mémoire de codebase (digest, jamais le code source). */
39
+ export function pushIndex(repo, index) {
40
+ const digest = {
41
+ ...index,
42
+ modules: index.modules.map((m) => ({ ...m, exports: m.exports.slice(0, 10) })).slice(0, 3000),
43
+ };
44
+ return authed("PUT", `/api/memory/index${q(repo)}`, digest);
45
+ }
46
+ /** Push asynchrone de la télémétrie d'usage des patterns. */
47
+ export function pushTelemetry(repo, events) {
48
+ return authed("POST", `/api/memory/telemetry${q(repo)}`, { events });
49
+ }
@@ -0,0 +1,137 @@
1
+ import { findSimilarRoutes } from "./indexer.js";
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // Capsule — le cœur du produit. À chaque prompt:
4
+ // intention → résolution spatiale (zones du graphe) → sélection de patterns
5
+ // → capsule compacte (budget ~400 tokens) injectée via le hook.
6
+ // Tout se calcule en local, en millisecondes, sans réseau.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ const TOKEN_BUDGET_CHARS = 1600; // ~400 tokens
9
+ const STOPWORDS = new Set([
10
+ "the", "a", "an", "to", "for", "of", "in", "on", "and", "or", "with", "add",
11
+ "create", "make", "fix", "update", "change", "new", "page", "file", "please",
12
+ "le", "la", "les", "un", "une", "des", "de", "du", "et", "ou", "pour", "dans",
13
+ "ajoute", "ajouter", "crée", "creer", "modifie", "modifier", "corrige", "il", "faut",
14
+ ]);
15
+ export function tokenize(text) {
16
+ return [...new Set(text.toLowerCase()
17
+ .split(/[^a-z0-9_]+/)
18
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w)))];
19
+ }
20
+ // ── Résolution spatiale: quelles zones du repo l'intention touche-t-elle ? ──
21
+ export function resolveZones(index, promptTokens) {
22
+ const scores = new Map();
23
+ for (const m of index.modules) {
24
+ const hay = m.id.toLowerCase();
25
+ let s = 0;
26
+ for (const t of promptTokens)
27
+ if (hay.includes(t))
28
+ s += 2;
29
+ for (const e of m.exports) {
30
+ const el = e.toLowerCase();
31
+ for (const t of promptTokens)
32
+ if (el.includes(t))
33
+ s += 1;
34
+ }
35
+ if (s > 0) {
36
+ const zone = m.id.includes("/") ? m.id.split("/").slice(0, 2).join("/") : m.id;
37
+ scores.set(zone, (scores.get(zone) ?? 0) + s);
38
+ }
39
+ }
40
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([z]) => z);
41
+ }
42
+ export function selectPatterns(patterns, promptTokens, zones, max = 5) {
43
+ const out = [];
44
+ for (const p of patterns) {
45
+ if (p.score < 0.3 && !p.pinned)
46
+ continue; // les mourants ne parlent plus
47
+ let s = 0;
48
+ if (p.pinned)
49
+ s += 10; // socle: toujours présent
50
+ for (const trig of p.triggers) {
51
+ const tl = trig.toLowerCase();
52
+ if (promptTokens.some((t) => t === tl || tl.includes(t) || t.includes(tl)))
53
+ s += 4;
54
+ }
55
+ for (const z of p.zones) {
56
+ if (zones.some((zone) => zone.startsWith(z) || z.startsWith(zone)))
57
+ s += 3;
58
+ }
59
+ if (p.zones.length === 0 && s > 0)
60
+ s += 1; // global + déjà pertinent
61
+ if (s > 0)
62
+ out.push({ pattern: p, score: s * (0.5 + p.score) });
63
+ }
64
+ return out.sort((a, b) => b.score - a.score).slice(0, max);
65
+ }
66
+ // ── Routes pertinentes pour l'intention (anti-duplication proactive) ────────
67
+ export function relevantRoutes(index, promptTokens) {
68
+ const scored = index.routes.map((r) => {
69
+ const hay = `${r.path} ${r.file}`.toLowerCase();
70
+ let s = 0;
71
+ for (const t of promptTokens)
72
+ if (hay.includes(t))
73
+ s++;
74
+ return { r, s };
75
+ });
76
+ return scored.filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 6).map((x) => x.r);
77
+ }
78
+ export function compileCapsule(index, patterns, prompt) {
79
+ const tokens = tokenize(prompt);
80
+ if (!tokens.length)
81
+ return null;
82
+ const zones = index ? resolveZones(index, tokens) : [];
83
+ const selected = selectPatterns(patterns, tokens, zones);
84
+ const routes = index ? relevantRoutes(index, tokens) : [];
85
+ const gods = index
86
+ ? index.god_nodes.filter((g) => zones.some((z) => g.id.startsWith(z))).slice(0, 2)
87
+ : [];
88
+ if (!selected.length && !routes.length && !gods.length)
89
+ return null; // le silence est le défaut
90
+ const parts = [];
91
+ parts.push(`[Kurtel memory — auto-injected, relevant to this task only]`);
92
+ if (routes.length) {
93
+ parts.push(`Existing routes in this area (do NOT recreate; extend or reuse):`);
94
+ for (const r of routes)
95
+ parts.push(`- ${r.method} ${r.path} → ${r.file}:${r.line}`);
96
+ }
97
+ if (selected.length) {
98
+ parts.push(`Team conventions learned from merged PRs (follow them):`);
99
+ for (const { pattern } of selected) {
100
+ const conf = Math.round(pattern.score * 100);
101
+ parts.push(`- ${pattern.rule} (confidence ${conf}%)`);
102
+ }
103
+ }
104
+ if (gods.length) {
105
+ parts.push(`High-coupling modules in this zone — changes here have a wide blast radius, check dependents:`);
106
+ for (const g of gods)
107
+ parts.push(`- ${g.id} (${g.degree} edges)`);
108
+ }
109
+ let text = parts.join("\n");
110
+ if (text.length > TOKEN_BUDGET_CHARS)
111
+ text = text.slice(0, TOKEN_BUDGET_CHARS - 1) + "…";
112
+ return { text, injectedPatternIds: selected.map((s) => s.pattern.id), zones };
113
+ }
114
+ // ── Capsule de zone (dérive d'intention en cours de session) ────────────────
115
+ export function compileZoneCapsule(index, patterns, filePath) {
116
+ const zone = filePath.includes("/") ? filePath.split("/").slice(0, 2).join("/") : filePath;
117
+ const zonePatterns = patterns.filter((p) => p.score >= 0.3 && p.zones.some((z) => zone.startsWith(z) || z.startsWith(zone))).sort((a, b) => b.score - a.score).slice(0, 3);
118
+ const zoneRoutes = index ? index.routes.filter((r) => r.file.startsWith(zone)).slice(0, 5) : [];
119
+ if (!zonePatterns.length && !zoneRoutes.length)
120
+ return null;
121
+ const parts = [`[Kurtel memory — you just entered zone "${zone}"]`];
122
+ if (zonePatterns.length) {
123
+ parts.push(`Conventions for this zone:`);
124
+ for (const p of zonePatterns)
125
+ parts.push(`- ${p.rule}`);
126
+ }
127
+ if (zoneRoutes.length) {
128
+ parts.push(`Routes defined here:`);
129
+ for (const r of zoneRoutes)
130
+ parts.push(`- ${r.method} ${r.path} (${r.file}:${r.line})`);
131
+ }
132
+ let text = parts.join("\n");
133
+ if (text.length > 800)
134
+ text = text.slice(0, 799) + "…";
135
+ return { text, injectedPatternIds: zonePatterns.map((p) => p.id), zones: [zone] };
136
+ }
137
+ export { findSimilarRoutes };
@@ -0,0 +1,280 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative, extname, dirname } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { repoFullName } from "./store.js";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Indexeur structurel — 100% local, 0 token, déterministe (même input → même
7
+ // output). Heuristiques regex pragmatiques pour TS/JS/Python ; le point
8
+ // d'extension propre pour passer à tree-sitter plus tard est extractFile().
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]);
11
+ const IGNORE_DIRS = new Set([
12
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "coverage",
13
+ "vendor", "__pycache__", ".venv", "venv", ".kurtel", ".claude", ".idea", ".vscode",
14
+ ]);
15
+ // Filtrage anti-bruit (la leçon Graphify) : on indexe le code, pas les assets/config.
16
+ const IGNORE_FILES = /\.(min\.js|d\.ts|test\.[jt]sx?|spec\.[jt]sx?|stories\.[jt]sx?)$|(^|\/)(conftest|setup)\.py$/;
17
+ // Patterns par langage — jamais croisés (sinon `@app.get(...)` Python matche aussi le pattern Express).
18
+ const JS_ROUTE_PATTERNS = [
19
+ // Express / Fastify / Koa-router (router.get("/x"), app.post('/y'))
20
+ {
21
+ re: /\b(?:app|router|server|api|fastify)\s*\.\s*(get|post|put|patch|delete|head|options|all)\s*\(\s*["'`]([^"'`]+)["'`]/g,
22
+ framework: "express-like",
23
+ method: (m) => m[1].toUpperCase(),
24
+ path: (m) => m[2],
25
+ },
26
+ // Décorateurs Nest: @Get("/x"), @Post()
27
+ {
28
+ re: /@(Get|Post|Put|Patch|Delete|Head|Options)\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)/g,
29
+ framework: "nest",
30
+ method: (m) => m[1].toUpperCase(),
31
+ path: (m) => m[2] ?? "",
32
+ },
33
+ ];
34
+ const PY_ROUTE_PATTERNS = [
35
+ // FastAPI / Flask: @app.get("/x"), @router.post("/y"), @app.route("/z", methods=["POST"])
36
+ {
37
+ re: /@\s*[\w.]+\.(get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/g,
38
+ framework: "python",
39
+ method: (m) => (m[1] === "route" ? "*" : m[1].toUpperCase()),
40
+ path: (m) => m[2],
41
+ },
42
+ ];
43
+ const NEXT_ROUTE_FILE = /(^|\/)(pages|app)\/(.+?)\/?(route|page|index)?\.(ts|tsx|js|jsx)$/;
44
+ function extractFile(root, rel, src) {
45
+ const lines = src.split("\n");
46
+ const facts = { rel, loc: lines.length, exports: [], importSpecs: [], routes: [] };
47
+ const ext = extname(rel);
48
+ if (ext === ".py") {
49
+ for (const m of src.matchAll(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm)) {
50
+ facts.importSpecs.push(m[1] ?? m[2]);
51
+ }
52
+ for (const m of src.matchAll(/^(?:def|class)\s+(\w+)/gm))
53
+ facts.exports.push(m[1]);
54
+ }
55
+ else {
56
+ for (const m of src.matchAll(/\bfrom\s+["']([^"']+)["']|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g)) {
57
+ facts.importSpecs.push(m[1] ?? m[2]);
58
+ }
59
+ for (const m of src.matchAll(/\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+(\w+)/g)) {
60
+ facts.exports.push(m[1]);
61
+ }
62
+ }
63
+ // Routes par appel/décorateur, avec n° de ligne — patterns du bon langage uniquement.
64
+ const routePatterns = ext === ".py" ? PY_ROUTE_PATTERNS : JS_ROUTE_PATTERNS;
65
+ for (const p of routePatterns) {
66
+ for (const m of src.matchAll(p.re)) {
67
+ const upto = src.slice(0, m.index ?? 0);
68
+ facts.routes.push({
69
+ method: p.method(m),
70
+ path: p.path(m),
71
+ file: rel,
72
+ line: upto.split("\n").length,
73
+ framework: p.framework,
74
+ });
75
+ }
76
+ }
77
+ // Routes par convention de fichier (Next.js app/pages router).
78
+ const nm = rel.replace(/\\/g, "/").match(NEXT_ROUTE_FILE);
79
+ if (nm) {
80
+ const urlPath = "/" + nm[3].replace(/\[(\w+)\]/g, ":$1").replace(/\/index$/, "");
81
+ facts.routes.push({ method: "*", path: urlPath, file: rel, line: 1, framework: "next" });
82
+ }
83
+ return facts;
84
+ }
85
+ // ── Résolution d'imports internes (TS/JS relatifs + Python par module path) ──
86
+ function resolveImports(all) {
87
+ const byNoExt = new Map();
88
+ for (const rel of all.keys()) {
89
+ const noExt = rel.replace(/\.(ts|tsx|js|jsx|mjs|cjs|py)$/, "");
90
+ byNoExt.set(noExt.replace(/\\/g, "/"), rel);
91
+ if (noExt.endsWith("/index"))
92
+ byNoExt.set(noExt.slice(0, -"/index".length), rel);
93
+ if (noExt.endsWith("/__init__"))
94
+ byNoExt.set(noExt.slice(0, -"/__init__".length), rel);
95
+ }
96
+ const resolved = new Map();
97
+ for (const [rel, facts] of all) {
98
+ const targets = [];
99
+ for (const spec of facts.importSpecs) {
100
+ let candidate;
101
+ if (spec.startsWith(".")) {
102
+ // relatif TS/JS
103
+ const base = join(dirname(rel), spec).replace(/\\/g, "/").replace(/\.(js|ts|tsx|jsx)$/, "");
104
+ candidate = byNoExt.get(base);
105
+ }
106
+ else if (spec.includes(".") && extname(rel) === ".py") {
107
+ // module python "app.services.billing" → app/services/billing.py
108
+ candidate = byNoExt.get(spec.replace(/\./g, "/"));
109
+ }
110
+ else {
111
+ // alias absolu fréquent: "src/lib/foo", "@/lib/foo"
112
+ const cleaned = spec.replace(/^@\//, "src/").replace(/^~\//, "src/");
113
+ candidate = byNoExt.get(cleaned);
114
+ }
115
+ if (candidate && candidate !== rel)
116
+ targets.push(candidate);
117
+ }
118
+ resolved.set(rel, [...new Set(targets)]);
119
+ }
120
+ return resolved;
121
+ }
122
+ // ── Parcours ────────────────────────────────────────────────────────────────
123
+ function* walk(dir, root) {
124
+ let entries;
125
+ try {
126
+ entries = readdirSync(dir);
127
+ }
128
+ catch {
129
+ return;
130
+ }
131
+ for (const name of entries) {
132
+ if (name.startsWith(".") && name !== ".")
133
+ continue;
134
+ const full = join(dir, name);
135
+ let st;
136
+ try {
137
+ st = statSync(full);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ if (st.isDirectory()) {
143
+ if (!IGNORE_DIRS.has(name))
144
+ yield* walk(full, root);
145
+ }
146
+ else if (st.isFile() && st.size < 1_000_000) {
147
+ const rel = relative(root, full).replace(/\\/g, "/");
148
+ if (CODE_EXT.has(extname(name)) && !IGNORE_FILES.test(rel))
149
+ yield rel;
150
+ }
151
+ }
152
+ }
153
+ // ── Construction de l'index ─────────────────────────────────────────────────
154
+ export function buildIndex(root, onProgress) {
155
+ const files = new Map();
156
+ let n = 0;
157
+ for (const rel of walk(root, root)) {
158
+ try {
159
+ const src = readFileSync(join(root, rel), "utf8");
160
+ files.set(rel, extractFile(root, rel, src));
161
+ if (onProgress && ++n % 50 === 0)
162
+ onProgress(n);
163
+ }
164
+ catch { /* binaire/illisible: skip */ }
165
+ }
166
+ const imports = resolveImports(files);
167
+ const inDegree = new Map();
168
+ for (const targets of imports.values()) {
169
+ for (const t of targets)
170
+ inDegree.set(t, (inDegree.get(t) ?? 0) + 1);
171
+ }
172
+ const modules = [...files.entries()]
173
+ .map(([rel, f]) => {
174
+ const out = imports.get(rel) ?? [];
175
+ return {
176
+ id: rel,
177
+ exports: f.exports.slice(0, 30),
178
+ imports: out,
179
+ loc: f.loc,
180
+ degree: out.length + (inDegree.get(rel) ?? 0),
181
+ };
182
+ })
183
+ .sort((a, b) => a.id.localeCompare(b.id)); // tri stable → déterminisme
184
+ const god_nodes = [...modules]
185
+ .sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id))
186
+ .slice(0, 10)
187
+ .filter((m) => m.degree >= 5)
188
+ .map((m) => ({ id: m.id, degree: m.degree }));
189
+ const domainMap = new Map();
190
+ for (const m of modules) {
191
+ const top = m.id.includes("/") ? m.id.split("/")[0] : "(root)";
192
+ const d = domainMap.get(top) ?? { files: 0, loc: 0 };
193
+ d.files += 1;
194
+ d.loc += m.loc;
195
+ domainMap.set(top, d);
196
+ }
197
+ const domains = [...domainMap.entries()]
198
+ .map(([name, d]) => ({ name, ...d }))
199
+ .sort((a, b) => b.loc - a.loc);
200
+ const routes = [...files.values()].flatMap((f) => f.routes)
201
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
202
+ let commit = "unknown";
203
+ try {
204
+ commit = execSync("git rev-parse HEAD", { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
205
+ }
206
+ catch { /* pas un repo git */ }
207
+ return {
208
+ version: 1,
209
+ repo: repoFullName(root),
210
+ commit,
211
+ generated_at: new Date().toISOString(),
212
+ files_indexed: files.size,
213
+ routes,
214
+ modules,
215
+ god_nodes,
216
+ domains,
217
+ };
218
+ }
219
+ // ── Rapport d'audit (le "moment wow" du jour 0) ─────────────────────────────
220
+ export function renderReport(index) {
221
+ const lines = [];
222
+ lines.push(`# Kurtel — Architecture Report`);
223
+ lines.push(``);
224
+ lines.push(`Repo **${index.repo}** · ${index.files_indexed} files indexed · commit \`${index.commit.slice(0, 8)}\` · ${index.generated_at}`);
225
+ lines.push(``);
226
+ lines.push(`## Domains`);
227
+ for (const d of index.domains.slice(0, 12)) {
228
+ lines.push(`- **${d.name}** — ${d.files} files, ${d.loc.toLocaleString()} LOC`);
229
+ }
230
+ lines.push(``);
231
+ if (index.god_nodes.length) {
232
+ lines.push(`## God nodes (high-coupling hotspots)`);
233
+ lines.push(`These modules concentrate the most connections; changes here have the widest blast radius.`);
234
+ for (const g of index.god_nodes) {
235
+ lines.push(`- \`${g.id}\` — **${g.degree} edges**`);
236
+ }
237
+ lines.push(``);
238
+ }
239
+ lines.push(`## Route inventory (${index.routes.length})`);
240
+ lines.push(`The agent receives this inventory before creating any endpoint — duplicates get flagged.`);
241
+ const byFw = new Map();
242
+ for (const r of index.routes) {
243
+ const arr = byFw.get(r.framework) ?? [];
244
+ arr.push(r);
245
+ byFw.set(r.framework, arr);
246
+ }
247
+ for (const [fw, rs] of byFw) {
248
+ lines.push(``);
249
+ lines.push(`### ${fw}`);
250
+ for (const r of rs.slice(0, 80)) {
251
+ lines.push(`- \`${r.method.padEnd(6)} ${r.path}\` → ${r.file}:${r.line}`);
252
+ }
253
+ if (rs.length > 80)
254
+ lines.push(`- … and ${rs.length - 80} more`);
255
+ }
256
+ lines.push(``);
257
+ return lines.join("\n");
258
+ }
259
+ // ── Recherche de routes proches (anti-duplication) ──────────────────────────
260
+ /** Similarité grossière entre deux chemins de route (segments partagés, params normalisés). */
261
+ function routeSimilarity(a, b) {
262
+ const norm = (p) => p.replace(/:(\w+)|\{(\w+)\}|\[(\w+)\]/g, ":p").toLowerCase()
263
+ .split("/").filter(Boolean);
264
+ const sa = norm(a), sb = norm(b);
265
+ if (!sa.length || !sb.length)
266
+ return 0;
267
+ let shared = 0;
268
+ for (let i = 0; i < Math.min(sa.length, sb.length); i++)
269
+ if (sa[i] === sb[i])
270
+ shared++;
271
+ return (2 * shared) / (sa.length + sb.length);
272
+ }
273
+ export function findSimilarRoutes(index, path, threshold = 0.6) {
274
+ return index.routes
275
+ .map((r) => ({ r, s: routeSimilarity(r.path, path) }))
276
+ .filter((x) => x.s >= threshold)
277
+ .sort((a, b) => b.s - a.s)
278
+ .slice(0, 5)
279
+ .map((x) => x.r);
280
+ }
@@ -0,0 +1,125 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execSync } from "node:child_process";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Emplacements disque
7
+ // ~/.kurtel/cache/<repo-slug>/patterns.json ← mémoire darwinienne (pull Supabase)
8
+ // <repo>/.kurtel/index.json ← mémoire de codebase (déterministe)
9
+ // <repo>/.kurtel/REPORT.md ← rapport d'audit lisible
10
+ // <repo>/.kurtel/memory.json ← état local (on/off, session zones)
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ export function repoRoot(cwd = process.cwd()) {
13
+ try {
14
+ return execSync("git rev-parse --show-toplevel", { cwd, stdio: ["ignore", "pipe", "ignore"] })
15
+ .toString().trim();
16
+ }
17
+ catch {
18
+ return cwd;
19
+ }
20
+ }
21
+ export function repoSlug(root) {
22
+ // owner/name depuis le remote si possible, sinon basename — slugifié pour un nom de dossier.
23
+ let name = root.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
24
+ try {
25
+ const url = execSync("git remote get-url origin", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
26
+ .toString().trim();
27
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
28
+ if (m)
29
+ name = m[1];
30
+ }
31
+ catch { /* pas de remote */ }
32
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "__");
33
+ }
34
+ export function repoFullName(root) {
35
+ try {
36
+ const url = execSync("git remote get-url origin", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
37
+ .toString().trim();
38
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git?)?$/);
39
+ if (m)
40
+ return m[1].replace(/\.git$/, "");
41
+ }
42
+ catch { /* ignore */ }
43
+ return root.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
44
+ }
45
+ function cacheDir(root) {
46
+ return join(homedir(), ".kurtel", "cache", repoSlug(root));
47
+ }
48
+ function readJSON(file, fallback) {
49
+ try {
50
+ if (!existsSync(file))
51
+ return fallback;
52
+ return { ...fallback, ...JSON.parse(readFileSync(file, "utf8")) };
53
+ }
54
+ catch {
55
+ return fallback;
56
+ }
57
+ }
58
+ function writeJSON(file, data) {
59
+ const dir = join(file, "..");
60
+ if (!existsSync(dir))
61
+ mkdirSync(dir, { recursive: true });
62
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n", "utf8");
63
+ }
64
+ // ── Cache mémoire darwinienne (par user × repo, hors du repo: ne se versionne pas) ──
65
+ const EMPTY_CACHE = { patterns: [], patterns_synced_at: null, pending_telemetry: [] };
66
+ export function loadMemoryCache(root) {
67
+ return readJSON(join(cacheDir(root), "memory.json"), EMPTY_CACHE);
68
+ }
69
+ export function saveMemoryCache(root, cache) {
70
+ writeJSON(join(cacheDir(root), "memory.json"), cache);
71
+ }
72
+ export function queueTelemetry(root, ev) {
73
+ const cache = loadMemoryCache(root);
74
+ cache.pending_telemetry.push(ev);
75
+ // borne dure: jamais plus de 500 événements en attente (pas de croissance infinie)
76
+ if (cache.pending_telemetry.length > 500) {
77
+ cache.pending_telemetry = cache.pending_telemetry.slice(-500);
78
+ }
79
+ saveMemoryCache(root, cache);
80
+ }
81
+ // ── Index de codebase (dans le repo: partageable, déterministe) ──
82
+ export function indexPath(root) {
83
+ return join(root, ".kurtel", "index.json");
84
+ }
85
+ export function reportPath(root) {
86
+ return join(root, ".kurtel", "REPORT.md");
87
+ }
88
+ export function loadIndex(root) {
89
+ const file = indexPath(root);
90
+ if (!existsSync(file))
91
+ return null;
92
+ try {
93
+ return JSON.parse(readFileSync(file, "utf8"));
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function saveIndex(root, index) {
100
+ writeJSON(indexPath(root), index);
101
+ }
102
+ const EMPTY_STATE = { enabled: true, session_zones: {} };
103
+ function statePath(root) {
104
+ return join(cacheDir(root), "state.json");
105
+ }
106
+ export function loadMemoryState(root) {
107
+ return readJSON(statePath(root), EMPTY_STATE);
108
+ }
109
+ export function saveMemoryState(root, state) {
110
+ // garde-fou: ne garder que les 20 dernières sessions
111
+ const ids = Object.keys(state.session_zones);
112
+ if (ids.length > 20) {
113
+ for (const id of ids.slice(0, ids.length - 20))
114
+ delete state.session_zones[id];
115
+ }
116
+ writeJSON(statePath(root), state);
117
+ }
118
+ export function memoryEnabled(root) {
119
+ return loadMemoryState(root).enabled;
120
+ }
121
+ export function setMemoryEnabled(root, enabled) {
122
+ const s = loadMemoryState(root);
123
+ s.enabled = enabled;
124
+ saveMemoryState(root, s);
125
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+ import { loadMemoryCache, saveMemoryCache, repoFullName } from "./store.js";
3
+ import { pullPatterns, pushTelemetry, pushIndex } from "./api.js";
4
+ import { loadIndex } from "./store.js";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Sync — règle d'or: AUCUN appel réseau sur le chemin critique d'un prompt.
7
+ // Les hooks lisent le cache local; le sync tourne en arrière-plan (process
8
+ // détaché) au SessionStart et après chaque session.
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ /** Sync synchrone (utilisé par `kurtel memory sync` et le process détaché). */
11
+ export async function syncNow(root) {
12
+ const repo = repoFullName(root);
13
+ const cache = loadMemoryCache(root);
14
+ // 1. Pull delta des patterns darwiniens.
15
+ let pulled = 0;
16
+ try {
17
+ const res = await pullPatterns(repo, cache.patterns_synced_at);
18
+ if (res.delta) {
19
+ const byId = new Map(cache.patterns.map((p) => [p.id, p]));
20
+ for (const p of res.patterns)
21
+ byId.set(p.id, p);
22
+ cache.patterns = [...byId.values()].filter((p) => p.score > 0); // score 0 = mort, purgé
23
+ }
24
+ else {
25
+ cache.patterns = res.patterns;
26
+ }
27
+ cache.patterns_synced_at = res.synced_at;
28
+ pulled = res.patterns.length;
29
+ }
30
+ catch { /* offline / pas loggé: le cache continue de servir */ }
31
+ // 2. Flush de la télémétrie en attente.
32
+ let flushed = 0;
33
+ if (cache.pending_telemetry.length) {
34
+ try {
35
+ await pushTelemetry(repo, cache.pending_telemetry);
36
+ flushed = cache.pending_telemetry.length;
37
+ cache.pending_telemetry = [];
38
+ }
39
+ catch { /* on réessaiera au prochain sync */ }
40
+ }
41
+ saveMemoryCache(root, cache);
42
+ return { pulled, flushed };
43
+ }
44
+ /** Push de l'index de codebase vers le backend (pour l'onglet Memory in-app). */
45
+ export async function syncIndexUp(root) {
46
+ const index = loadIndex(root);
47
+ if (!index)
48
+ return false;
49
+ try {
50
+ await pushIndex(index.repo, index);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Lance un sync en arrière-plan, détaché — fire & forget, jamais bloquant. */
58
+ export function syncInBackground(root) {
59
+ try {
60
+ const child = spawn(process.execPath, [process.argv[1], "memory", "sync", "--quiet"], {
61
+ cwd: root,
62
+ stdio: "ignore",
63
+ detached: true,
64
+ env: process.env,
65
+ });
66
+ child.on("error", () => { });
67
+ child.unref();
68
+ }
69
+ catch { /* best effort */ }
70
+ }
package/dist/ui/banner.js CHANGED
@@ -2,10 +2,11 @@ import { c, symbols } from "./colors.js";
2
2
  import { box } from "./box.js";
3
3
  import { getVersion } from "../lib/version.js";
4
4
  const wordmark = [
5
- " _ __ _ _ ",
6
- " | |/ / _ _ _ | |_ ___| |",
7
- " | ' < | '_| || | _/ -_) |",
8
- " |_|\\_\\ |_| \\_,_|\\__\\___|_|",
5
+ " _ __ _ _ ",
6
+ "| |/ / _ _ __| |_ ___| |",
7
+ "| ' / | | | '__| __/ _ \\ |",
8
+ "| . \\ |_| | | | || __/ |",
9
+ "|_|\\_\\__,_|_| \\__\\___|_|",
9
10
  ];
10
11
  export function banner() {
11
12
  const art = wordmark.map((l) => c.indigoBold(l)).join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtel/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.12",
4
4
  "description": "Launch self-improving coding agents in the cloud — the Kurtel CLI.",
5
5
  "type": "module",
6
6
  "bin": {