composto-ai 0.7.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,25 @@
1
1
  # Composto
2
2
 
3
- **Causal memory layer for coding agents. Catches the bug your agent is about to reintroduce.**
3
+ **Token-efficient code context for AI agents. Your file's full structure in a fraction of the tokens, with its causal history baked in.**
4
4
 
5
- Composto is a repo-local graph of your git history that your AI coding agent consults before every edit. When a file was reverted recently, has a fix cluster in its history, or was last touched by someone who left the team, Composto surfaces that signal as in-context guidance before the agent writes the code. Hook-enforced on Claude Code, Cursor, and Gemini CLI. Local-first, MIT.
5
+ > Send your agent the structure, not the noise.
6
+
7
+ Composto compresses any source file into a Health-Aware IR that keeps exactly what your agent needs — signatures, types, control flow, dependencies — at 60-95% fewer tokens than raw code. On top of that, it surfaces the file's causal history (what historically changed and broke alongside the code you're touching) as advisory context. Local-first, MIT. Works with Claude Code, Cursor, and Gemini CLI.
6
8
 
7
9
  ```
8
- $ composto impact src/memory/signals/hotspot.ts
9
-
10
- verdict: medium
11
- score: 0.52
12
- confidence: 0.50
13
- signals:
14
- revert_match ■■■■■■■■■■ strength=1.00 precision=1.00
15
- hotspot ■ strength=0.10 precision=0.54
16
- fix_ratio ■ strength=0.07 precision=0.54
17
- author_churn · strength=0.00 precision=0.16
18
-
19
- # This file was touched by a Revert commit in history.
20
- # blastradius remembers. Your LLM couldn't.
10
+ $ composto ir src/memory/confidence.ts L1
11
+
12
+ USE:./types.js
13
+ OUT INTERFACE:ConfidenceContext
14
+ OUT INTERFACE:ScoreAndConfidence
15
+ FN:calibrationFactor(signals: Signal[])
16
+ GUARD:[firing.length === 0 → 1.0, avg < 20 → 0.3, avg < 100 → 0.6]
17
+ FN:historyFactor(totalCommits: number)
18
+ GUARD:[totalCommits < 50 → 0.2, totalCommits < 200 → 0.5, totalCommits < 1000 → 0.8]
19
+ OUT FN:computeScoreAndConfidence(signals: Signal[], ctx: ConfidenceContext)
20
+
21
+ # 541 tokens of raw code 230 tokens of IR (57% fewer). Structure intact:
22
+ # every signature, dependency, and decision threshold survives.
21
23
  ```
22
24
 
23
25
  ---
@@ -45,9 +47,9 @@ composto impact src/auth/login.ts
45
47
  composto index --status # diagnostics: schema, freshness, calibration
46
48
  ```
47
49
 
48
- ### Also in the box: AST compression tools
50
+ ### The core: token-efficient structural context
49
51
 
50
- Composto also ships a tree-sitter based AST compressor (about 89% token savings) and a smart context packer for bug-fix tasks. These are separate from the causal layer but live in the same binary.
52
+ Composto's spine is a tree-sitter based AST compressor and a smart context packer. Compress any file to IR, or pack a whole directory into a token budget:
51
53
 
52
54
  ```bash
53
55
  composto ir src/app.ts # compress a file to IR (L0/L1/L2/L3)
@@ -57,6 +59,10 @@ composto benchmark . # see compression stats
57
59
 
