composto-ai 0.4.1 → 0.6.1

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/index.js CHANGED
@@ -1,4 +1,73 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/memory/git.ts
13
+ var git_exports = {};
14
+ __export(git_exports, {
15
+ countCommits: () => countCommits,
16
+ isAncestor: () => isAncestor,
17
+ isShallowRepo: () => isShallowRepo,
18
+ logRange: () => logRange,
19
+ resolveSinceBoundary: () => resolveSinceBoundary,
20
+ revListCount: () => revListCount,
21
+ revParseHead: () => revParseHead
22
+ });
23
+ import { execSync as execSync2 } from "child_process";
24
+ function run(cwd, cmd, timeoutMs = 1e4) {
25
+ return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
26
+ }
27
+ function revParseHead(cwd) {
28
+ return run(cwd, "git rev-parse HEAD");
29
+ }
30
+ function isShallowRepo(cwd) {
31
+ return run(cwd, "git rev-parse --is-shallow-repository") === "true";
32
+ }
33
+ function revListCount(cwd, from, to) {
34
+ if (from === to) return 0;
35
+ const out = run(cwd, `git rev-list --count ${from}..${to}`);
36
+ return parseInt(out, 10);
37
+ }
38
+ function isAncestor(cwd, ancestor, descendant) {
39
+ try {
40
+ execSync2(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
41
+ cwd,
42
+ stdio: "ignore"
43
+ });
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+ function countCommits(cwd) {
50
+ const out = run(cwd, "git rev-list --count HEAD");
51
+ return parseInt(out, 10);
52
+ }
53
+ function resolveSinceBoundary(cwd, since) {
54
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(since)) {
55
+ throw new Error(`--since must be YYYY-MM-DD (got "${since}")`);
56
+ }
57
+ const out = run(cwd, `git rev-list -n 1 --before='${since}T23:59:59' HEAD`);
58
+ return out || null;
59
+ }
60
+ function logRange(cwd, from, to, timeoutMs = 6e4) {
61
+ const range = from ? `${from}..${to}` : to;
62
+ const fmt = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s%x00%b%x1f";
63
+ const cmd = `git log ${fmt} --numstat --no-renames ${range}`;
64
+ return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });
65
+ }
66
+ var init_git = __esm({
67
+ "src/memory/git.ts"() {
68
+ "use strict";
69
+ }
70
+ });
2
71
 
3
72
  // src/cli/commands.ts
4
73
  import { readFileSync as readFileSync4 } from "fs";
@@ -1717,7 +1786,25 @@ function openDatabase(path) {
1717
1786
  }
1718
1787
 
1719
1788
  // src/memory/schema.ts
1720
- var CURRENT_VERSION = 1;
1789
+ var CURRENT_VERSION = 3;
1790
+ var V2_SQL = `
1791
+ CREATE INDEX IF NOT EXISTS idx_ft_file_commit ON file_touches(file_path, commit_sha);
1792
+ `;
1793
+ var V3_SQL = `
1794
+ CREATE TABLE IF NOT EXISTS hook_invocations (
1795
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1796
+ timestamp INTEGER NOT NULL,
1797
+ platform TEXT NOT NULL,
1798
+ event TEXT NOT NULL,
1799
+ file_path TEXT,
1800
+ verdict TEXT,
1801
+ score REAL,
1802
+ confidence REAL,
1803
+ latency_ms INTEGER NOT NULL,
1804
+ cache_hit INTEGER NOT NULL
1805
+ );
1806
+ CREATE INDEX IF NOT EXISTS idx_hi_timestamp ON hook_invocations(timestamp);
1807
+ `;
1721
1808
  var V1_SQL = `
1722
1809
  CREATE TABLE IF NOT EXISTS index_state (
1723
1810
  key TEXT PRIMARY KEY,
@@ -1805,7 +1892,9 @@ function runMigrations(db) {
1805
1892
  if (current >= CURRENT_VERSION) return;
1806
1893
  db.exec("BEGIN");
1807
1894
  try {
1808
- db.exec(V1_SQL);
1895
+ if (current < 1) db.exec(V1_SQL);
1896
+ if (current < 2) db.exec(V2_SQL);
1897
+ if (current < 3) db.exec(V3_SQL);
1809
1898
  db.pragma(`user_version = ${CURRENT_VERSION}`);
1810
1899
  db.exec("COMMIT");
1811
1900
  } catch (err) {
@@ -1814,39 +1903,8 @@ function runMigrations(db) {
1814
1903
  }
1815
1904
  }
1816
1905
 
1817
- // src/memory/git.ts
1818
- import { execSync as execSync2 } from "child_process";
1819
- function run(cwd, cmd, timeoutMs = 1e4) {
1820
- return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
1821
- }
1822
- function revParseHead(cwd) {
1823
- return run(cwd, "git rev-parse HEAD");
1824
- }
1825
- function isShallowRepo(cwd) {
1826
- return run(cwd, "git rev-parse --is-shallow-repository") === "true";
1827
- }
1828
- function revListCount(cwd, from, to) {
1829
- if (from === to) return 0;
1830
- const out = run(cwd, `git rev-list --count ${from}..${to}`);
1831
- return parseInt(out, 10);
1832
- }
1833
- function isAncestor(cwd, ancestor, descendant) {
1834
- try {
1835
- execSync2(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
1836
- cwd,
1837
- stdio: "ignore"
1838
- });
1839
- return true;
1840
- } catch {
1841
- return false;
1842
- }
1843
- }
1844
- function countCommits(cwd) {
1845
- const out = run(cwd, "git rev-list --count HEAD");
1846
- return parseInt(out, 10);
1847
- }
1848
-
1849
1906
  // src/memory/freshness.ts
1907
+ init_git();
1850
1908
  function ensureFresh(db, repoPath) {
1851
1909
  const head = revParseHead(repoPath);
1852
1910
  const row = db.prepare("SELECT value FROM index_state WHERE key = 'last_indexed_sha'").get();
@@ -1947,13 +2005,19 @@ function computeRevertMatch(db, filePath) {
1947
2005
  };
1948
2006
  }
1949
2007
 
2008
+ // src/memory/signals/db-clock.ts
2009
+ function getDbMaxTimestamp(db) {
2010
+ const row = db.prepare("SELECT MAX(timestamp) AS ts FROM commits").get();
2011
+ return row?.ts ?? null;
2012
+ }
2013
+
1950
2014
  // src/memory/signals/hotspot.ts
1951
2015
  var WINDOW_SECONDS = 90 * 86400;
1952
2016
  var SATURATION_TOUCHES = 30;
1953
2017
  var FALLBACK_PRECISION2 = 0.3;
1954
2018
  function computeHotspot(db, filePath) {
1955
- const now = Math.floor(Date.now() / 1e3);
1956
- const lowerBound = now - WINDOW_SECONDS;
2019
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
2020
+ const lowerBound = anchor - WINDOW_SECONDS;
1957
2021
  const row = db.prepare(`
