@rosh100yx/outlier 0.4.24 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const cyan = (text) => `\x1b[36m${text}\x1b[0m`;
4
- const dim = (text) => `\x1b[2m${text}\x1b[0m`;
5
- const bold = (text) => `\x1b[1m${text}\x1b[0m`;
3
+ const cyan = (t) => `\x1b[36m${t}\x1b[0m`;
4
+ const dim = (t) => `\x1b[2m${t}\x1b[0m`;
5
+ const bold = (t) => `\x1b[1m${t}\x1b[0m`;
6
+ const green = (t) => `\x1b[32m${t}\x1b[0m`;
6
7
 
7
- console.log('\n' + bold('Welcome to Outlier') + ' - AI Code Governance for the Terminal');
8
- console.log(dim('────────────────────────────────────────────────────────────'));
9
- console.log('To start the interactive wizard and audit your codebase, type:\n');
10
- console.log(` ${cyan('outlier')}\n`);
11
- console.log('Available Commands:');
12
- console.log(` ${cyan('outlier status')} Run full AI reliance & capability audit`);
13
- console.log(` ${cyan('outlier authorship')} Scan git history for AI co-authorship ratio`);
14
- console.log(` ${cyan('outlier carbon')} Scan local logs for token waste & carbon cost`);
15
- console.log(` ${cyan('outlier capabilities')} Audit active MCPs, skills, and orchestrations`);
16
- console.log(` ${cyan('outlier policy')} Configure CI/CD guardrails and thresholds`);
17
- console.log(` ${cyan('outlier impact')} See the compounding horizon of AI Deskilling`);
18
- console.log(` ${cyan('outlier knowledge')} Explore core literature and METR references`);
19
- console.log(` ${cyan('outlier participate')} Help build the literature on AI deskilling`);
20
- console.log(dim('────────────────────────────────────────────────────────────\n'));
8
+ console.log('\n' + bold(' Outlier installed') + dim(' · AI code governance for the terminal'));
9
+ console.log(dim(' ──────────────────────────────────────────────────────────'));
10
+ console.log(' Run it before you start coding. It reads your local git history');
11
+ console.log(' and AI logs — ' + green('on your machine') + ' — and shows you:');
12
+ console.log(' how much of your code AI wrote');
13
+ console.log(' what it cost (tokens, $, wasted context, carbon)');
14
+ console.log(' whether you are keeping the skill while you use the speed');
15
+ console.log('');
16
+ console.log(' Start your first audit:');
17
+ console.log(` ${cyan('outlier')}`);
18
+ console.log('');
19
+ console.log(' Other commands: ' + dim('outlier --help'));
20
+ console.log(dim(' ──────────────────────────────────────────────────────────'));
21
+ console.log(' ' + green('Local-first:') + ' nothing ever leaves your machine.\n');
@@ -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.24",
3
+ "version": "0.7.0",
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"
@@ -1,76 +1,116 @@
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
+ for (const cfg of [
76
+ join(homeDirPath, '.claude.json'),
77
+ join(homeDirPath, '.claude', 'settings.json'),
78
+ join(repoPath, '.mcp.json'),
79
+ join(repoPath, '.claude', 'settings.json'),
80
+ ]) {
81
+ const j = readJson(cfg);
82
+ if (j?.mcpServers) Object.keys(j.mcpServers).forEach(k => mcpNames.add(k));
32
83
  }
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) {}
84
+ // Gemini-style MCP dir
85
+ const geminiMcp = join(homeDirPath, '.gemini', 'antigravity-cli', 'mcp');
86
+ if (existsSync(geminiMcp)) {
87
+ try { readdirSync(geminiMcp, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => mcpNames.add(d.name)); } catch {}
47
88
  }
48
89
 
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) {}
90
+ const mcps: ToolReach[] = [...mcpNames].map(name => ({ name, reach: classifyReach(name) }));
91
+
92
+ // Skills
93
+ const skills: string[] = [];
94
+ for (const p of [join(repoPath, '.agents', 'skills'), join(homeDirPath, '.claude', 'skills'), join(homeDirPath, '.gemini', 'skills')]) {
95
+ if (existsSync(p)) {
96
+ try { readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory()).forEach(d => { if (!skills.includes(d.name)) skills.push(d.name); }); } catch {}
97
+ }
58
98
  }
