@rosh100yx/outlier 0.4.25 → 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.
- package/README.md +18 -1
- package/bin/outlier.js +440 -103
- package/data/grid-factors.json +16 -3
- package/package.json +1 -1
- package/src/capabilities.ts +98 -58
- package/src/carbon.ts +35 -12
- package/src/cli.ts +126 -14
- package/src/emissions.ts +69 -0
- package/src/sources.ts +110 -0
package/data/grid-factors.json
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"vietnam": 681,
|
|
3
|
-
"
|
|
4
|
-
"
|
|
3
|
+
"india_average": 715,
|
|
4
|
+
"indonesia": 650,
|
|
5
|
+
"china": 581,
|
|
5
6
|
"singapore": 408,
|
|
6
|
-
"
|
|
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.
|
|
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"
|
package/src/capabilities.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
@@ -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;
|
|
17
|
-
costIsReal: boolean;
|
|
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
|
|
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
|
|
|
@@ -114,13 +126,14 @@ function getLocalGridFactor(): { region: string, factor: number } {
|
|
|
114
126
|
if (tz.includes('Singapore')) return { region: 'Singapore', factor: gridFactors.singapore };
|
|
115
127
|
if (tz.includes('Calcutta') || tz.includes('Kolkata') || tz.includes('Asia/Kabul')) return { region: 'India', factor: gridFactors.india_average };
|
|
116
128
|
} catch (e) {}
|
|
117
|
-
return { region: 'Global Average', factor:
|
|
129
|
+
return { region: 'Global Average', factor: gridFactors.global_average };
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
export async function getCarbonStats(): Promise<CarbonStats> {
|
|
121
133
|
const parsers: TokenLogParser[] = [new ClaudeLogParser(), new CursorLogParser()];
|
|
122
|
-
|
|
134
|
+
|
|
123
135
|
let totalTokens = 0, outputTokens = 0, cacheReadTokens = 0, sessions = 0, loggedCost = 0;
|
|
136
|
+
const outputByModel: Record<string, number> = {};
|
|
124
137
|
|
|
125
138
|
for (const parser of parsers) {
|
|
126
139
|
const stats = await parser.parse();
|
|
@@ -129,11 +142,18 @@ export async function getCarbonStats(): Promise<CarbonStats> {
|
|
|
129
142
|
cacheReadTokens += stats.cache;
|
|
130
143
|
sessions += stats.sessions;
|
|
131
144
|
loggedCost += stats.cost;
|
|
145
|
+
for (const [m, out] of Object.entries(stats.outputByModel)) {
|
|
146
|
+
outputByModel[m] = (outputByModel[m] || 0) + out;
|
|
147
|
+
}
|
|
132
148
|
}
|
|
133
149
|
|
|
134
|
-
|
|
150
|
+
// Model-aware energy (replaces the single flat 0.662 coefficient).
|
|
151
|
+
const energyKwh = energyKwhByModel(outputByModel);
|
|
135
152
|
const localGrid = getLocalGridFactor();
|
|
136
153
|
|
|
154
|
+
// Source provenance for honest labelling in the UI.
|
|
155
|
+
const sources = detectSources();
|
|
156
|
+
|
|
137
157
|
// Prefer the log's own cost field (accurate); fall back to a rough token estimate.
|
|
138
158
|
const costIsReal = loggedCost > 0;
|
|
139
159
|
const estUsd = costIsReal ? loggedCost : estimateUsd(outputTokens, cacheReadTokens, totalTokens);
|
|
@@ -149,6 +169,9 @@ export async function getCarbonStats(): Promise<CarbonStats> {
|
|
|
149
169
|
localRegion: localGrid.region,
|
|
150
170
|
sessions,
|
|
151
171
|
estUsd,
|
|
152
|
-
costIsReal
|
|
172
|
+
costIsReal,
|
|
173
|
+
tokenProvenance: sources.tokenSource.provenance,
|
|
174
|
+
carbonProvenance: sources.carbonSource.provenance,
|
|
175
|
+
sourceLabel: provLabel(sources.tokenSource)
|
|
153
176
|
};
|
|
154
177
|
}
|
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,74 @@ const ASCII_LOGO = `
|
|
|
20
20
|
|
|
21
21
|
let finalReceipt = '';
|
|
22
22
|
|
|
23
|
+
// Build a stable, machine-readable audit object. This is the contract agents,
|
|
24
|
+
// swarms, and CI parse — everything the human receipt shows, as plain JSON.
|
|
25
|
+
async function emitJson() {
|
|
26
|
+
const pkg = require('../package.json');
|
|
27
|
+
const [gitStats, carbon, caps] = await Promise.all([
|
|
28
|
+
getAuthorshipStats().catch(() => null),
|
|
29
|
+
getCarbonStats().catch(() => null),
|
|
30
|
+
getCapabilitiesStats().catch(() => null),
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const aiRatio = gitStats ? gitStats.ratio : 0;
|
|
34
|
+
const cap = 0.70;
|
|
35
|
+
const writeOrDeploy = caps
|
|
36
|
+
? caps.mcps.filter((m: any) => ['money', 'exec', 'deploy', 'write-remote', 'write-local'].includes(m.reach)).length
|
|
37
|
+
: 0;
|
|
38
|
+
|
|
39
|
+
const out = {
|
|
40
|
+
tool: 'outlier',
|
|
41
|
+
version: pkg.version,
|
|
42
|
+
repo: process.cwd().split('/').pop(),
|
|
43
|
+
generatedAt: new Date().toISOString(),
|
|
44
|
+
localFirst: true,
|
|
45
|
+
authorship: gitStats ? {
|
|
46
|
+
aiPercent: +(gitStats.ratio * 100).toFixed(1),
|
|
47
|
+
aiRatio: gitStats.ratio,
|
|
48
|
+
totalCommits: gitStats.total,
|
|
49
|
+
aiCommits: gitStats.ai,
|
|
50
|
+
nonMergePercent: +(gitStats.ratioNoMerges * 100).toFixed(1),
|
|
51
|
+
provenance: 'proxy',
|
|
52
|
+
note: 'git Co-Authored-By trailers; under-counts if the agent omits the trailer',
|
|
53
|
+
} : null,
|
|
54
|
+
cost: carbon ? {
|
|
55
|
+
totalTokens: carbon.totalTokens,
|
|
56
|
+
outputTokens: carbon.outputTokens,
|
|
57
|
+
cacheReusePercent: carbon.totalTokens ? +((carbon.cacheReadTokens / carbon.totalTokens) * 100).toFixed(1) : 0,
|
|
58
|
+
estUsd: +carbon.estUsd.toFixed(2),
|
|
59
|
+
costIsReal: carbon.costIsReal,
|
|
60
|
+
provenance: carbon.tokenProvenance,
|
|
61
|
+
source: carbon.sourceLabel,
|
|
62
|
+
} : null,
|
|
63
|
+
carbon: carbon ? {
|
|
64
|
+
energyKwh: +carbon.energyKwh.toFixed(4),
|
|
65
|
+
co2Kg: +carbon.localCo2Kg.toFixed(4),
|
|
66
|
+
region: carbon.localRegion,
|
|
67
|
+
provenance: carbon.carbonProvenance,
|
|
68
|
+
note: 'counterfactual: cloud inference runs on the provider grid, not yours',
|
|
69
|
+
} : null,
|
|
70
|
+
reach: caps ? {
|
|
71
|
+
blastRadius: caps.blastRadius,
|
|
72
|
+
reasons: caps.blastReasons,
|
|
73
|
+
toolCount: caps.mcps.length,
|
|
74
|
+
writeOrDeployCount: writeOrDeploy,
|
|
75
|
+
tools: caps.mcps,
|
|
76
|
+
subagents: caps.subagents,
|
|
77
|
+
hooks: caps.hooks,
|
|
78
|
+
skills: caps.skills.length,
|
|
79
|
+
orchestration: caps.hasOrchestration,
|
|
80
|
+
} : null,
|
|
81
|
+
policy: {
|
|
82
|
+
aiCapPercent: cap * 100,
|
|
83
|
+
status: aiRatio > cap ? 'over' : 'within',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Only JSON on stdout — nothing else.
|
|
88
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
23
91
|
async function runOnboarding() {
|
|
24
92
|
console.log(pc.cyan(ASCII_LOGO));
|
|
25
93
|
intro(pc.inverse(' outlier: Welcome '));
|
|
@@ -78,11 +146,18 @@ async function main() {
|
|
|
78
146
|
action = 'status';
|
|
79
147
|
}
|
|
80
148
|
|
|
149
|
+
// Agent / CI / swarm contract: --json emits a structured audit and nothing else
|
|
150
|
+
// (no logo, no spinner, no ANSI). This is how an agent perceives outlier.
|
|
151
|
+
if (process.argv.includes('--json')) {
|
|
152
|
+
await emitJson();
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
81
156
|
console.log(pc.cyan(ASCII_LOGO));
|
|
82
157
|
const pkg = require('../package.json');
|
|
83
158
|
console.log(pc.dim(` Outlier v${pkg.version} · AI Code Reliance & Telemetry Engine\n`));
|
|
84
|
-
|
|
85
|
-
|
|
159
|
+
|
|
160
|
+
|
|
86
161
|
if (action === '--help' || action === '-h' || action === 'help') {
|
|
87
162
|
console.log(pc.bold('\nWHAT OUTLIER DOES'));
|
|
88
163
|
console.log(pc.dim(' Reads your local git history and AI logs — on your machine — to show'));
|
|
@@ -91,6 +166,7 @@ async function main() {
|
|
|
91
166
|
console.log(` ${pc.cyan('outlier')} Run the audit (the default — same as 'status')`);
|
|
92
167
|
console.log(` ${pc.cyan('outlier status')} Full audit: who wrote the code, what it cost, your limit`);
|
|
93
168
|
console.log(` ${pc.cyan('outlier status --save')} Save the audit to ./outlier-audit.txt`);
|
|
169
|
+
console.log(` ${pc.cyan('outlier --json')} Machine-readable audit (for agents, CI, swarms)`);
|
|
94
170
|
console.log(` ${pc.cyan('outlier authorship')} Just the AI-vs-human commit breakdown`);
|
|
95
171
|
console.log(` ${pc.cyan('outlier carbon')} Just the token spend, cache waste & carbon`);
|
|
96
172
|
console.log(` ${pc.cyan('outlier capabilities')} What tools & skills your agents can reach`);
|
|
@@ -301,12 +377,25 @@ Conservative Floor: ${color(nmPct + '%')}`,
|
|
|
301
377
|
let cachePct = '0';
|
|
302
378
|
let co2Str = '0.0kg';
|
|
303
379
|
let regionStr = 'Global Average';
|
|
380
|
+
let sourceLabel = 'no local AI logs found';
|
|
381
|
+
let noData = true;
|
|
304
382
|
if (carbon) {
|
|
305
383
|
if (carbon.totalTokens > 0) {
|
|
306
384
|
cachePct = ((carbon.cacheReadTokens / carbon.totalTokens) * 100).toFixed(1);
|
|
385
|
+
noData = false;
|
|
307
386
|
}
|
|
308
387
|
co2Str = `${carbon.localCo2Kg.toFixed(2)}kg CO2`;
|
|
309
388
|
regionStr = carbon.localRegion;
|
|
389
|
+
sourceLabel = carbon.sourceLabel;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// One-line agent-reach summary (full detail in `outlier capabilities`).
|
|
393
|
+
let reachStr = pc.dim('run: outlier capabilities');
|
|
394
|
+
if (capabilities) {
|
|
395
|
+
const rc = capabilities.blastRadius;
|
|
396
|
+
const col = rc === 'CRITICAL' || rc === 'HIGH' ? pc.red : rc === 'MEDIUM' ? pc.yellow : pc.green;
|
|
397
|
+
const risky = capabilities.mcps.filter((m: any) => ['money','exec','deploy','write-remote','write-local'].includes(m.reach)).length;
|
|
398
|
+
reachStr = `${col(pc.bold(rc))} · ${capabilities.mcps.length} tools` + (risky ? pc.dim(`, ${risky} can write/deploy`) : '');
|
|
310
399
|
}
|
|
311
400
|
|
|
312
401
|
// The thermal receipt below is the single canonical output for `status`.
|
|
@@ -370,10 +459,15 @@ Conservative Floor: ${color(nmPct + '%')}`,
|
|
|
370
459
|
${pc.dim('│')} Tokens used ${pc.bold(totalTokensStr)}
|
|
371
460
|
${pc.dim('│')} Est. spend ${pc.bold(estUsdStr)}
|
|
372
461
|
${pc.dim('│')} Re-used context ${cacheBar} ${pc.bold(cachePct + '%')}
|
|
373
|
-
${pc.dim('│')} Energy ${pc.bold(co2Str)} ${pc.dim(`(${regionStr} grid
|
|
462
|
+
${pc.dim('│')} Energy ${pc.bold(co2Str)} ${pc.dim(`(${regionStr} grid)`)}
|
|
463
|
+
${pc.dim('│')} ${pc.dim(`Source: ${sourceLabel}`)}
|
|
374
464
|
${pc.dim('│')}
|
|
375
465
|
${pc.dim('│')} ${cacheVerdict} — ${cacheText.split('\n').join('\n ' + pc.dim('│') + ' ')}
|
|
376
466
|
${pc.dim('├────────────────────────────────────────────────────────')}
|
|
467
|
+
${pc.dim('│')} ${pc.bold(pc.bgCyan(pc.black(' WHAT YOUR AGENTS CAN REACH ')))}
|
|
468
|
+
${pc.dim('│')} Blast radius ${reachStr}
|
|
469
|
+
${pc.dim('│')} ${pc.dim('Full map (deploy/push/write tools): outlier capabilities')}
|
|
470
|
+
${pc.dim('├────────────────────────────────────────────────────────')}
|
|
377
471
|
${pc.dim('│')} ${pc.bold(pc.bgYellow(pc.black(' YOUR LIMIT ')))}
|
|
378
472
|
${pc.dim('│')} AI cap ${pc.bold('70%')} ${pc.dim('· change with: outlier policy')}
|
|
379
473
|
${pc.dim('│')} Status ${policyStatus} ${pc.dim('·')} ${policyAction}
|
|
@@ -394,24 +488,42 @@ Conservative Floor: ${color(nmPct + '%')}`,
|
|
|
394
488
|
}
|
|
395
489
|
|
|
396
490
|
} else if (action === 'capabilities') {
|
|
397
|
-
s.start('
|
|
491
|
+
s.start('Mapping what your agents can reach...');
|
|
398
492
|
try {
|
|
399
493
|
const caps = await getCapabilitiesStats();
|
|
400
|
-
s.stop('
|
|
494
|
+
s.stop('Reach map complete');
|
|
495
|
+
|
|
496
|
+
const radiusColor = caps.blastRadius === 'CRITICAL' ? pc.red
|
|
497
|
+
: caps.blastRadius === 'HIGH' ? pc.red
|
|
498
|
+
: caps.blastRadius === 'MEDIUM' ? pc.yellow : pc.green;
|
|
499
|
+
|
|
500
|
+
// Group tools by reach so the risky ones stand out.
|
|
501
|
+
const order: string[] = ['money', 'exec', 'deploy', 'write-remote', 'write-local', 'data', 'network', 'model', 'read'];
|
|
502
|
+
const reachLabel: Record<string, string> = {
|
|
503
|
+
money: 'can move money', exec: 'can run shell', deploy: 'can deploy', 'write-remote': 'can push to repos',
|
|
504
|
+
'write-local': 'can write files', data: 'data stores', network: 'network', model: 'models', read: 'read-only',
|
|
505
|
+
};
|
|
506
|
+
const riskyReaches = new Set(['money', 'exec', 'deploy', 'write-remote', 'write-local']);
|
|
507
|
+
const toolLines = caps.mcps.length === 0 ? ' None detected'
|
|
508
|
+
: order.filter(r => caps.mcps.some(m => m.reach === r)).map(r => {
|
|
509
|
+
const names = caps.mcps.filter(m => m.reach === r).map(m => m.name).join(', ');
|
|
510
|
+
const tag = riskyReaches.has(r) ? pc.red(`[${reachLabel[r]}]`) : pc.dim(`[${reachLabel[r]}]`);
|
|
511
|
+
return ` ${tag} ${names}`;
|
|
512
|
+
}).join('\n');
|
|
401
513
|
|
|
402
514
|
note(
|
|
403
|
-
|
|
515
|
+
`${pc.bold('BLAST RADIUS:')} ${radiusColor(pc.bold(caps.blastRadius))} ${pc.dim('— if an agent or a prompt injection drives your tools')}
|
|
516
|
+
${caps.blastReasons.length ? caps.blastReasons.map(r => ` ${pc.red('•')} ${r}`).join('\n') : pc.green(' • read-only — limited reach')}
|
|
404
517
|
|
|
405
|
-
|
|
406
|
-
${
|
|
518
|
+
${pc.bold(`What your agents can reach (${caps.mcps.length} MCP tools):`)}
|
|
519
|
+
${toolLines}
|
|
407
520
|
|
|
408
|
-
|
|
409
|
-
${caps.
|
|
521
|
+
${pc.bold('Automation & agents:')}
|
|
522
|
+
Hooks that fire for you: ${caps.hooks.length ? pc.yellow(caps.hooks.join(', ')) : 'none'}
|
|
523
|
+
Sub-agents: ${caps.subagents} Skills: ${caps.skills.length} Orchestration policy: ${caps.hasOrchestration ? pc.green('yes') : pc.yellow('no')}
|
|
410
524
|
|
|
411
|
-
${pc.
|
|
412
|
-
|
|
413
|
-
${caps.skills.length > 5 ? pc.red('⚠ High Surface Area: Ensure strict authorship review is enabled.') : pc.green('✓ Low Surface Area: Risk contained.')}`,
|
|
414
|
-
'AI Capabilities Map'
|
|
525
|
+
${pc.dim('This is your attack surface. Fewer write/deploy tools per session = smaller blast radius.')}`,
|
|
526
|
+
'Agent Reach & Blast Radius'
|
|
415
527
|
);
|
|
416
528
|
} catch (e: any) {
|
|
417
529
|
s.stop('Audit failed');
|
package/src/emissions.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Offline, model-aware emissions engine.
|
|
2
|
+
//
|
|
3
|
+
// Local-first means NO network: no Electricity Maps / WattTime API calls. We bundle
|
|
4
|
+
// the coefficients and look them up. This is the same approach CodeCarbon uses for its
|
|
5
|
+
// offline tracker. Two inputs:
|
|
6
|
+
// 1. per-model energy (kWh per 1M output tokens) — output tokens dominate inference cost
|
|
7
|
+
// 2. grid carbon intensity (gCO2 per kWh) for the assumed region
|
|
8
|
+
//
|
|
9
|
+
// All numbers are estimates with wide uncertainty (inference energy varies ~4-20x in the
|
|
10
|
+
// literature). We expose the method so the UI can label provenance honestly. We never
|
|
11
|
+
// claim precision we don't have.
|
|
12
|
+
|
|
13
|
+
// Energy per 1M OUTPUT tokens, by model class (kWh). Anchor: the paper measured ~10 kWh
|
|
14
|
+
// across 15.1M output tokens on Opus-class (~0.66). Smaller/faster models use materially
|
|
15
|
+
// less. These are order-of-magnitude class estimates, not vendor figures.
|
|
16
|
+
const MODEL_ENERGY_KWH_PER_M_OUTPUT: Record<string, number> = {
|
|
17
|
+
'opus': 0.66, // large frontier (Claude Opus, GPT-4 class)
|
|
18
|
+
'sonnet': 0.30, // mid (Claude Sonnet, GPT-4o)
|
|
19
|
+
'haiku': 0.10, // small/fast (Claude Haiku, GPT-4o-mini)
|
|
20
|
+
'gpt-4': 0.55,
|
|
21
|
+
'gpt-4o': 0.30,
|
|
22
|
+
'gpt-5': 0.45,
|
|
23
|
+
'gemini': 0.35, // Gemini Pro class
|
|
24
|
+
'flash': 0.10, // Gemini Flash class
|
|
25
|
+
'local': 0.50, // self-hosted / unknown open weights
|
|
26
|
+
'default': 0.45, // unknown model -> conservative mid
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Map a raw model id (e.g. "claude-opus-4-8", "gpt-4o-mini", "gemini-2.5-flash") to a class.
|
|
30
|
+
export function modelClass(modelId: string): string {
|
|
31
|
+
const m = (modelId || '').toLowerCase();
|
|
32
|
+
if (m.includes('opus')) return 'opus';
|
|
33
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
34
|
+
if (m.includes('haiku')) return 'haiku';
|
|
35
|
+
if (m.includes('flash') || m.includes('mini')) return 'haiku';
|
|
36
|
+
if (m.includes('gpt-5')) return 'gpt-5';
|
|
37
|
+
if (m.includes('gpt-4o')) return 'gpt-4o';
|
|
38
|
+
if (m.includes('gpt-4')) return 'gpt-4';
|
|
39
|
+
if (m.includes('gemini')) return 'gemini';
|
|
40
|
+
if (m.includes('llama') || m.includes('qwen') || m.includes('mistral') || m.includes('local')) return 'local';
|
|
41
|
+
return 'default';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function energyKwhForModel(modelId: string, outputTokens: number): number {
|
|
45
|
+
const cls = modelClass(modelId);
|
|
46
|
+
const coeff = MODEL_ENERGY_KWH_PER_M_OUTPUT[cls] ?? 0.45;
|
|
47
|
+
return (outputTokens / 1_000_000) * coeff;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sum energy across a per-model output-token breakdown. Falls back to 'default' when the
|
|
51
|
+
// model is unknown. Returns total kWh.
|
|
52
|
+
export function energyKwhByModel(outputByModel: Record<string, number>): number {
|
|
53
|
+
let kwh = 0;
|
|
54
|
+
for (const [model, out] of Object.entries(outputByModel)) {
|
|
55
|
+
kwh += energyKwhForModel(model, out);
|
|
56
|
+
}
|
|
57
|
+
return kwh;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EmissionsResult {
|
|
61
|
+
energyKwh: number;
|
|
62
|
+
co2Kg: number;
|
|
63
|
+
gridFactor: number;
|
|
64
|
+
method: string; // human-readable provenance for the UI
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function co2FromEnergy(energyKwh: number, gridFactorGPerKwh: number): number {
|
|
68
|
+
return (energyKwh * gridFactorGPerKwh) / 1000; // kg
|
|
69
|
+
}
|