1958
2022
  SELECT COUNT(*) AS n
1959
2023
  FROM file_touches ft
@@ -2011,38 +2075,12 @@ function computeFixRatio(db, filePath) {
2011
2075
  };
2012
2076
  }
2013
2077
 
2014
- // src/memory/signals/coverage-decline.ts
2015
- var FALLBACK_PRECISION4 = 0.3;
2016
- function computeCoverageDecline(db, repoPath, filePath) {
2017
- const cal = getCalibration(db, "coverage_decline", FALLBACK_PRECISION4);
2018
- let strength = 0;
2019
- try {
2020
- const entries = getGitLog(repoPath, 200);
2021
- const trends = {
2022
- hotspots: detectHotspots(entries, { threshold: 10, fixRatioThreshold: 0.5 }),
2023
- decaySignals: detectDecay(entries),
2024
- inconsistencies: detectInconsistencies(entries)
2025
- };
2026
- const health = computeHealthFromTrends(filePath, trends);
2027
- if (health.coverageTrend === "down") strength = 1;
2028
- } catch {
2029
- strength = 0;
2030
- }
2031
- return {
2032
- type: "coverage_decline",
2033
- strength,
2034
- precision: cal.precision,
2035
- sample_size: cal.sampleSize,
2036
- evidence: []
2037
- };
2038
- }
2039
-
2040
2078
  // src/memory/signals/author-churn.ts
2041
2079
  var WINDOW_SECONDS2 = 90 * 86400;
2042
2080
  var INACTIVE_THRESHOLD = 5;
2043
- var FALLBACK_PRECISION5 = 0.3;
2081
+ var FALLBACK_PRECISION4 = 0.3;
2044
2082
  function computeAuthorChurn(db, filePath) {
2045
- const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION5);
2083
+ const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION4);
2046
2084
  const base = {
2047
2085
  type: "author_churn",
2048
2086
  precision: cal.precision,
@@ -2058,8 +2096,8 @@ function computeAuthorChurn(db, filePath) {
2058
2096
  LIMIT 1
2059
2097
  `).get(filePath);
2060
2098
  if (!lastTouch) return { ...base, strength: 0 };
2061
- const now = Math.floor(Date.now() / 1e3);
2062
- const lowerBound = now - WINDOW_SECONDS2;
2099
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
2100
+ const lowerBound = anchor - WINDOW_SECONDS2;
2063
2101
  const activity = db.prepare(`SELECT COUNT(*) AS n FROM commits WHERE author = ? AND timestamp >= ?`).get(lastTouch.author, lowerBound);
2064
2102
  let strength = 0;
2065
2103
  if (activity.n === 0) strength = 1;
@@ -2068,12 +2106,11 @@ function computeAuthorChurn(db, filePath) {
2068
2106
  }
2069
2107
 
2070
2108
  // src/memory/signals/index.ts
2071
- function collectSignals(db, repoPath, filePath) {
2109
+ function collectSignals(db, _repoPath, filePath) {
2072
2110
  return [
2073
2111
  computeRevertMatch(db, filePath),
2074
2112
  computeHotspot(db, filePath),
2075
2113
  computeFixRatio(db, filePath),
2076
- computeCoverageDecline(db, repoPath, filePath),
2077
2114
  computeAuthorChurn(db, filePath)
2078
2115
  ];
2079
2116
  }
@@ -2236,6 +2273,9 @@ var WorkerPool = class {
2236
2273
  }
2237
2274
  };
2238
2275
 
2276
+ // src/memory/api.ts
2277
+ init_git();
2278
+
2239
2279
  // src/memory/detectors.ts
2240
2280
  function detectSquashed(db) {
2241
2281
  const row = db.prepare(`
@@ -2404,6 +2444,24 @@ var MemoryAPI = class {
2404
2444
  });
2405
2445
  return this.bootstrapPromise;
2406
2446
  }