59
99
 
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) {}
100
+ // Sub-agents (Claude Code agent definitions)
101
+ const subagents = countDir(join(homeDirPath, '.claude', 'agents')) + countDir(join(repoPath, '.claude', 'agents'));
102
+
103
+ // Hooks — automation that fires on the developer's behalf (a real governance surface)
104
+ const hooks: string[] = [];
105
+ for (const cfg of [join(homeDirPath, '.claude', 'settings.json'), join(repoPath, '.claude', 'settings.json')]) {
106
+ const j = readJson(cfg);
107
+ if (j?.hooks) Object.keys(j.hooks).forEach(k => { if (!hooks.includes(k)) hooks.push(k); });
73
108
  }
74
109
 
75
- return stats;
110
+ // Orchestration policy
111
+ const hasOrchestration = existsSync(join(repoPath, 'AGENTS.md')) || existsSync(join(repoPath, '.mcp.json'));
112
+
113
+ const { radius, reasons } = scoreBlast(mcps.map(m => m.reach));
114
+
115
+ return { mcps, skills, subagents, hooks, hasOrchestration, blastRadius: radius, blastReasons: reasons };
76
116
  }
package/src/carbon.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { homedir } from 'os';
2
2
  import { join } from 'path';
3
- import { readFile, access } from 'fs/promises';
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,51 +15,98 @@ 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 {
25
30
  private baseDir: string;
26
- constructor(baseDir = homedir()) {
31
+ private cwd: string;
32
+ constructor(baseDir = homedir(), cwd = process.cwd()) {
27
33
  this.baseDir = baseDir;
34
+ this.cwd = cwd;
28
35
  }
36
+
29
37
  async parse() {
38
+ // Primary source: the standard Claude Code session transcripts for THIS repo.
39
+ // Claude Code stores them at ~/.claude/projects/<cwd-with-slashes-as-dashes>/*.jsonl,
40
+ // one JSON object per line; assistant turns carry `message.usage`.
41
+ const slug = this.cwd.replace(/\//g, '-');
42
+ const projectDir = join(this.baseDir, '.claude', 'projects', slug);
43
+ try {
44
+ const files = (await readdir(projectDir)).filter(f => f.endsWith('.jsonl'));
45
+ if (files.length > 0) {
46
+ let total = 0, output = 0, cache = 0;
47
+ const sessions = new Set<string>();
48
+ const outputByModel: Record<string, number> = {};
49
+ for (const file of files) {
50
+ let text = '';
51
+ try { text = await readFile(join(projectDir, file), 'utf-8'); } catch { continue; }
52
+ for (const line of text.split('\n')) {
53
+ if (!line.trim()) continue;
54
+ try {
55
+ const d = JSON.parse(line);
56
+ const msg = d.message || {};
57
+ const u = msg.usage || d.usage;
58
+ if (u) {
59
+ const inp = u.input_tokens || 0;
60
+ const out = u.output_tokens || 0;
61
+ const cr = u.cache_read_input_tokens || 0;
62
+ const cw = u.cache_creation_input_tokens || 0;
63
+ total += inp + out + cr + cw;
64
+ output += out;
65
+ cache += cr;
66
+ const model = msg.model || 'default';
67
+ outputByModel[model] = (outputByModel[model] || 0) + out;
68
+ }
69
+ if (d.sessionId) sessions.add(d.sessionId);
70
+ } catch {}
71
+ }
72
+ }
73
+ // Standard transcripts carry no cost field; cost is estimated downstream.
74
+ return { total, output, cache, sessions: sessions.size, cost: 0, outputByModel };
75
+ }
76
+ } catch {}
77
+
78
+ // Fallback: the optional tokenomics-log.jsonl (written by a custom Stop hook;
79
+ // carries a real cost_usd field when present).
30
80
  const logPath = join(this.baseDir, '.claude', 'tokenomics-log.jsonl');
31
-
32
81
  try {
33
82
  await access(logPath);
34
83
  } catch {
35
- return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
84
+ return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
36
85
  }
37
-
38
86
  const text = await readFile(logPath, 'utf-8');
39
- const lines = text.trim().split('\n').filter(l => l.length > 0);
40
-
41
87
  let total = 0, output = 0, cache = 0, cost = 0;
42
88
  const sessions = new Set<string>();
43
-
44
- for (const line of lines) {
89
+ const outputByModel: Record<string, number> = {};
90
+ for (const line of text.split('\n')) {
91
+ if (!line.trim()) continue;
45
92
  try {
46
93
  const data = JSON.parse(line);
47
94
  total += data.total_tokens || 0;
48
95
  output += data.output_tokens || 0;
49
96
  cache += data.cache_read || 0;
50
- cost += data.cost_usd || 0; // present when the log was written with a cost field
97
+ cost += data.cost_usd || 0;
51
98
  if (data.session_id) sessions.add(data.session_id);
52
- } catch (e) {}
99
+ const model = data.model || 'default';
100
+ outputByModel[model] = (outputByModel[model] || 0) + (data.output_tokens || 0);
101
+ } catch {}
53
102
  }
54
- return { total, output, cache, sessions: sessions.size, cost };
103
+ return { total, output, cache, sessions: sessions.size, cost, outputByModel };
55
104
  }
56
105
  }
57
106
 
58
107
  class CursorLogParser implements TokenLogParser {
59
108
  async parse() {
60
- return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0 };
109
+ return { total: 0, output: 0, cache: 0, sessions: 0, cost: 0, outputByModel: {} };
61
110
  }
62
111
  }
63
112
 
@@ -77,13 +126,14 @@ function getLocalGridFactor(): { region: string, factor: number } {
77
126
  if (tz.includes('Singapore')) return { region: 'Singapore', factor: gridFactors.singapore };
78
127
  if (tz.includes('Calcutta') || tz.includes('Kolkata') || tz.includes('Asia/Kabul')) return { region: 'India', factor: gridFactors.india_average };
79
128
  } catch (e) {}
80
- return { region: 'Global Average', factor: 450 };
129
+ return { region: 'Global Average', factor: gridFactors.global_average };
81
130
  }
