@rosh100yx/outlier 0.4.25 → 0.10.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.
@@ -1,7 +1,20 @@
1
1
  {
2
2
  "vietnam": 681,
3
- "france": 21.7,
4
- "us_east": 380,
3
+ "india_average": 715,
4
+ "indonesia": 650,
5
+ "china": 581,
5
6
  "singapore": 408,
6
- "india_average": 715
7
+ "japan": 470,
8
+ "south_korea": 415,
9
+ "australia": 510,
10
+ "us_east": 380,
11
+ "us_west": 210,
12
+ "canada": 120,
13
+ "brazil": 95,
14
+ "uk": 210,
15
+ "germany": 350,
16
+ "france": 21.7,
17
+ "norway": 28,
18
+ "sweden": 41,
19
+ "global_average": 450
7
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rosh100yx/outlier",
3
- "version": "0.4.25",
3
+ "version": "0.10.2",
4
4
  "description": "AI Code Governance & Capability Auditing for the Terminal. Measures AI reliance, context waste, and enforces local CI/CD policies.",
5
5
  "bin": {
6
6
  "outlier": "bin/outlier.js"
@@ -0,0 +1,59 @@
1
+ // #8 Light team/fleet aggregation — local-first, no export.
2
+ //
3
+ // Each developer runs `outlier --json > me.json` and drops it in a shared folder (or CI
4
+ // collects them). `outlier aggregate <dir>` merges those JSON files into a team rollup.
5
+ // No machine talks to another; it only reads JSON the team already produced. This is the
6
+ // honest, minimal version of fleet view — real aggregation without a backend.
7
+
8
+ import { readdirSync, readFileSync } from 'fs';
9
+ import { join } from 'path';
10
+
11
+ export interface TeamRollup {
12
+ developers: number;
13
+ avgAiPercent: number | null;
14
+ maxAiPercent: number | null;
15
+ totalEstUsd: number;
16
+ worstBlastRadius: string;
17
+ overLimit: number; // how many devs are over their AI cap
18
+ reachWriteDeploy: number; // total write/deploy-capable tools across the team
19
+ notes: string[];
20
+ }
21
+
22
+ const BLAST_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
23
+
24
+ export function aggregateDir(dir: string): TeamRollup {
25
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'));
26
+ const audits: any[] = [];
27
+ for (const f of files) {
28
+ try {
29
+ const j = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
30
+ if (j?.tool === 'outlier') audits.push(j);
31
+ } catch {}
32
+ }
33
+
34
+ const aiPcts = audits.map(a => a.authorship?.aiPercent).filter((x: any) => typeof x === 'number');
35
+ const spends = audits.map(a => a.cost?.estUsd || 0);
36
+ const blasts = audits.map(a => a.reach?.blastRadius).filter(Boolean);
37
+ const overs = audits.filter(a => a.policy?.status === 'over').length;
38
+ const writeDeploy = audits.reduce((s, a) => s + (a.reach?.writeOrDeployCount || 0), 0);
39
+
40
+ const worst = blasts.reduce((m: string, b: string) =>
41
+ BLAST_ORDER.indexOf(b) > BLAST_ORDER.indexOf(m) ? b : m, 'LOW');
42
+
43
+ const notes: string[] = [];
44
+ if (aiPcts.length && Math.max(...aiPcts) > 70) notes.push('At least one developer is over 70% AI authorship.');
45
+ if (worst === 'HIGH' || worst === 'CRITICAL') notes.push(`Worst-case agent blast radius across the team is ${worst}.`);
46
+ if (overs > 0) notes.push(`${overs} developer(s) over their AI-authorship limit.`);
47
+ if (audits.length === 0) notes.push('No outlier --json files found in this folder.');
48
+
49
+ return {
50
+ developers: audits.length,
51
+ avgAiPercent: aiPcts.length ? +(aiPcts.reduce((a: number, b: number) => a + b, 0) / aiPcts.length).toFixed(1) : null,
52
+ maxAiPercent: aiPcts.length ? Math.max(...aiPcts) : null,
53
+ totalEstUsd: +spends.reduce((a: number, b: number) => a + b, 0).toFixed(2),
54
+ worstBlastRadius: worst,
55
+ overLimit: overs,
56
+ reachWriteDeploy: writeDeploy,
57
+ notes,
58
+ };
59
+ }
@@ -1,76 +1,121 @@
1
1
  import { homedir } from 'os';
2
2
  import { join } from 'path';
3
- import { existsSync, readdirSync } from 'fs';
3
+ import { existsSync, readdirSync, readFileSync } from 'fs';
4
+
5
+ // Reach = what a tool can actually do to you if an agent (or a prompt injection) drives it.
6
+ export type Reach = 'read' | 'network' | 'model' | 'data' | 'write-local' | 'write-remote' | 'deploy' | 'exec' | 'money';
7
+
8
+ export interface ToolReach {
9
+ name: string;
10
+ reach: Reach;
11
+ }
12
+
13
+ export type BlastRadius = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
4
14
 
5
15
  export interface CapabilitiesStats {
6
- mcps: string[];
16
+ mcps: ToolReach[];
7
17
  skills: string[];
18
+ subagents: number;
19
+ hooks: string[]; // lifecycle events with auto-firing hooks
8
20
  hasOrchestration: boolean;
21
+ blastRadius: BlastRadius;
22
+ blastReasons: string[]; // plain-language reasons for the score
23
+ }
24
+
25
+ // Classify an MCP/tool by name into a reach category. Keyword-based, since names vary.
26
+ export function classifyReach(name: string): Reach {
27
+ const n = name.toLowerCase();
28
+ if (/(stripe|payment|infinity|kucoin|wallet|billing|payout)/.test(n)) return 'money';
29
+ if (/(shell|bash|exec|terminal|command|sandbox)/.test(n)) return 'exec';
30
+ if (/(cloudflare|vercel|netlify|modal|deploy|fly\.io|render|heroku|aws|gcp|azure)/.test(n)) return 'deploy';
31
+ if (/(github|gitlab|git|bitbucket)/.test(n)) return 'write-remote';
32
+ if (/(filesystem|file|fs|disk)/.test(n)) return 'write-local';
33
+ if (/(memory|supermemory|mem|obsidian|notion|airtable|coda|database|sql|store)/.test(n)) return 'data';
34
+ if (/(openrouter|ollama|openai|anthropic|llm|model|hugging)/.test(n)) return 'model';
35
+ if (/(exa|web|fetch|search|brave|browser|http|scrape)/.test(n)) return 'network';
36
+ return 'network'; // unknown tool: assume it can reach out
37
+ }
38
+
39
+ const REACH_RISK: Record<Reach, number> = {
40
+ read: 0, model: 1, network: 1, 'data': 2, 'write-local': 2, 'write-remote': 3, deploy: 3, exec: 4, money: 4,
41
+ };
42
+
43
+ function scoreBlast(reaches: Reach[]): { radius: BlastRadius; reasons: string[] } {
44
+ const reasons: string[] = [];
45
+ const has = (r: Reach) => reaches.includes(r);
46
+ if (has('money')) reasons.push('can move money');
47
+ if (has('exec')) reasons.push('can run shell commands');
48
+ if (has('deploy')) reasons.push('can deploy to production');
49
+ if (has('write-remote')) reasons.push('can push to your remote repos');
50
+ if (has('write-local')) reasons.push('can write your local files');
51
+ if (has('data')) reasons.push('can read/write your stored data');
52
+ const netCount = reaches.filter(r => r === 'network' || r === 'model').length;
53
+ if (netCount >= 3) reasons.push(`reaches ${netCount} external services`);
54
+
55
+ const max = reaches.reduce((m, r) => Math.max(m, REACH_RISK[r]), 0);
56
+ let radius: BlastRadius = 'LOW';
57
+ if (max >= 4) radius = 'CRITICAL';
58
+ else if (max >= 3) radius = 'HIGH';
59
+ else if (max >= 2) radius = 'MEDIUM';
60
+ return { radius, reasons };
61
+ }
62
+
63
+ function readJson(path: string): any {
64
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
65
+ }
66
+
67
+ function countDir(path: string, ext = '.md'): number {
68
+ try { return readdirSync(path).filter(f => f.endsWith(ext)).length; } catch { return 0; }
9
69
  }
10
70
 
11
71
  export async function getCapabilitiesStats(repoPath: string = process.cwd(), homeDirPath: string = homedir()): Promise<CapabilitiesStats> {
12
- const stats: CapabilitiesStats = {
13
- mcps: [],
14
- skills: [],
15
- hasOrchestration: false
16
- };
17
-
18
- // Check for AGENTS.md (Orchestration Policy)
19
- if (existsSync(join(repoPath, 'AGENTS.md'))) {
20
- stats.hasOrchestration = true;
21
- }
72
+ const mcpNames = new Set<string>();
22
73
 
23
- // Scan local project skills
24
- const projectSkillsPath = join(repoPath, '.agents', 'skills');
25
- if (existsSync(projectSkillsPath)) {
26
- try {
27
- const skills = readdirSync(projectSkillsPath, { withFileTypes: true })
28
- .filter(dirent => dirent.isDirectory())
29
- .map(dirent => dirent.name);
30
- stats.skills.push(...skills);
31
- } catch (e) {}
74
+ // MCP servers the agent's actual tool reach. Read from every place they live.
75
+ // Read MCP servers from every place agents declare them (Claude, Cursor, Gemini, project).
76
+ for (const cfg of [
77
+ join(homeDirPath, '.claude.json'),
78
+ join(homeDirPath, '.claude', 'settings.json'),
79
+ join(homeDirPath, '.cursor', 'mcp.json'),
80
+ join(homeDirPath, '.gemini', 'config', 'mcp_config.json'),
81
+ join(homeDirPath, '.gemini', 'settings.json'),
82
+ join(repoPath, '.mcp.json'),
83
+ join(repoPath, '.cursor', 'mcp.json'),
84
+ join(repoPath, '.claude', 'settings.json'),
85
+ ]) {
86
+ const j = readJson(cfg);
87
+ if (j?.mcpServers) Object.keys(j.mcpServers).forEach(k => mcpNames.add(k));
32
88
  }
33
-
34
- // Scan global skills (Gemini / Claude)
35
- const geminiSkillsPath = join(homeDirPath, '.gemini', 'skills');
36
- if (existsSync(geminiSkillsPath)) {
37
- try {
38
- const skills = readdirSync(geminiSkillsPath, { withFileTypes: true })
39
- .filter(dirent => dirent.isDirectory())
40
- .map(dirent => dirent.name);
41
-
42
- // Only add if not already added (dedupe)
43
- for (const skill of skills) {
44
- if (!stats.skills.includes(skill)) stats.skills.push(skill);
45
- }
46
- } catch (e) {}
89
+ // Gemini-style MCP dir
90
+ const geminiMcp = join(homeDirPath, '.gemini', 'antigravity-cli', 'mcp');
91
+ if (existsSync(geminiMcp)) {
92
+ try { readdirSync(geminiMcp, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => mcpNames.add(d.name)); } catch {}
47
93
  }
48
94
 
49
- // Scan MCPs from gemini config
50
- const geminiMcpPath = join(homeDirPath, '.gemini', 'antigravity-cli', 'mcp');
51
- if (existsSync(geminiMcpPath)) {
52
- try {
53
- const mcps = readdirSync(geminiMcpPath, { withFileTypes: true })
54
- .filter(dirent => dirent.isDirectory())
55
- .map(dirent => dirent.name);
56
- stats.mcps.push(...mcps);
57
- } catch (e) {}
95
+ const mcps: ToolReach[] = [...mcpNames].map(name => ({ name, reach: classifyReach(name) }));
96
+
97
+ // Skills
98
+ const skills: string[] = [];
99
+ for (const p of [join(repoPath, '.agents', 'skills'), join(homeDirPath, '.claude', 'skills'), join(homeDirPath, '.gemini', 'skills')]) {
100
+ if (existsSync(p)) {
101
+ try { readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => { if (!skills.includes(d.name)) skills.push(d.name); }); } catch {}
102
+ }
58
103
  }
59
104
 
60
- // Also check Claude settings for legacy MCPs/Plugins
61
- const claudeSettingsPath = join(homeDirPath, '.claude', 'settings.json');
62
- if (existsSync(claudeSettingsPath)) {
63
- try {
64
- const claudeSettings = require(claudeSettingsPath);
65
- if (claudeSettings.enabledPlugins) {
66
- Object.keys(claudeSettings.enabledPlugins).forEach(plugin => {
67
- if (claudeSettings.enabledPlugins[plugin] && !stats.mcps.includes(plugin)) {
68
- stats.mcps.push(`plugin:${plugin}`);
69
- }
70
- });
71
- }
72
- } catch (e) {}
105
+ // Sub-agents (Claude Code agent definitions)
106
+ const subagents = countDir(join(homeDirPath, '.claude', 'agents')) + countDir(join(repoPath, '.claude', 'agents'));
107
+
108
+ // Hooks — automation that fires on the developer's behalf (a real governance surface)
109
+ const hooks: string[] = [];
110
+ for (const cfg of [join(homeDirPath, '.claude', 'settings.json'), join(repoPath, '.claude', 'settings.json')]) {
111
+ const j = readJson(cfg);
112
+ if (j?.hooks) Object.keys(j.hooks).forEach(k => { if (!hooks.includes(k)) hooks.push(k); });
73
113
  }
74
114
 
75
- return stats;
115
+ // Orchestration policy
116
+ const hasOrchestration = existsSync(join(repoPath, 'AGENTS.md')) || existsSync(join(repoPath, '.mcp.json'));
117
+
118
+ const { radius, reasons } = scoreBlast(mcps.map(m => m.reach));
119
+
120
+ return { mcps, skills, subagents, hooks, hasOrchestration, blastRadius: radius, blastReasons: reasons };
76
121
  }
package/src/carbon.ts CHANGED
@@ -2,6 +2,8 @@ import { homedir } from 'os';
2
2
  import { join } from 'path';
3
3
  import { readFile, access, readdir } from 'fs/promises';
4
4
  import gridFactors from '../data/grid-factors.json';
5
+ import { energyKwhByModel } from './emissions';
6
+ import { detectSources, provLabel, type Provenance } from './sources';
5
7
 
6
8
  export interface CarbonStats {
7
9
  totalTokens: number;
@@ -13,12 +15,15 @@ export interface CarbonStats {
13
15
  localCo2Kg: number;
14
16
  localRegion: string;
15
17
  sessions: number;
16
- estUsd: number; // estimated spend in USD
17
- costIsReal: boolean; // true if summed from the log's own cost field, false if estimated from tokens
18
+ estUsd: number; // estimated spend in USD
19
+ costIsReal: boolean; // true if summed from the log's own cost field, false if estimated
20
+ tokenProvenance: Provenance;
21
+ carbonProvenance: Provenance;
22
+ sourceLabel: string; // e.g. "estimated · Claude Code transcripts"
18
23
  }
19
24
 
20
25
  export interface TokenLogParser {
21
- parse(): Promise<{ total: number, output: number, cache: number, sessions: number, cost: number }>;
26
+ parse(): Promise<{ total: number, output: number, cache: number, sessions: number, cost: number, outputByModel: Record<string, number> }>;
22
27
  }
23
28
 
24
29
  export class ClaudeLogParser implements TokenLogParser {
@@ -40,6 +45,7 @@ export class ClaudeLogParser implements TokenLogParser {
40
45
  if (files.length > 0) {
41
46
  let total = 0, output = 0, cache = 0;
42
47
  const sessions = new Set<string>();
48
+ const outputByModel: Record<string, number> = {};
43
49
  for (const file of files) {
44
50
  let text = '';
45
51
  try { text = await readFile(join(projectDir, file), 'utf-8'); } catch { continue; }
@@ -47,7 +53,8 @@ export class ClaudeLogParser implements TokenLogParser {
47
53
  if (!line.trim()) continue;
48
54
  try {
49
55
  const d = JSON.parse(line);
50
- const u = (d.message && d.message.usage) || d.usage;
56
+ const msg = d.message || {};
57
+ const u = msg.usage || d.usage;
51
58
  if (u) {
52
59
  const inp = u.input_tokens || 0;
53
60
  const out = u.output_tokens || 0;
@@ -56,13 +63,15 @@ export class ClaudeLogParser implements TokenLogParser {
56
63
  total += inp + out + cr + cw;
57
64
  output += out;
58
65
  cache += cr;
66
+ const model = msg.model || 'default';
67
+ outputByModel[model] = (outputByModel[model] || 0) + out;
59
68
  }
60
69
  if (d.sessionId) sessions.add(d.sessionId);
61
70
  } catch {}
62
71
  }
63
72
  }
64
73
  // Standard transcripts carry no cost field; cost is estimated downstream.
65
- return { total, output, cache, sessions: sessions.size, cost: 0 };
74
+ return { total, output, cache, sessions: sessions.size, cost: 0, outputByModel };
66
75
  }
67
76
  } catch {}
68
77
 
@@ -72,11 +81,12 @@ export class ClaudeLogParser implements TokenLogParser {
72
81
  try {
73
82
  await access(logPath);
74
83
  } catch {
75
- return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
84
+ return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
76
85
  }
77
86
  const text = await readFile(logPath, 'utf-8');
78
87
  let total = 0, output = 0, cache = 0, cost = 0;
79
88
  const sessions = new Set<string>();
89
+ const outputByModel: Record<string, number> = {};
80
90
  for (const line of text.split('\n')) {
81
91
  if (!line.trim()) continue;
82
92
  try {
@@ -86,15 +96,17 @@ export class ClaudeLogParser implements TokenLogParser {
86
96
  cache += data.cache_read || 0;
87
97
  cost += data.cost_usd || 0;
88
98
  if (data.session_id) sessions.add(data.session_id);
99
+ const model = data.model || 'default';
100
+ outputByModel[model] = (outputByModel[model] || 0) + (data.output_tokens || 0);
89
101
  } catch {}
90
102
  }
91
- return { total, output, cache, sessions: sessions.size, cost };
103
+ return { total, output, cache, sessions: sessions.size, cost, outputByModel };
92
104
  }
93
105
  }
94
106
 
95
107
  class CursorLogParser implements TokenLogParser {
96
108
  async parse() {
97
- return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
109
+ return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
98
110
  }
99
111
  }
100
112
 
@@ -105,6 +117,33 @@ function estimateUsd(output: number, cacheRead: number, total: number): number {
105
117
  return (output / 1e6) * 9 + (cacheRead / 1e6) * 0.3 + (otherInput / 1e6) * 3;
106
118
  }
107
119
 
120
+ // #6 CodeCarbon: if the developer runs CodeCarbon, it writes a real MEASURED
121
+ // emissions.csv (hardware energy, not a token estimate). Read the latest run.
122
+ // Columns (codecarbon >=2): timestamp,project_name,...,duration,emissions,...,energy_consumed,...
123
+ function readCodeCarbon(cwd: string, home: string): { energyKwh: number; co2Kg: number } | null {
124
+ const { readFileSync, existsSync } = require('fs');
125
+ const { join } = require('path');
126
+ for (const p of [join(cwd, 'emissions.csv'), join(home, '.codecarbon', 'emissions.csv')]) {
127
+ try {
128
+ if (!existsSync(p)) continue;
129
+ const lines = readFileSync(p, 'utf-8').trim().split('\n');
130
+ if (lines.length < 2) continue;
131
+ const header = lines[0].split(',');
132
+ const iEm = header.indexOf('emissions'); // kg CO2eq
133
+ const iEn = header.indexOf('energy_consumed'); // kWh
134
+ if (iEm === -1 && iEn === -1) continue;
135
+ let co2 = 0, kwh = 0;
136
+ for (const row of lines.slice(1)) {
137
+ const cols = row.split(',');
138
+ co2 += parseFloat(cols[iEm]) || 0;
139
+ kwh += parseFloat(cols[iEn]) || 0;
140
+ }
141
+ return { energyKwh: kwh, co2Kg: co2 };
142
+ } catch {}
143
+ }
144
+ return null;
145
+ }
146
+
108
147
  function getLocalGridFactor(): { region: string, factor: number } {
109
148
  try {
110
149
  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -114,13 +153,14 @@ function getLocalGridFactor(): { region: string, factor: number } {
114
153
  if (tz.includes('Singapore')) return { region: 'Singapore', factor: gridFactors.singapore };
115
154
  if (tz.includes('Calcutta') || tz.includes('Kolkata') || tz.includes('Asia/Kabul')) return { region: 'India', factor: gridFactors.india_average };
116
155
  } catch (e) {}
117
- return { region: 'Global Average', factor: 450 };
156
+ return { region: 'Global Average', factor: gridFactors.global_average };
118
157
  }
119
158
 
120
159
  export async function getCarbonStats(): Promise<CarbonStats> {
121
160
  const parsers: TokenLogParser[] = [new ClaudeLogParser(), new CursorLogParser()];
122
-
161
+
123
162
  let totalTokens = 0, outputTokens = 0, cacheReadTokens = 0, sessions = 0, loggedCost = 0;
163
+ const outputByModel: Record<string, number> = {};
124
164
 
125
165
  for (const parser of parsers) {
126
166
  const stats = await parser.parse();
@@ -129,10 +169,19 @@ export async function getCarbonStats(): Promise<CarbonStats> {
129
169
  cacheReadTokens += stats.cache;
130
170
  sessions += stats.sessions;
131
171
  loggedCost += stats.cost;
172
+ for (const [m, out] of Object.entries(stats.outputByModel)) {
173
+ outputByModel[m] = (outputByModel[m] || 0) + out;
174
+ }
132
175
  }
133
176
 
134
- const energyKwh = (outputTokens / 1_000_000) * 0.662;
177
+ // Carbon: prefer CodeCarbon's measured hardware data; else model-aware estimate.
135
178
  const localGrid = getLocalGridFactor();
179
+ const measured = readCodeCarbon(process.cwd(), homedir());
180
+ const energyKwh = measured ? measured.energyKwh : energyKwhByModel(outputByModel);
181
+ const measuredCo2 = measured ? measured.co2Kg : null;
182
+
183
+ // Source provenance for honest labelling in the UI.
184
+ const sources = detectSources();
136
185
 
137
186
  // Prefer the log's own cost field (accurate); fall back to a rough token estimate.
138
187
  const costIsReal = loggedCost > 0;
@@ -145,10 +194,13 @@ export async function getCarbonStats(): Promise<CarbonStats> {
145
194
  energyKwh,
146
195
  co2KgVietnam: (energyKwh * gridFactors.vietnam) / 1000,
147
196
  co2KgFrance: (energyKwh * gridFactors.france) / 1000,
148
- localCo2Kg: (energyKwh * localGrid.factor) / 1000,
149
- localRegion: localGrid.region,
197
+ localCo2Kg: measuredCo2 !== null ? measuredCo2 : (energyKwh * localGrid.factor) / 1000,
198
+ localRegion: measured ? 'CodeCarbon (measured)' : localGrid.region,
150
199
  sessions,
151
200
  estUsd,
152
- costIsReal
201
+ costIsReal,
202
+ tokenProvenance: sources.tokenSource.provenance,
203
+ carbonProvenance: sources.carbonSource.provenance,
204
+ sourceLabel: provLabel(sources.tokenSource)
153
205
  };
154
206
  }