58
60
  See the [IR Layers](#ir-layers), [Health-Aware IR](#health-aware-ir), and [Context Budget](#context-budget) sections below for details.
59
61
 
62
+ ### On top: causal history as advisory context
63
+
64
+ Composto also indexes your git history and surfaces what historically changed and broke alongside the file you're editing — advisory context the agent weighs, not a gate. See [Causal context](#causal-context) below.
65
+
60
66
  ### MCP plugin (Claude Code, Cursor, Claude Desktop)
61
67
 
62
68
  The MCP server is bundled inside `composto-ai`. Install the package globally first, then register the server with your client:
@@ -188,27 +194,25 @@ composto index --status # diagnostics: schema, freshness, calibration
188
194
 
189
195
  ---
190
196
 
191
- ## BlastRadius
197
+ ## Causal context
192
198
 
193
- Beyond compression, Composto indexes your repo's git history into a local SQLite graph and exposes it as a queryable risk surface. Before your agent edits a file, it can ask: *"has this region been reverted? who fixed the last similar bug? is the last author still around?"* — signals no LLM can infer from current code alone.
199
+ On top of compression, Composto indexes your repo's git history into a local SQLite graph and surfaces what the current code can't tell you: *"has this region been reverted? does it have a fix cluster? what files historically changed and broke alongside it?"* — context no LLM can infer from the file alone. It's delivered as **advisory context the agent weighs**, not a hard gate.
194
200
 
195
- Four signals per query: `revert_match`, `hotspot`, `fix_ratio`, `author_churn`. Verdict is `low` / `medium` / `high` / `unknown`; when confidence is low the tool returns `unknown` rather than guessing. Precision is repo-calibrated (self-validation over the repo's own fix history).
201
+ Signals per query: `revert_match`, `hotspot`, `fix_ratio`, `author_churn`, `cochange`. The tool returns `unknown` when confidence is low rather than guessing.
196
202
 
197
203
  ```
198
- verdict: high
199
- score: 1.00
200
- confidence: 0.30
201
- signals:
202
- revert_match ■■■■■■■■■■ strength=1.00 precision=0.50
203
- hotspot · strength=0.00 precision=0.30
204
- ...
204
+ $ composto impact src/auth/login.ts
205
+
206
+ revert_match ■■■■■■■■■■ this file was touched by a Revert commit
207
+ cochange ■■■■■ historically co-changed with session.ts, token.ts in fixes
208
+ hotspot ■ 14 changes in the last 90 days
205
209
  ```
206
210
 
207
- **Where we are, honestly.** The v2.1 time-travel backtest (rewinds the DB to each pre-fix snapshot) shows `revert_match` carrying most of the product's value it clears the ship gate on picomatch (precision 0.65, recall 0.78 on the `medium|high` band). Signal-attributed precision (excluding `revert_match`) is weaker: the three non-revert signals are alive but need calibration work. See [docs/blastradius-proof-v2.md](docs/blastradius-proof-v2.md) for numbers on all four band combinations across two public repos + the per-signal diagnostic behind them.
211
+ **Where we are, honestly.** A 4-repo time-travel backtest (fastify, express, got, flask each rewound to pre-fix snapshots) shows the causal layer is a **high-recall, advisory-grade** signal: on mature repos it recovers 67-80% of the files a fix actually touches. Precision is modest (~0.55) these signals point you at *candidates*, they don't certify them, which is exactly why Composto surfaces them as context for the agent to judge rather than as a blocking verdict. Recall scales with git history, so the value grows as your repo matures (a young repo gets little until it accumulates fix history).
208
212
 
209
- The honest framing: **BlastRadius is a bug-history memory layer that agents query before editing.** "This file was reverted three weeks ago" is the primary promise v1 delivers. The other signals expand the query surface calibration work on `hotspot` and `fix_ratio` is the open follow-on.
213
+ The honest framing: **causal context is a high-recall memory layer agents consult before editing** "these files have a history of breaking together" not a precision gate. The compression core works unconditionally; the causal layer adds repo-specific memory on top.
210
214
 
211
- Feature-flagged via `COMPOSTO_BLASTRADIUS=1` during the beta. Available as both CLI (`composto impact`, `composto index`) and MCP tool (`composto_blastradius`).
215
+ Available as CLI (`composto impact`, `composto index`) and MCP tool (`composto_blastradius`, gated by `COMPOSTO_BLASTRADIUS=1` during beta).
212
216
 
213
217
  ---
214
218
 
@@ -307,14 +311,14 @@ Hotspot files get full detail. Everything else gets structure. Budget is never e
307
311
  ## Stats
308
312
 
309
313
  ```
310
- Overall compression: 89.2%
311
- L0 compression: 97.5%
312
- AST engine: 51/51 files (0 regex fallback)
314
+ L1 compression: ~81% fewer tokens (full IR, structure preserved)
315
+ L0 compression: ~97% fewer tokens (structure map)
316
+ Token counts: verified against a real BPE tokenizer, not estimates
317
+ AST engine: AST-parsed, 0 regex fallback
313
318
  Languages: TypeScript, JavaScript, Python, Go, Rust
314
- Tests: 251 passing
315
- BlastRadius v2.1: precision 0.65, recall 0.78 (picomatch, time-travel,
316
- medium|high band). Honest signal-attributed numbers
317
- in docs/blastradius-proof-v2.md.
319
+ Causal layer: high-recall advisory (0.67-0.80 recall on mature repos,
320
+ time-travel backtest across 4 public repos); precision
321
+ ~0.55, surfaced as context not a gate.
318
322
  ```
319
323
 
320
324
  ---
package/dist/index.js CHANGED
@@ -1010,6 +1010,17 @@ function emitTier3(node) {
1010
1010
  if (expr.type === "await_expression") {
1011
1011
  return null;
1012
1012
  }
1013
+ if (expr.type === "assignment_expression") {
1014
+ if (node.parent?.type === "statement_block") return null;
1015
+ const left = expr.childForFieldName("left");
1016
+ const right = expr.childForFieldName("right");
1017
+ if (left?.type === "member_expression" && right && (right.type === "function_expression" || right.type === "arrow_function")) {
1018
+ const asyncPrefix = isAsync(right) ? "ASYNC " : "";
1019
+ const params = right.childForFieldName("parameters")?.text ?? "()";
1020
+ return `${asyncPrefix}FN:${left.text}${collapseText(params, 60)}`;
1021
+ }
1022
+ return null;
1023
+ }
1013
1024
  if (expr.type === "call_expression") {
1014
1025
  const callee = expr.child(0)?.text ?? "";
1015
1026
  if (callee === "ObjectSetPrototypeOf" || callee === "Object.setPrototypeOf") {
@@ -2125,13 +2136,38 @@ function computeAuthorChurn(db, filePath) {
2125
2136
  return { ...base, strength };
2126
2137
  }
2127
2138
 
2139
+ // src/memory/signals/cochange.ts
2140
+ var SATURATION_DEGREE = 10;
2141
+ var FALLBACK_PRECISION5 = 0.3;
2142
+ function computeCochange(db, filePath) {
2143
+ const row = db.prepare(`
2144
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
2145
+ FROM file_touches ft1
2146
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
2147
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
2148
+ WHERE ft1.file_path = ?
2149
+ `).get(filePath);
2150
+ const degree = row?.degree ?? 0;
2151
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
2152
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
2153
+ return {
2154
+ type: "cochange",
2155
+ strength,
2156
+ precision: cal.precision,
2157
+ sample_size: cal.sampleSize,
2158
+ evidence: [],
2159
+ cochange_degree: degree
2160
+ };
2161
+ }
2162
+
2128
2163
  // src/memory/signals/index.ts
2129
2164
  function collectSignals(db, _repoPath, filePath) {
2130
2165
  return [
2131
2166
  computeRevertMatch(db, filePath),
2132
2167
  computeHotspot(db, filePath),
2133
2168
  computeFixRatio(db, filePath),
2134
- computeAuthorChurn(db, filePath)
2169
+ computeAuthorChurn(db, filePath),
2170
+ computeCochange(db, filePath)
2135
2171
  ];
2136
2172
  }
2137
2173
 
@@ -2170,18 +2206,29 @@ function historyFactor(totalCommits) {
2170
2206
  if (totalCommits < 1e3) return 0.8;
2171
2207
  return 1;
2172
2208
  }
2209
+ var DEFAULT_COCHANGE_FLOOR = 1;
2210
+ function cochangeFloor() {
2211
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
2212
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
2213
+ }
2173
2214
  function computeScoreAndConfidence(signals, ctx) {
2215
+ const cochange = signals.find((s) => s.type === "cochange");
2216
+ const averaged = signals.filter((s) => s.type !== "cochange");
2174
2217
  let num = 0;
2175
2218
  let den = 0;
2176
- for (const s of signals) {
2219
+ for (const s of averaged) {
2177
2220
  if (s.strength <= 0 || s.precision <= 0) continue;
2178
2221
  num += s.strength * s.precision;
2179
2222
  den += s.precision;
2180
2223
  }
2181
- const score = den === 0 ? 0 : num / den;
2224
+ let score = den === 0 ? 0 : num / den;
2225
+ if (cochange) {
2226
+ const floor = cochangeFloor();
2227
+ score *= floor + (1 - floor) * cochange.strength;
2228
+ }
2182
2229
  const confidence = Math.min(
2183
- coverageFactor(signals),
2184
- calibrationFactor(signals),
2230
+ coverageFactor(averaged),
2231
+ calibrationFactor(averaged),
2185
2232
  freshnessFactor(ctx),
2186
2233
  historyFactor(ctx.totalCommits)
2187
2234
  );
@@ -2752,6 +2799,25 @@ async function runBenchmark(projectPath) {
2752
2799
  console.log(` L1 (full IR): ${summary.totalRaw} \u2192 ${summary.totalIRL1} tokens (${summary.totalSavedPercent.toFixed(1)}% reduction)`);
2753
2800
  console.log(` Files analyzed: ${summary.fileCount}`);
2754
2801
  console.log(` Engine: ${summary.astCount} AST, ${summary.fpCount} FP`);
2802
+ printSavingsCard(summary);
2803
+ }
2804
+ function printSavingsCard(summary) {
2805
+ if (summary.totalRaw <= 0) return;
2806
+ const saved = summary.totalRaw - summary.totalIRL1;
2807
+ const pct2 = summary.totalSavedPercent.toFixed(1);
2808
+ const SONNET_PER_MTOK = 3;
2809
+ const LOADS_PER_DAY = 50;
2810
+ const DAYS = 30;
2811
+ const rawCost = summary.totalRaw / 1e6 * SONNET_PER_MTOK;
2812
+ const irCost = summary.totalIRL1 / 1e6 * SONNET_PER_MTOK;
2813
+ const monthly = (rawCost - irCost) * LOADS_PER_DAY * DAYS;
2814
+ console.log("\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2815
+ console.log(` \u{1F4B8} Every full-context load of this project: $${rawCost.toFixed(2)} raw \u2192 $${irCost.toFixed(2)} with Composto`);
2816
+ console.log(` (Claude Sonnet input, $3/Mtok). At ${LOADS_PER_DAY} loads/day that is ~$${monthly.toFixed(0)}/month saved.`);
2817
+ console.log("\n \u{1F4CB} Share your result:");
2818
+ console.log(` Composto compressed my ${summary.fileCount}-file project ${pct2}% (${summary.totalRaw.toLocaleString()} \u2192 ${summary.totalIRL1.toLocaleString()} tokens).`);
2819
+ console.log(" Try yours: npm i -g composto-ai && composto benchmark .");
2820
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2755
2821
  }
2756
2822
  async function runBenchmarkQuality(projectPath, filePath) {
2757
2823
  const apiKey = process.env.ANTHROPIC_API_KEY;
@@ -818,6 +818,17 @@ function emitTier3(node) {
818
818
  if (expr.type === "await_expression") {
819
819
  return null;
820
820
  }
821
+ if (expr.type === "assignment_expression") {
822
+ if (node.parent?.type === "statement_block") return null;
823
+ const left = expr.childForFieldName("left");
824
+ const right = expr.childForFieldName("right");
825
+ if (left?.type === "member_expression" && right && (right.type === "function_expression" || right.type === "arrow_function")) {
826
+ const asyncPrefix = isAsync(right) ? "ASYNC " : "";
827
+ const params = right.childForFieldName("parameters")?.text ?? "()";
828
+ return `${asyncPrefix}FN:${left.text}${collapseText(params, 60)}`;
829
+ }
830
+ return null;
831
+ }
821
832
  if (expr.type === "call_expression") {
822
833
  const callee = expr.child(0)?.text ?? "";
823
834
  if (callee === "ObjectSetPrototypeOf" || callee === "Object.setPrototypeOf") {
@@ -1929,13 +1940,38 @@ function computeAuthorChurn(db, filePath) {
1929
1940
  return { ...base, strength };
1930
1941
  }
1931
1942
 
1943
+ // src/memory/signals/cochange.ts
1944
+ var SATURATION_DEGREE = 10;
1945
+ var FALLBACK_PRECISION5 = 0.3;
1946
+ function computeCochange(db, filePath) {
1947
+ const row = db.prepare(`
1948
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
1949
+ FROM file_touches ft1
1950
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
1951
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
1952
+ WHERE ft1.file_path = ?
1953
+ `).get(filePath);
1954
+ const degree = row?.degree ?? 0;
1955
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
1956
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
1957
+ return {
1958
+ type: "cochange",
1959
+ strength,
1960
+ precision: cal.precision,
1961
+ sample_size: cal.sampleSize,
1962
+ evidence: [],
1963
+ cochange_degree: degree
1964
+ };
1965
+ }
1966
+
1932
1967
  // src/memory/signals/index.ts
1933
1968
  function collectSignals(db, _repoPath, filePath) {
1934
1969
  return [
1935
1970
  computeRevertMatch(db, filePath),
1936
1971
  computeHotspot(db, filePath),
1937
1972
  computeFixRatio(db, filePath),
1938
- computeAuthorChurn(db, filePath)
1973
+ computeAuthorChurn(db, filePath),
1974
+ computeCochange(db, filePath)
1939
1975
  ];
1940
1976
  }
1941
1977
 
@@ -1974,18 +2010,29 @@ function historyFactor(totalCommits) {
1974
2010
  if (totalCommits < 1e3) return 0.8;
1975
2011
  return 1;
1976
2012
  }
2013
+ var DEFAULT_COCHANGE_FLOOR = 1;
2014
+ function cochangeFloor() {
2015
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
2016
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
2017
+ }
1977
2018
  function computeScoreAndConfidence(signals, ctx) {
2019
+ const cochange = signals.find((s) => s.type === "cochange");
2020
+ const averaged = signals.filter((s) => s.type !== "cochange");
1978
2021
  let num = 0;
1979
2022
  let den = 0;
1980
- for (const s of signals) {
2023
+ for (const s of averaged) {
1981
2024
  if (s.strength <= 0 || s.precision <= 0) continue;
1982
2025
  num += s.strength * s.precision;
1983
2026
  den += s.precision;
1984
2027
  }
1985
- const score = den === 0 ? 0 : num / den;
2028
+ let score = den === 0 ? 0 : num / den;
2029
+ if (cochange) {
2030
+ const floor = cochangeFloor();
2031
+ score *= floor + (1 - floor) * cochange.strength;
2032
+ }
1986
2033
  const confidence = Math.min(
1987
- coverageFactor(signals),
1988
- calibrationFactor(signals),
2034
+ coverageFactor(averaged),
2035
+ calibrationFactor(averaged),
1989
2036
  freshnessFactor(ctx),
1990
2037
  historyFactor(ctx.totalCommits)
1991
2038
  );
@@ -364,13 +364,38 @@ function computeAuthorChurn(db, filePath) {
364
364
  return { ...base, strength };
365
365
  }
366
366
 
367
+ // src/memory/signals/cochange.ts
368
+ var SATURATION_DEGREE = 10;
369
+ var FALLBACK_PRECISION5 = 0.3;
370
+ function computeCochange(db, filePath) {
371
+ const row = db.prepare(`
372
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
373
+ FROM file_touches ft1
374
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
375
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
376
+ WHERE ft1.file_path = ?
377
+ `).get(filePath);
378
+ const degree = row?.degree ?? 0;
379
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
380
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
381
+ return {
382
+ type: "cochange",
383
+ strength,
384
+ precision: cal.precision,
385
+ sample_size: cal.sampleSize,
386
+ evidence: [],
387
+ cochange_degree: degree
388
+ };
389
+ }
390
+
367
391
  // src/memory/signals/index.ts
368
392
  function collectSignals(db, _repoPath, filePath) {
369
393
  return [
370
394
  computeRevertMatch(db, filePath),
371
395
  computeHotspot(db, filePath),
372
396
  computeFixRatio(db, filePath),
373
- computeAuthorChurn(db, filePath)
397
+ computeAuthorChurn(db, filePath),
398
+ computeCochange(db, filePath)
374
399
  ];
375
400
  }
376
401
 
@@ -409,18 +434,29 @@ function historyFactor(totalCommits) {
409
434
  if (totalCommits < 1e3) return 0.8;
410
435
  return 1;
411
436
  }
437
+ var DEFAULT_COCHANGE_FLOOR = 1;
438
+ function cochangeFloor() {
439
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
440
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
441
+ }
412
442
  function computeScoreAndConfidence(signals, ctx) {
443
+ const cochange = signals.find((s) => s.type === "cochange");
444
+ const averaged = signals.filter((s) => s.type !== "cochange");
413
445
  let num = 0;
414
446
  let den = 0;
415
- for (const s of signals) {
447
+ for (const s of averaged) {
416
448
  if (s.strength <= 0 || s.precision <= 0) continue;
417
449
  num += s.strength * s.precision;
418
450
  den += s.precision;
419
451
  }
420
- const score = den === 0 ? 0 : num / den;
452
+ let score = den === 0 ? 0 : num / den;
453
+ if (cochange) {
454
+ const floor = cochangeFloor();
455
+ score *= floor + (1 - floor) * cochange.strength;
456
+ }
421
457
  const confidence = Math.min(
422
- coverageFactor(signals),
423
- calibrationFactor(signals),
458
+ coverageFactor(averaged),
459
+ calibrationFactor(averaged),
424
460
  freshnessFactor(ctx),
425
461
  historyFactor(ctx.totalCommits)
426
462
  );
@@ -283,11 +283,31 @@ function validateAuthorChurn(db) {
283
283
  const hits = db.prepare(`SELECT COUNT(*) AS n FROM fix_links`).get().n;
284
284
  return { total, hits };
285
285
  }
286
+ function validateCochange(db) {
287
+ const total = db.prepare(`
288
+ SELECT COUNT(DISTINCT ft1.file_path) AS n
289
+ FROM file_touches ft1
290
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
291
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
292
+ `).get().n;
293
+ const hits = db.prepare(`
294
+ SELECT COUNT(DISTINCT ft1.file_path) AS n
295
+ FROM file_touches ft1
296
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
297
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
298
+ WHERE ft1.file_path IN (
299
+ SELECT ft.file_path FROM file_touches ft
300
+ JOIN fix_links fl ON fl.suspected_break_sha = ft.commit_sha
301
+ )
302
+ `).get().n;
303
+ return { total, hits };
304
+ }
286
305
  var VALIDATORS = {
287
306
  revert_match: validateRevertMatch,
288
307
  hotspot: validateHotspot,
289
308
  fix_ratio: validateFixRatio,
290
- author_churn: validateAuthorChurn
309
+ author_churn: validateAuthorChurn,
310
+ cochange: validateCochange
291
311
  };
292
312
  function refreshCalibration(db, headSha) {
293
313
  const now = Math.floor(Date.now() / 1e3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "composto-ai",
3
- "version": "0.7.1",
3
+ "version": "0.8.2",
4
4
  "description": "Proactive AI team companion — less tokens, more insight",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsup",
21
+ "prepublishOnly": "npm run build",
21
22
  "test": "vitest run",
22
23
  "test:watch": "vitest",
23
24
  "dev": "tsx src/index.ts"