@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.
@@ -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.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
@@ -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
 
@@ -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: 450 };
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
- const energyKwh = (outputTokens / 1_000_000) * 0.662;
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, rough)`)}
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('Auditing AI surface area (MCPs, Skills, Orchestrators)...');
491
+ s.start('Mapping what your agents can reach...');
398
492
  try {
399
493
  const caps = await getCapabilitiesStats();
400
- s.stop('Capabilities Scan Complete');
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
- `Orchestration Policy: ${caps.hasOrchestration ? pc.green('Detected (AGENTS.md)') : pc.yellow('None')}
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
- Active Skills (${caps.skills.length}):
406
- ${caps.skills.length > 0 ? pc.cyan(caps.skills.map(s => ` • ${s}`).join('\n')) : ' None'}
518
+ ${pc.bold(`What your agents can reach (${caps.mcps.length} MCP tools):`)}
519
+ ${toolLines}
407
520
 
408
- Active MCP Servers (${caps.mcps.length}):
409
- ${caps.mcps.length > 0 ? pc.magenta(caps.mcps.map(m => ` • ${m}`).join('\n')) : ' None'}
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.bold('Governance Assessment:')}
412
- This repository provides agents with ${caps.mcps.length} toolsets and ${caps.skills.length} skills.
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');
@@ -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
+ }