82
131
 
83
132
  export async function getCarbonStats(): Promise<CarbonStats> {
84
133
  const parsers: TokenLogParser[] = [new ClaudeLogParser(), new CursorLogParser()];
85
-
134
+
86
135
  let totalTokens = 0, outputTokens = 0, cacheReadTokens = 0, sessions = 0, loggedCost = 0;
136
+ const outputByModel: Record<string, number> = {};
87
137
 
88
138
  for (const parser of parsers) {
89
139
  const stats = await parser.parse();
@@ -92,11 +142,18 @@ export async function getCarbonStats(): Promise<CarbonStats> {
92
142
  cacheReadTokens += stats.cache;
93
143
  sessions += stats.sessions;
94
144
  loggedCost += stats.cost;
145
+ for (const [m, out] of Object.entries(stats.outputByModel)) {
146
+ outputByModel[m] = (outputByModel[m] || 0) + out;
147
+ }
95
148
  }
96
149
 
97
- const energyKwh = (outputTokens / 1_000_000) * 0.662;
150
+ // Model-aware energy (replaces the single flat 0.662 coefficient).
151
+ const energyKwh = energyKwhByModel(outputByModel);
98
152
  const localGrid = getLocalGridFactor();
99
153
 
154
+ // Source provenance for honest labelling in the UI.
155
+ const sources = detectSources();
156
+
100
157
  // Prefer the log's own cost field (accurate); fall back to a rough token estimate.
101
158
  const costIsReal = loggedCost > 0;
102
159
  const estUsd = costIsReal ? loggedCost : estimateUsd(outputTokens, cacheReadTokens, totalTokens);
@@ -112,6 +169,9 @@ export async function getCarbonStats(): Promise<CarbonStats> {
112
169
  localRegion: localGrid.region,
113
170
  sessions,
114
171
  estUsd,
115
- costIsReal
172
+ costIsReal,
173
+ tokenProvenance: sources.tokenSource.provenance,
174
+ carbonProvenance: sources.carbonSource.provenance,
175
+ sourceLabel: provLabel(sources.tokenSource)
116
176
  };
117
177
  }