2447
+ // bootstrapFromBoundary indexes only commits between fromSha and HEAD.
2448
+ // Used by `composto index --since=YYYY-MM-DD` to bound work on huge repos.
2449
+ // Pass fromSha=null to index the full history (same as bootstrapIfNeeded).
2450
+ async bootstrapFromBoundary(fromSha) {
2451
+ if (this.bootstrapPromise) return this.bootstrapPromise;
2452
+ const head = revParseHead(this.repoPath);
2453
+ const range = { from: fromSha, to: head };
2454
+ this.bootstrapPromise = this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range }).then(() => {
2455
+ this.log.info("bootstrap_done", { through: range.to, from: range.from });
2456
+ }).catch((err) => {
2457
+ this.log.error("bootstrap_failed", { message: err.message });
2458
+ this.failures.recordFailure("ingest_failure");
2459
+ throw err;
2460
+ }).finally(() => {
2461
+ this.bootstrapPromise = null;
2462
+ });
2463
+ return this.bootstrapPromise;
2464
+ }
2407
2465
  async blastradius(input) {
2408
2466
  const start = Date.now();
2409
2467
  if (this.failures.isDisabled()) {
@@ -2567,7 +2625,7 @@ function statFileSize(path) {
2567
2625
  function runScan(projectPath) {
2568
2626
  const adapter = new CLIAdapter();
2569
2627
  const config = loadConfig(projectPath);
2570
- console.log("composto v0.4.1 \u2014 scanning...\n");
2628
+ console.log("composto v0.4.2 \u2014 scanning...\n");
2571
2629
  const files = collectFiles(projectPath, [".ts", ".tsx", ".js", ".jsx"]);
2572
2630
  console.log(` Found ${files.length} files
2573
2631
  `);
@@ -2594,7 +2652,7 @@ function runScan(projectPath) {
2594
2652
  function runTrends(projectPath) {
2595
2653
  const adapter = new CLIAdapter();
2596
2654
  const config = loadConfig(projectPath);
2597
- console.log("composto v0.4.1 \u2014 trend analysis...\n");
2655
+ console.log("composto v0.4.2 \u2014 trend analysis...\n");
2598
2656
  const entries = getGitLog(projectPath, 100);
2599
2657
  if (entries.length === 0) {
2600
2658
  console.log(" No git history found.\n");
@@ -2636,7 +2694,7 @@ async function runIR(projectPath, filePath, layer) {
2636
2694
  }
2637
2695
  var ALL_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs"];
2638
2696
  async function runBenchmark(projectPath) {
2639
- console.log("composto v0.4.1 \u2014 benchmark\n");
2697
+ console.log("composto v0.4.2 \u2014 benchmark\n");
2640
2698
  const files = collectFiles(projectPath, ALL_EXTENSIONS);
2641
2699
  console.log(` ${files.length} files
2642
2700
  `);
@@ -2683,7 +2741,7 @@ async function runBenchmarkQuality(projectPath, filePath) {
2683
2741
  }
2684
2742
  const code = readFileSync4(filePath, "utf-8");
2685
2743
  const relPath = relative2(projectPath, filePath);
2686
- console.log("composto v0.4.1 \u2014 quality benchmark\n");
2744
+ console.log("composto v0.4.2 \u2014 quality benchmark\n");
2687
2745
  console.log(` File: ${relPath}
2688
2746
  `);
2689
2747
  console.log(" Sending to Claude Haiku...\n");
@@ -2713,8 +2771,8 @@ ${result.ir.response}
2713
2771
  }
2714
2772
  }
2715
2773
  async function runContext(projectPath, budget, target) {
2716
- const header = target ? `composto v0.4.1 \u2014 context (target: ${target}, budget: ${budget} tokens)
2717
- ` : `composto v0.4.1 \u2014 context (budget: ${budget} tokens)
2774
+ const header = target ? `composto v0.4.2 \u2014 context (target: ${target}, budget: ${budget} tokens)
2775
+ ` : `composto v0.4.2 \u2014 context (budget: ${budget} tokens)
2718
2776
  `;
2719
2777
  console.log(header);
2720
2778
  const files = collectFiles(projectPath, ALL_EXTENSIONS);
@@ -2812,18 +2870,60 @@ async function runImpact(projectPath, file, opts = {}) {
2812
2870
  await api.close();
2813
2871
  }
2814
2872
  }
2815
- async function runIndex(projectPath) {
2873
+ async function runIndex(projectPath, options = {}) {
2874
+ const { resolveSinceBoundary: resolveSinceBoundary2 } = await Promise.resolve().then(() => (init_git(), git_exports));
2816
2875
  const dbPath = join6(projectPath, ".composto", "memory.db");
2817
2876
  const api = new MemoryAPI({ dbPath, repoPath: projectPath });
2877
+ const Database2 = (await import("better-sqlite3")).default;
2878
+ const probeDb = new Database2(dbPath, { readonly: true, fileMustExist: false });
2879
+ const start = Date.now();
2880
+ let stopProgress = () => {
2881
+ };
2818
2882
  try {
2819
- console.log("composto: bootstrapping memory index...");
2820
- const start = Date.now();
2821
- await api.bootstrapIfNeeded();
2822
- console.log(`composto: index ready (${Date.now() - start} ms)`);
2883
+ if (options.since) {
2884
+ const fromSha = resolveSinceBoundary2(projectPath, options.since);
2885
+ console.log(`composto: indexing commits since ${options.since}${fromSha ? ` (boundary ${fromSha.slice(0, 8)})` : " (whole history \u2014 date predates first commit)"}...`);
2886
+ stopProgress = startProgressPoller(probeDb, start);
2887
+ await api.bootstrapFromBoundary(fromSha);
2888
+ } else {
2889
+ console.log("composto: bootstrapping memory index...");
2890
+ stopProgress = startProgressPoller(probeDb, start);
2891
+ await api.bootstrapIfNeeded();
2892
+ }
2893
+ stopProgress();
2894
+ const total = readIndexedTotal(probeDb);
2895
+ const elapsed = Date.now() - start;
2896
+ const rate = total > 0 && elapsed > 0 ? Math.round(total * 1e3 / elapsed) : 0;
2897
+ console.log(`composto: index ready \u2014 ${total.toLocaleString()} commits in ${(elapsed / 1e3).toFixed(1)}s (${rate.toLocaleString()} commits/sec)`);
2823
2898
  } finally {
2899
+ stopProgress();
2900
+ probeDb.close();
2824
2901
  await api.close();
2825
2902
  }
2826
2903
  }
2904
+ function readIndexedTotal(db) {
2905
+ try {
2906
+ const row = db.prepare("SELECT value FROM index_state WHERE key = 'indexed_commits_total'").get();
2907
+ return row ? parseInt(row.value, 10) : 0;
2908
+ } catch {
2909
+ return 0;
2910
+ }
2911
+ }
2912
+ function startProgressPoller(db, start) {
2913
+ let last = -1;
2914
+ const interval = setInterval(() => {
2915
+ const total = readIndexedTotal(db);
2916
+ if (total === last) return;
2917
+ last = total;
2918
+ const elapsed = Date.now() - start;
2919
+ const rate = total > 0 && elapsed > 0 ? Math.round(total * 1e3 / elapsed) : 0;
2920
+ process.stdout.write(` indexed ${total.toLocaleString()} commits (${rate.toLocaleString()}/sec) [${(elapsed / 1e3).toFixed(1)}s]\r`);
2921
+ }, 1500);
2922
+ return () => {
2923
+ clearInterval(interval);
2924
+ process.stdout.write("\n");
2925
+ };
2926
+ }
2827
2927
  async function runIndexStatus(projectPath) {
2828
2928
  const dbPath = join6(projectPath, ".composto", "memory.db");
2829
2929
  const s = collectStatus(dbPath);
@@ -2852,8 +2952,646 @@ async function runIndexStatus(projectPath) {
2852
2952
  console.log(` Integrity check: ${s.integrityOk ? "OK" : "FAIL"}`);
2853
2953
  }
2854
2954
 
2955
+ // src/cli/init.ts
2956
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
2957
+ import { homedir } from "os";
2958
+ import { dirname as dirname5, join as join7 } from "path";
2959
+ var CURSOR_RULES_MDC = `---
2960
+ description: Composto MCP usage \u2014 tells the agent when to prefer composto_blastradius / composto_context / composto_ir / composto_scan over built-in file tools.
2961
+ alwaysApply: true
2962
+ ---
2963
+
2964
+ # Composto MCP \u2014 when to use
2965
+
2966
+ This project has the \`composto\` MCP server registered. Prefer Composto tools over Cursor's built-in tools in the situations below.
2967
+
2968
+ ## Before editing any existing file under \`src/\`, \`lib/\`, or \`app/\`
2969
+
2970
+ Call \`composto_blastradius\` with the target file path **first**, before proposing any edit.
2971
+
2972
+ - If \`verdict\` is \`high\`: surface the risk to the user before changing the file. Mention the strongest signal (e.g. "this file was reverted the last time it was touched", "high fix-ratio in recent history").
2973
+ - If \`verdict\` is \`medium\`: note it briefly and proceed.
2974
+ - If \`verdict\` is \`low\` or \`unknown\`: proceed without comment.
2975
+
2976
+ Skip this on test files, generated code, lockfiles, and anything in \`node_modules/\` or \`dist/\`.
2977
+
2978
+ ## When the user says "fix this bug in X", "why does X return wrong value", or "trace X"
2979
+
2980
+ Call \`composto_context\` with \`target: "<symbol-or-file>"\` and \`budget: 4000\` instead of opening 5 files individually. The target file comes back as raw code; surrounding files come back as compressed IR. One round-trip, full picture.
2981
+
2982
+ ## When you only need to know what a file does (not its exact code)
2983
+
2984
+ Call \`composto_ir\` with \`layer: "L1"\` instead of \`read_file\`. ~89% fewer tokens, same understanding. Use \`read_file\` only when you need exact strings, regexes, or formatting.
2985
+
2986
+ ## Before staging commits / when reviewing diffs
2987
+
2988
+ Call \`composto_scan\` on the changed paths to catch hardcoded secrets, debug artifacts, and stray \`console.log\` calls before the user commits.
2989
+
2990
+ ## Don't
2991
+
2992
+ - Don't call \`composto_benchmark\` unless the user explicitly asks about token savings.
2993
+ - Don't run \`composto_blastradius\` on every read \u2014 only before edits.
2994
+ - Don't compress a file the user explicitly asked to see in full.
2995
+ `;
2996
+ function ensureDir(filePath) {
2997
+ mkdirSync4(dirname5(filePath), { recursive: true });
2998
+ }
2999
+ function writeJsonMerged(filePath, patch, result, relPath) {
3000
+ ensureDir(filePath);
3001
+ if (existsSync4(filePath)) {
3002
+ const existing = JSON.parse(readFileSync5(filePath, "utf-8"));
3003
+ const mergedServers = {
3004
+ ...existing.mcpServers ?? {},
3005
+ ...patch.mcpServers ?? {}
3006
+ };
3007
+ const merged = { ...existing, ...patch, mcpServers: mergedServers };
3008
+ writeFileSync2(filePath, JSON.stringify(merged, null, 2) + "\n");
3009
+ result.merged.push(relPath);
3010
+ } else {
3011
+ writeFileSync2(filePath, JSON.stringify(patch, null, 2) + "\n");
3012
+ result.written.push(relPath);
3013
+ }
3014
+ }
3015
+ function writeFileSkipIfExists(filePath, content, result, relPath) {
3016
+ ensureDir(filePath);
3017
+ if (existsSync4(filePath)) {
3018
+ result.skipped.push(relPath);
3019
+ return;
3020
+ }
3021
+ writeFileSync2(filePath, content);
3022
+ result.written.push(relPath);
3023
+ }
3024
+ function mergeHookArray(existingHooks, newEntry, dedupKey) {
3025
+ const arr = Array.isArray(existingHooks) ? [...existingHooks] : [];
3026
+ const key = dedupKey(newEntry);
3027
+ if (arr.some((e) => dedupKey(e) === key)) return arr;
3028
+ arr.push(newEntry);
3029
+ return arr;
3030
+ }
3031
+ function readJsonIfExists(filePath) {
3032
+ if (!existsSync4(filePath)) return {};
3033
+ try {
3034
+ const parsed = JSON.parse(readFileSync5(filePath, "utf-8"));
3035
+ return parsed && typeof parsed === "object" ? parsed : {};
3036
+ } catch {
3037
+ return {};
3038
+ }
3039
+ }
3040
+ function writeCursorHooks(projectPath, result) {
3041
+ const hooksPath = join7(projectPath, ".cursor", "hooks.json");
3042
+ const relPath = ".cursor/hooks.json";
3043
+ const existed = existsSync4(hooksPath);
3044
+ const existing = readJsonIfExists(hooksPath);
3045
+ const existingHooks = existing.hooks ?? {};
3046
+ const compostoEntry = {
3047
+ matcher: "Edit|Write",
3048
+ command: "composto hook cursor pretooluse"
3049
+ };
3050
+ const preToolUse = mergeHookArray(
3051
+ existingHooks.preToolUse,
3052
+ compostoEntry,
3053
+ (e) => e?.command ?? ""
3054
+ );
3055
+ const merged = {
3056
+ ...existing,
3057
+ version: existing.version ?? 1,
3058
+ hooks: { ...existingHooks, preToolUse }
3059
+ };
3060
+ ensureDir(hooksPath);
3061
+ writeFileSync2(hooksPath, JSON.stringify(merged, null, 2) + "\n");
3062
+ if (existed) result.merged.push(relPath);
3063
+ else result.written.push(relPath);
3064
+ }
3065
+ function initCursor(projectPath, result) {
3066
+ writeJsonMerged(
3067
+ join7(projectPath, ".cursor", "mcp.json"),
3068
+ {
3069
+ mcpServers: {
3070
+ composto: {
3071
+ command: "composto-mcp",
3072
+ env: { COMPOSTO_BLASTRADIUS: "1" }
3073
+ }
3074
+ }
3075
+ },
3076
+ result,
3077
+ ".cursor/mcp.json"
3078
+ );
3079
+ writeFileSkipIfExists(
3080
+ join7(projectPath, ".cursor", "rules", "composto.mdc"),
3081
+ CURSOR_RULES_MDC,
3082
+ result,
3083
+ ".cursor/rules/composto.mdc"
3084
+ );
3085
+ writeCursorHooks(projectPath, result);
3086
+ }
3087
+ function initClaudeCode(projectPath, result) {
3088
+ const settingsPath = join7(projectPath, ".claude", "settings.json");
3089
+ const relPath = ".claude/settings.json";
3090
+ const existed = existsSync4(settingsPath);
3091
+ const existing = readJsonIfExists(settingsPath);
3092
+ const mcpServers = {
3093
+ ...existing.mcpServers ?? {},
3094
+ composto: {
3095
+ command: "composto-mcp",
3096
+ env: { COMPOSTO_BLASTRADIUS: "1" }
3097
+ }
3098
+ };
3099
+ const compostoHookEntry = {
3100
+ matcher: "Edit|Write|MultiEdit",
3101
+ hooks: [
3102
+ { type: "command", command: "composto hook claude-code pretooluse" }
3103
+ ]
3104
+ };
3105
+ const existingHooks = existing.hooks ?? {};
3106
+ const preToolUse = mergeHookArray(
3107
+ existingHooks.PreToolUse,
3108
+ compostoHookEntry,
3109
+ (e) => e?.hooks?.[0]?.command ?? ""
3110
+ );
3111
+ const merged = {
3112
+ ...existing,
3113
+ mcpServers,
3114
+ hooks: { ...existingHooks, PreToolUse: preToolUse }
3115
+ };
3116
+ ensureDir(settingsPath);
3117
+ writeFileSync2(settingsPath, JSON.stringify(merged, null, 2) + "\n");
3118
+ if (existed) result.merged.push(relPath);
3119
+ else result.written.push(relPath);
3120
+ }
3121
+ function initGeminiCli(_projectPath, result, options) {
3122
+ const settingsPath = options.geminiSettingsPath ?? join7(homedir(), ".gemini", "settings.json");
3123
+ const relPath = settingsPath;
3124
+ try {
3125
+ const existed = existsSync4(settingsPath);
3126
+ const existing = readJsonIfExists(settingsPath);
3127
+ const mcpServers = {
3128
+ ...existing.mcpServers ?? {},
3129
+ composto: {
3130
+ command: "composto-mcp",
3131
+ env: { COMPOSTO_BLASTRADIUS: "1" }
3132
+ }
3133
+ };
3134
+ const compostoHookEntry = {
3135
+ matcher: "edit_file|write_file|replace",
3136
+ hooks: [
3137
+ { type: "command", command: "composto hook gemini-cli beforetool" }
3138
+ ]
3139
+ };
3140
+ const existingHooks = existing.hooks ?? {};
3141
+ const beforeTool = mergeHookArray(
3142
+ existingHooks.BeforeTool,
3143
+ compostoHookEntry,
3144
+ (e) => e?.hooks?.[0]?.command ?? ""
3145
+ );
3146
+ const merged = {
3147
+ ...existing,
3148
+ mcpServers,
3149
+ hooks: { ...existingHooks, BeforeTool: beforeTool }
3150
+ };
3151
+ ensureDir(settingsPath);
3152
+ writeFileSync2(settingsPath, JSON.stringify(merged, null, 2) + "\n");
3153
+ if (existed) result.merged.push(relPath);
3154
+ else result.written.push(relPath);
3155
+ } catch (err) {
3156
+ const reason = err instanceof Error ? err.message : String(err);
3157
+ result.skipped.push(`${settingsPath} (write failed: ${reason})`);
3158
+ }
3159
+ }
3160
+ function runInit(projectPath, options) {
3161
+ const client = options.client ?? "cursor";
3162
+ const result = { client, written: [], skipped: [], merged: [] };
3163
+ if (client === "claude-code") initClaudeCode(projectPath, result);
3164
+ else if (client === "gemini-cli") initGeminiCli(projectPath, result, options);
3165
+ else initCursor(projectPath, result);
3166
+ return result;
3167
+ }
3168
+
3169
+ // src/cli/hook/api-deps.ts
3170
+ var defaultDeps = {
3171
+ makeApi(opts) {
3172
+ return new MemoryAPI(opts);
3173
+ }
3174
+ };
3175
+
3176
+ // src/cli/hook/extract.ts
3177
+ var FILE_TOOLS = {
3178
+ Edit: ["file_path"],
3179
+ Write: ["file_path"],
3180
+ MultiEdit: ["file_path"],
3181
+ edit_file: ["path", "file_path"],
3182
+ write_file: ["path", "file_path"],
3183
+ replace: ["path", "file_path"]
3184
+ };
3185
+ function extractFilePath(inv) {
3186
+ const name = inv.tool_name;
3187
+ if (typeof name !== "string") return null;
3188
+ const candidates = FILE_TOOLS[name];
3189
+ if (!candidates) return null;
3190
+ const input = inv.tool_input;
3191
+ if (!input || typeof input !== "object") return null;
3192
+ for (const field of candidates) {
3193
+ const v = input[field];
3194
+ if (typeof v === "string" && v.length > 0) return v;
3195
+ }
3196
+ return null;
3197
+ }
3198
+
3199
+ // src/cli/hook/format.ts
3200
+ function formatBlastRadiusContext(filePath, res, opts = {}) {
3201
+ const firing = res.signals.filter((s) => s.strength > 0).map((s) => `${s.type}=${s.strength.toFixed(2)}`).join(", ");
3202
+ const hint = opts.hint ?? "this file's bug history may be relevant to your edit. See composto_blastradius for detail.";
3203
+ return [
3204
+ `<composto_blastradius>`,
3205
+ ` file: ${filePath}`,
3206
+ ` verdict: ${res.verdict}`,
3207
+ ` score: ${res.score.toFixed(2)} confidence: ${res.confidence.toFixed(2)}`,
3208
+ firing ? ` firing_signals: ${firing}` : ` firing_signals: (none)`,
3209
+ ` hint: ${hint}`,
3210
+ `</composto_blastradius>`
3211
+ ].join("\n");
3212
+ }
3213
+
3214
+ // src/cli/hook/adapters/claude-code.ts
3215
+ import { join as join8 } from "path";
3216
+ var EMPTY_META = {
3217
+ filePath: null,
3218
+ verdict: null,
3219
+ score: null,
3220
+ confidence: null
3221
+ };
3222
+ function passthrough(filePath = null) {
3223
+ return {
3224
+ envelope: { hookSpecificOutput: {} },
3225
+ metadata: { ...EMPTY_META, filePath }
3226
+ };
3227
+ }
3228
+ async function runClaudeCodeHook(opts, deps = defaultDeps) {
3229
+ let payload;
3230
+ try {
3231
+ payload = JSON.parse(opts.stdin);
3232
+ } catch {
3233
+ return passthrough();
3234
+ }
3235
+ if (typeof payload !== "object" || payload === null) return passthrough();
3236
+ const filePath = extractFilePath(payload);
3237
+ if (!filePath) return passthrough();
3238
+ try {
3239
+ const dbPath = join8(opts.cwd, ".composto", "memory.db");
3240
+ const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
3241
+ try {
3242
+ const res = await api.blastradius({ file: filePath });
3243
+ if (!res || res.verdict === "low") {
3244
+ return {
3245
+ envelope: { hookSpecificOutput: {} },
3246
+ metadata: {
3247
+ filePath,
3248
+ verdict: res ? res.verdict : null,
3249
+ score: res ? res.score : null,
3250
+ confidence: res ? res.confidence : null
3251
+ }
3252
+ };
3253
+ }
3254
+ const body = formatBlastRadiusContext(filePath, res);
3255
+ return {
3256
+ envelope: {
3257
+ hookSpecificOutput: {
3258
+ hookEventName: "PreToolUse",
3259
+ additionalContext: body
3260
+ }
3261
+ },
3262
+ metadata: {
3263
+ filePath,
3264
+ verdict: res.verdict,
3265
+ score: res.score,
3266
+ confidence: res.confidence
3267
+ }
3268
+ };
3269
+ } finally {
3270
+ await api.close();
3271
+ }
3272
+ } catch {
3273
+ return passthrough(filePath);
3274
+ }
3275
+ }
3276
+
3277
+ // src/cli/hook/adapters/cursor.ts
3278
+ import { join as join9 } from "path";
3279
+ var CURSOR_HINT = "this file's bug history suggests high risk \u2014 ask the user to confirm before editing.";
3280
+ var EMPTY_META2 = {
3281
+ filePath: null,
3282
+ verdict: null,
3283
+ score: null,
3284
+ confidence: null
3285
+ };
3286
+ function passthrough2(filePath = null) {
3287
+ return { envelope: {}, metadata: { ...EMPTY_META2, filePath } };
3288
+ }
3289
+ async function runCursorHook(opts, deps = defaultDeps) {
3290
+ let payload;
3291
+ try {
3292
+ payload = JSON.parse(opts.stdin);
3293
+ } catch {
3294
+ return passthrough2();
3295
+ }
3296
+ if (typeof payload !== "object" || payload === null) return passthrough2();
3297
+ const filePath = extractFilePath(payload);
3298
+ if (!filePath) return passthrough2();
3299
+ try {
3300
+ const dbPath = join9(opts.cwd, ".composto", "memory.db");
3301
+ const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
3302
+ try {
3303
+ const res = await api.blastradius({ file: filePath });
3304
+ if (!res || res.verdict !== "high") {
3305
+ return {
3306
+ envelope: {},
3307
+ metadata: {
3308
+ filePath,
3309
+ verdict: res ? res.verdict : null,
3310
+ score: res ? res.score : null,
3311
+ confidence: res ? res.confidence : null
3312
+ }
3313
+ };
3314
+ }
3315
+ return {
3316
+ envelope: {
3317
+ permissionDecision: "deny",
3318
+ permissionDecisionReason: formatBlastRadiusContext(filePath, res, { hint: CURSOR_HINT })
3319
+ },
3320
+ metadata: {
3321
+ filePath,
3322
+ verdict: res.verdict,
3323
+ score: res.score,
3324
+ confidence: res.confidence
3325
+ }
3326
+ };
3327
+ } finally {
3328
+ await api.close();
3329
+ }
3330
+ } catch {
3331
+ return passthrough2(filePath);
3332
+ }
3333
+ }
3334
+
3335
+ // src/cli/hook/adapters/gemini-cli.ts
3336
+ import { join as join10 } from "path";
3337
+ var EMPTY_META3 = {
3338
+ filePath: null,
3339
+ verdict: null,
3340
+ score: null,
3341
+ confidence: null
3342
+ };
3343
+ function passthrough3(filePath = null) {
3344
+ return {
3345
+ envelope: { hookSpecificOutput: {} },
3346
+ metadata: { ...EMPTY_META3, filePath }
3347
+ };
3348
+ }
3349
+ async function runGeminiCliHook(opts, deps = defaultDeps) {
3350
+ let payload;
3351
+ try {
3352
+ payload = JSON.parse(opts.stdin);
3353
+ } catch {
3354
+ return passthrough3();
3355
+ }
3356
+ if (typeof payload !== "object" || payload === null) return passthrough3();
3357
+ const filePath = extractFilePath(payload);
3358
+ if (!filePath) return passthrough3();
3359
+ try {
3360
+ const dbPath = join10(opts.cwd, ".composto", "memory.db");
3361
+ const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
3362
+ try {
3363
+ const res = await api.blastradius({ file: filePath });
3364
+ if (!res || res.verdict === "low") {
3365
+ return {
3366
+ envelope: { hookSpecificOutput: {} },
3367
+ metadata: {
3368
+ filePath,
3369
+ verdict: res ? res.verdict : null,
3370
+ score: res ? res.score : null,
3371
+ confidence: res ? res.confidence : null
3372
+ }
3373
+ };
3374
+ }
3375
+ const body = formatBlastRadiusContext(filePath, res);
3376
+ return {
3377
+ envelope: {
3378
+ hookSpecificOutput: {
3379
+ hookEventName: "BeforeTool",
3380
+ additionalContext: body
3381
+ }
3382
+ },
3383
+ metadata: {
3384
+ filePath,
3385
+ verdict: res.verdict,
3386
+ score: res.score,
3387
+ confidence: res.confidence
3388
+ }
3389
+ };
3390
+ } finally {
3391
+ await api.close();
3392
+ }
3393
+ } catch {
3394
+ return passthrough3(filePath);
3395
+ }
3396
+ }
3397
+
3398
+ // src/cli/hook/dispatcher.ts
3399
+ async function runHookDispatch(opts, deps = defaultDeps) {
3400
+ const p = opts.platform;
3401
+ const e = opts.event;
3402
+ const hookOpts = { stdin: opts.stdin, cwd: opts.cwd };
3403
+ switch (p) {
3404
+ case "claude-code":
3405
+ if (e === "pretooluse") return runClaudeCodeHook(hookOpts, deps);
3406
+ throw new Error(`unknown event for claude-code: ${e}`);
3407
+ case "cursor":
3408
+ if (e === "pretooluse") return runCursorHook(hookOpts, deps);
3409
+ throw new Error(`unknown event for cursor: ${e}`);
3410
+ case "gemini-cli":
3411
+ if (e === "beforetool") return runGeminiCliHook(hookOpts, deps);
3412
+ throw new Error(`unknown event for gemini-cli: ${e}`);
3413
+ default: {
3414
+ const _exhaustive = p;
3415
+ throw new Error(`unknown platform: ${_exhaustive}`);
3416
+ }
3417
+ }
3418
+ }
3419
+
3420
+ // src/cli/stats.ts
3421
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
3422
+ import { join as join12 } from "path";
3423
+
3424
+ // src/memory/telemetry/hook-invocations.ts
3425
+ import { existsSync as existsSync5 } from "fs";
3426
+ import { dirname as dirname6, join as join11 } from "path";
3427
+ var SEVEN_DAYS_SEC = 7 * 24 * 60 * 60;
3428
+ var DISABLE_MARKER_NAME = "telemetry-disabled";
3429
+ function dbDirectory(db) {
3430
+ const name = db.name;
3431
+ if (typeof name !== "string" || name.length === 0 || name === ":memory:") {
3432
+ return null;
3433
+ }
3434
+ return dirname6(name);
3435
+ }
3436
+ function isTelemetryDisabled(db) {
3437
+ const dir = dbDirectory(db);
3438
+ if (!dir) return false;
3439
+ try {
3440
+ return existsSync5(join11(dir, DISABLE_MARKER_NAME));
3441
+ } catch {
3442
+ return false;
3443
+ }
3444
+ }
3445
+ function recordInvocation(db, record) {
3446
+ try {
3447
+ if (isTelemetryDisabled(db)) return;
3448
+ const stmt = db.prepare(
3449
+ `INSERT INTO hook_invocations
3450
+ (timestamp, platform, event, file_path, verdict, score, confidence, latency_ms, cache_hit)
3451
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
3452
+ );
3453
+ stmt.run(
3454
+ record.timestamp,
3455
+ record.platform,
3456
+ record.event,
3457
+ record.filePath,
3458
+ record.verdict,
3459
+ record.score,
3460
+ record.confidence,
3461
+ record.latencyMs,
3462
+ record.cacheHit ? 1 : 0
3463
+ );
3464
+ } catch {
3465
+ }
3466
+ }
3467
+ function percentile(sortedAsc, p) {
3468
+ if (sortedAsc.length === 0) return 0;
3469
+ if (sortedAsc.length === 1) return sortedAsc[0];
3470
+ const rank = Math.min(
3471
+ sortedAsc.length - 1,
3472
+ Math.max(0, Math.ceil(p / 100 * sortedAsc.length) - 1)
3473
+ );
3474
+ return sortedAsc[rank];
3475
+ }
3476
+ function recentSummary(db, opts = {}) {
3477
+ const now = opts.now ?? Math.floor(Date.now() / 1e3);
3478
+ const since = opts.since ?? now - SEVEN_DAYS_SEC;
3479
+ const empty = {
3480
+ windowStart: since,
3481
+ windowEnd: now,
3482
+ total: 0,
3483
+ byVerdict: {},
3484
+ byPlatform: {},
3485
+ latencyP50: 0,
3486
+ latencyP95: 0,
3487
+ cacheHitRate: 0
3488
+ };
3489
+ let rows;
3490
+ try {
3491
+ rows = db.prepare(
3492
+ `SELECT platform, verdict, latency_ms, cache_hit
3493
+ FROM hook_invocations
3494
+ WHERE timestamp >= ? AND timestamp <= ?`
3495
+ ).all(since, now);
3496
+ } catch {
3497
+ return empty;
3498
+ }
3499
+ if (rows.length === 0) return empty;
3500
+ const byVerdict = {};
3501
+ const byPlatform = {};
3502
+ const latencies = [];
3503
+ let cacheHits = 0;
3504
+ for (const r of rows) {
3505
+ const v = r.verdict == null || r.verdict === "" ? "passthrough" : r.verdict;
3506
+ byVerdict[v] = (byVerdict[v] ?? 0) + 1;
3507
+ byPlatform[r.platform] = (byPlatform[r.platform] ?? 0) + 1;
3508
+ latencies.push(r.latency_ms);
3509
+ if (r.cache_hit === 1) cacheHits++;
3510
+ }
3511
+ latencies.sort((a, b) => a - b);
3512
+ return {
3513
+ windowStart: since,
3514
+ windowEnd: now,
3515
+ total: rows.length,
3516
+ byVerdict,
3517
+ byPlatform,
3518
+ latencyP50: percentile(latencies, 50),
3519
+ latencyP95: percentile(latencies, 95),
3520
+ cacheHitRate: rows.length > 0 ? cacheHits / rows.length : 0
3521
+ };
3522
+ }
3523
+
3524
+ // src/cli/stats.ts
3525
+ var DISABLE_NOTICE = "Composto telemetry disabled. Delete .composto/telemetry-disabled to re-enable.";
3526
+ function runStats(opts) {
3527
+ const composstoDir = join12(opts.cwd, ".composto");
3528
+ if (opts.disable) {
3529
+ mkdirSync5(composstoDir, { recursive: true });
3530
+ writeFileSync3(join12(composstoDir, "telemetry-disabled"), "");
3531
+ return { action: "disabled", output: DISABLE_NOTICE };
3532
+ }
3533
+ const dbPath = join12(composstoDir, "memory.db");
3534
+ if (!existsSync6(dbPath)) {
3535
+ const msg = "No .composto/memory.db yet \u2014 run `composto index` or trigger a hook first.";
3536
+ if (opts.json) {
3537
+ return {
3538
+ action: "printed",
3539
+ output: JSON.stringify({ total: 0, note: msg }, null, 2)
3540
+ };
3541
+ }
3542
+ return { action: "printed", output: msg };
3543
+ }
3544
+ const db = openDatabase(dbPath);
3545
+ try {
3546
+ runMigrations(db);
3547
+ const summary = recentSummary(db);
3548
+ return {
3549
+ action: "printed",
3550
+ output: opts.json ? JSON.stringify(summary, null, 2) : renderSummary(summary)
3551
+ };
3552
+ } finally {
3553
+ db.close();
3554
+ }
3555
+ }
3556
+ function pct(n, total) {
3557
+ if (total === 0) return "0%";
3558
+ return `${Math.round(n / total * 100)}%`;
3559
+ }
3560
+ function renderSummary(s) {
3561
+ const lines = [];
3562
+ lines.push(`hook invocations (last 7d): ${s.total}`);
3563
+ if (s.total === 0) {
3564
+ lines.push(" no hook firings recorded yet.");
3565
+ return lines.join("\n");
3566
+ }
3567
+ const verdictOrder = ["low", "medium", "high", "unknown", "passthrough"];
3568
+ const verdictKeys = [
3569
+ ...verdictOrder.filter((k) => k in s.byVerdict),
3570
+ ...Object.keys(s.byVerdict).filter((k) => !verdictOrder.includes(k))
3571
+ ];
3572
+ const verdictParts = verdictKeys.map(
3573
+ (k) => `${k} ${pct(s.byVerdict[k], s.total)}`
3574
+ );
3575
+ lines.push(` by verdict: ${verdictParts.join(" / ")}`);
3576
+ const platformParts = Object.entries(s.byPlatform).map(([k, v]) => `${k} ${v}`);
3577
+ lines.push(` by platform: ${platformParts.join(", ")}`);
3578
+ lines.push(` latency: p50 ${s.latencyP50}ms, p95 ${s.latencyP95}ms`);
3579
+ lines.push(
3580
+ ` cache: hit rate ${Math.round(s.cacheHitRate * 100)}% (cache feature deferred \u2014 see Phase 1 plan)`
3581
+ );
3582
+ return lines.join("\n");
3583
+ }
3584
+
2855
3585
  // src/index.ts
2856
- import { resolve as resolve2 } from "path";
3586
+ import { join as join13, resolve as resolve2 } from "path";
3587
+ async function readStdin() {
3588
+ if (process.stdin.isTTY) return "";
3589
+ const chunks = [];
3590
+ for await (const chunk of process.stdin) {
3591
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
3592
+ }
3593
+ return Buffer.concat(chunks).toString("utf-8");
3594
+ }
2857
3595
  var args = process.argv.slice(2);
2858
3596
  var command = args[0];
2859
3597
  switch (command) {
@@ -2925,15 +3663,85 @@ switch (command) {
2925
3663
  if (args.includes("--status")) {
2926
3664
  await runIndexStatus(resolve2("."));
2927
3665
  } else {
2928
- await runIndex(resolve2("."));
3666
+ const sinceArg = args.find((a) => a.startsWith("--since="))?.slice("--since=".length);
3667
+ await runIndex(resolve2("."), { since: sinceArg });
2929
3668
  }
2930
3669
  break;
2931
3670
  }
3671
+ case "init": {
3672
+ const valid = ["cursor", "claude-code", "gemini-cli"];
3673
+ const clientArg = args.find((a) => a.startsWith("--client="))?.slice("--client=".length);
3674
+ if (clientArg && !valid.includes(clientArg)) {
3675
+ console.error(`Unknown --client=${clientArg}. Valid: ${valid.join(", ")}`);
3676
+ process.exit(1);
3677
+ }
3678
+ const result = runInit(resolve2("."), { client: clientArg });
3679
+ console.log(`composto init \u2014 configured for ${result.client}
3680
+ `);
3681
+ for (const f of result.written) console.log(` wrote ${f}`);
3682
+ for (const f of result.merged) console.log(` merged ${f}`);
3683
+ for (const f of result.skipped) console.log(` skipped ${f} (already exists)`);
3684
+ console.log("\nRestart your AI client and check that 'composto' MCP is green.");
3685
+ console.log(
3686
+ "Composto collects local-only hook telemetry to help you monitor agent behavior. Disable with `composto stats --disable` at any time."
3687
+ );
3688
+ break;
3689
+ }
3690
+ case "hook": {
3691
+ const hookStart = Date.now();
3692
+ try {
3693
+ const platform = args[1];
3694
+ const event = args[2];
3695
+ if (!platform || !event) {
3696
+ console.log('{"hookSpecificOutput":{}}');
3697
+ break;
3698
+ }
3699
+ const stdin = await readStdin();
3700
+ const result = await runHookDispatch({
3701
+ platform,
3702
+ event,
3703
+ stdin,
3704
+ cwd: process.cwd()
3705
+ });
3706
+ console.log(JSON.stringify(result.envelope));
3707
+ try {
3708
+ const dbPath = join13(process.cwd(), ".composto", "memory.db");
3709
+ const db = openDatabase(dbPath);
3710
+ try {
3711
+ runMigrations(db);
3712
+ recordInvocation(db, {
3713
+ timestamp: Math.floor(Date.now() / 1e3),
3714
+ platform,
3715
+ event,
3716
+ filePath: result.metadata.filePath,
3717
+ verdict: result.metadata.verdict,
3718
+ score: result.metadata.score,
3719
+ confidence: result.metadata.confidence,
3720
+ latencyMs: Date.now() - hookStart,
3721
+ cacheHit: false
3722
+ });
3723
+ } finally {
3724
+ db.close();
3725
+ }
3726
+ } catch {
3727
+ }
3728
+ } catch {
3729
+ console.log('{"hookSpecificOutput":{}}');
3730
+ }
3731
+ break;
3732
+ }
3733
+ case "stats": {
3734
+ const json = args.includes("--json");
3735
+ const disable = args.includes("--disable");
3736
+ const res = runStats({ cwd: resolve2("."), json, disable });
3737
+ console.log(res.output);
3738
+ break;
3739
+ }
2932
3740
  case "version":
2933
- console.log("composto v0.4.1");
3741
+ console.log("composto v0.4.2");
2934
3742
  break;
2935
3743
  default:
2936
- console.log("composto v0.4.1 \u2014 less tokens, more insight\n");
3744
+ console.log("composto v0.4.2 \u2014 less tokens, more insight\n");
2937
3745
  console.log("Commands:");
2938
3746
  console.log(" scan [path] Scan codebase for issues");
2939
3747
  console.log(" trends [path] Analyze codebase health trends");
@@ -2943,8 +3751,12 @@ switch (command) {
2943
3751
  console.log(" context [path] --budget N Smart context within token budget");
2944
3752
  console.log(" context [path] --target <symbol> Target file as raw, surrounding as IR");
2945
3753
  console.log(" impact <file> Show historical blast radius for a file");
2946
- console.log(" index Build or refresh the memory index");
3754
+ console.log(" index [--since=YYYY-MM-DD] Build or refresh the memory index (--since bounds work for huge repos)");
2947
3755
  console.log(" index --status Show memory index diagnostics");
3756
+ console.log(" init [--client=<name>] Configure Composto MCP + hooks for an AI client");
3757
+ console.log(" (clients: cursor, claude-code, gemini-cli)");
3758
+ console.log(" hook <platform> <event> Run BlastRadius hook (reads tool JSON from stdin)");
3759
+ console.log(" stats [--json] [--disable] Show hook telemetry (last 7d); --disable opts out");
2948
3760
  console.log(" version Show version");
2949
3761
  break;
2950
3762
  }