@hone-ai/cli 1.4.0 → 1.5.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/hone-cli.js CHANGED
@@ -1299,6 +1299,138 @@ program
1299
1299
  console.log('Verification complete.');
1300
1300
  });
1301
1301
 
1302
+ // ── USAGE command (#251) ─────────────────────────────────────────────────────
1303
+ program
1304
+ .command('usage')
1305
+ .description('Show current month token usage and budget status')
1306
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
1307
+ .action(async (opts) => {
1308
+ const config = getConfig();
1309
+ const client = api(config);
1310
+
1311
+ try {
1312
+ const { data } = await client.get('/usage/me');
1313
+
1314
+ if (opts.format === 'json') {
1315
+ console.log(JSON.stringify(data, null, 2));
1316
+ return;
1317
+ }
1318
+
1319
+ console.log('');
1320
+ console.log('Hone AI — Token Usage');
1321
+ console.log('================================');
1322
+ console.log(`Org: ${data.org}`);
1323
+ console.log(`Month: ${data.current_month}`);
1324
+ console.log(`Tokens used: ${data.used_tokens.toLocaleString()}`);
1325
+ console.log(`Cost (est): $${data.used_cost_usd.toFixed(2)}`);
1326
+
1327
+ if (data.monthly_budget != null) {
1328
+ console.log(`Budget: ${data.monthly_budget.toLocaleString()} tokens`);
1329
+ console.log(`Used: ${data.budget_pct}%`);
1330
+ console.log(`Remaining: ${data.remaining.toLocaleString()}`);
1331
+ if (data.exceeded) {
1332
+ console.log(`Status: EXCEEDED — resets ${data.resetsAt.split('T')[0]}`);
1333
+ } else if (data.budget_pct >= data.budget_alert_pct) {
1334
+ console.log(`Status: WARNING — approaching budget (${data.budget_pct}% of ${data.budget_alert_pct}% alert threshold)`);
1335
+ } else {
1336
+ console.log(`Status: OK`);
1337
+ }
1338
+ } else {
1339
+ console.log(`Budget: unlimited`);
1340
+ }
1341
+
1342
+ if (data.by_job && data.by_job.length > 0) {
1343
+ console.log('');
1344
+ console.log('Recent derive jobs:');
1345
+ for (const j of data.by_job.slice(0, 10)) {
1346
+ console.log(` ${j.date} | ${j.job_id} | ${j.tokens.toLocaleString()} tokens | $${Number(j.cost_usd).toFixed(2)}`);
1347
+ }
1348
+ }
1349
+
1350
+ console.log('');
1351
+ } catch (e) {
1352
+ if (e.response?.status === 401) {
1353
+ console.error('Not authenticated. Run: hone init');
1354
+ } else {
1355
+ console.error(`Failed to fetch usage: ${e.message}`);
1356
+ }
1357
+ process.exit(1);
1358
+ }
1359
+ });
1360
+
1361
+ // ── ADMIN-USAGE command ──────────────────────────────────────────────────────
1362
+ program
1363
+ .command('admin-usage')
1364
+ .description('Admin dashboard: cross-org token usage, budgets, alerts, trends')
1365
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
1366
+ .action(async (opts) => {
1367
+ const rc = readRc();
1368
+ const adminKey = process.env.HONE_ADMIN_KEY || rc.admin_key;
1369
+ const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
1370
+
1371
+ if (!adminKey) {
1372
+ console.error('Error: Admin key not found.');
1373
+ console.error('Set HONE_ADMIN_KEY env var, or add "admin_key" to ~/.honerc');
1374
+ process.exit(1);
1375
+ }
1376
+
1377
+ try {
1378
+ const { data } = await axios.get(`${apiUrl}/admin/usage`, {
1379
+ headers: { 'x-admin-key': adminKey, 'User-Agent': `@hone-ai/cli/${pkg.version}` },
1380
+ timeout: 15000,
1381
+ });
1382
+
1383
+ if (opts.format === 'json') {
1384
+ console.log(JSON.stringify(data, null, 2));
1385
+ return;
1386
+ }
1387
+
1388
+ console.log('');
1389
+ console.log('Hone AI — Admin Dashboard');
1390
+ console.log('================================');
1391
+ console.log(`Month: ${data.current_month}`);
1392
+ console.log(`Total orgs: ${data.platform_totals.total_orgs} (${data.platform_totals.active_orgs} active)`);
1393
+ console.log(`Total tokens: ${data.platform_totals.total_tokens.toLocaleString()}`);
1394
+ console.log(`Total cost: $${data.platform_totals.total_cost_usd.toFixed(2)}`);
1395
+ console.log(`Total calls: ${data.platform_totals.total_calls}`);
1396
+
1397
+ if (data.alerts.length > 0) {
1398
+ console.log('');
1399
+ console.log('Alerts:');
1400
+ for (const a of data.alerts) {
1401
+ const icon = a.level === 'critical' ? '!!' : a.level === 'warning' ? ' !' : ' i';
1402
+ console.log(` [${icon}] ${a.org}: ${a.message}`);
1403
+ }
1404
+ }
1405
+
1406
+ if (data.orgs.length > 0) {
1407
+ console.log('');
1408
+ console.log('Per-org usage:');
1409
+ console.log(' Org Tier Tokens Cost Budget% Trend Fails');
1410
+ console.log(' --- ---- ------ ---- ------- ----- -----');
1411
+ for (const o of data.orgs) {
1412
+ const name = o.org.padEnd(20).slice(0, 20);
1413
+ const tier = (o.tier || '').padEnd(10).slice(0, 10);
1414
+ const tokens = String(o.total_tokens.toLocaleString()).padStart(12);
1415
+ const cost = ('$' + o.total_cost_usd.toFixed(2)).padStart(8);
1416
+ const pct = o.monthly_budget != null ? (o.budget_pct + '%').padStart(8) : ' n/a';
1417
+ const trend = o.trend_pct != null ? ((o.trend_pct >= 0 ? '+' : '') + o.trend_pct + '%').padStart(6) : ' n/a';
1418
+ const fails = String(o.failed_jobs).padStart(5);
1419
+ console.log(` ${name} ${tier} ${tokens} ${cost} ${pct} ${trend} ${fails}`);
1420
+ }
1421
+ }
1422
+
1423
+ console.log('');
1424
+ } catch (e) {
1425
+ if (e.response?.status === 401) {
1426
+ console.error('Invalid admin key. Check HONE_ADMIN_KEY or ~/.honerc admin_key.');
1427
+ } else {
1428
+ console.error(`Failed to fetch admin dashboard: ${e.message}`);
1429
+ }
1430
+ process.exit(1);
1431
+ }
1432
+ });
1433
+
1302
1434
  // ── SYNC command ──────────────────────────────────────────────────────────────
1303
1435
  program
1304
1436
  .command('sync')
@@ -3997,6 +4129,54 @@ program
3997
4129
  }, null, 2));
3998
4130
  });
3999
4131
 
4132
+ // ── HC-019d: Agent Eval Runner ────────────────────────────────────────────────
4133
+ program
4134
+ .command('eval')
4135
+ .description('Run eval scenarios against agent prompts (deterministic, zero LLM tokens)')
4136
+ .option('--agent <name>', 'Run evals for a specific agent only')
4137
+ .option('--tag <tag>', 'Filter scenarios by tag (e.g., smoke, regression)')
4138
+ .option('--scenario <id>', 'Run a single scenario by ID')
4139
+ .option('--format <fmt>', 'Output format: pretty | json', 'pretty')
4140
+ .option('--evals-dir <path>', 'Override eval scenarios directory')
4141
+ .option('--fail-fast', 'Stop on first failure')
4142
+ .action(async (opts) => {
4143
+ const path = require('path');
4144
+ const fs = require('fs');
4145
+ const yaml = require('js-yaml');
4146
+ const { loadScenarios, runAllScenarios, formatResults } = require('./lib/eval-runner');
4147
+
4148
+ const evalDir = opts.evalsDir || path.resolve(__dirname, '..', 'evals');
4149
+ if (!fs.existsSync(evalDir)) {
4150
+ console.error(`Eval directory not found: ${evalDir}`);
4151
+ process.exit(1);
4152
+ }
4153
+
4154
+ // Load agent prompts from seed-agent-prompts.js
4155
+ const seedPath = path.resolve(__dirname, '..', 'scripts', 'seed-agent-prompts.js');
4156
+ const { AGENT_PROMPTS } = require(seedPath);
4157
+
4158
+ const scenarios = loadScenarios({
4159
+ evalDir,
4160
+ agent: opts.agent,
4161
+ tag: opts.tag,
4162
+ scenarioId: opts.scenario,
4163
+ readFile: (p) => fs.readFileSync(p, 'utf8'),
4164
+ listDir: (p) => fs.readdirSync(p),
4165
+ isDir: (p) => fs.statSync(p).isDirectory(),
4166
+ parseYaml: (text) => yaml.load(text),
4167
+ });
4168
+
4169
+ if (scenarios.length === 0) {
4170
+ console.log('No eval scenarios found matching filters.');
4171
+ process.exit(0);
4172
+ }
4173
+
4174
+ const results = runAllScenarios(scenarios, AGENT_PROMPTS, { failFast: opts.failFast });
4175
+ console.log(formatResults(results, opts.format));
4176
+
4177
+ process.exit(results.failed + results.errors > 0 ? 1 : 0);
4178
+ });
4179
+
4000
4180
  // ── CLI setup ─────────────────────────────────────────────────────────────────
4001
4181
  program
4002
4182
  .name('hone')
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ /**
3
+ * eval-graders.js — HC-019d deterministic grading checks for agent eval scenarios.
4
+ *
5
+ * Each grader is a pure function: (text, config) => { passed, detail }
6
+ * Zero LLM tokens — string/regex/structural checks only.
7
+ */
8
+
9
+ function contains(text, { value, case_insensitive = false }) {
10
+ const haystack = case_insensitive ? text.toLowerCase() : text;
11
+ const needle = case_insensitive ? value.toLowerCase() : value;
12
+ const found = haystack.includes(needle);
13
+ return { passed: found, detail: found ? `found "${value}"` : `"${value}" NOT FOUND` };
14
+ }
15
+
16
+ function notContains(text, { value, case_insensitive = false }) {
17
+ const haystack = case_insensitive ? text.toLowerCase() : text;
18
+ const needle = case_insensitive ? value.toLowerCase() : value;
19
+ const found = haystack.includes(needle);
20
+ return { passed: !found, detail: found ? `"${value}" FOUND (should be absent)` : `"${value}" correctly absent` };
21
+ }
22
+
23
+ function regex(text, { pattern, flags = '' }) {
24
+ try {
25
+ const re = new RegExp(pattern, flags);
26
+ const match = re.test(text);
27
+ return { passed: match, detail: match ? `matched /${pattern}/` : `/${pattern}/ did NOT match` };
28
+ } catch (e) {
29
+ return { passed: false, detail: `invalid regex /${pattern}/: ${e.message}` };
30
+ }
31
+ }
32
+
33
+ function sectionExists(text, { heading }) {
34
+ const re = new RegExp(`^#{1,4}\\s+${escapeRegex(heading)}`, 'mi');
35
+ const found = re.test(text);
36
+ return { passed: found, detail: found ? `section "${heading}" found` : `section "${heading}" NOT FOUND` };
37
+ }
38
+
39
+ function wordCount(text, { min = 0, max = Infinity }) {
40
+ const count = text.split(/\s+/).filter(Boolean).length;
41
+ const passed = count >= min && count <= max;
42
+ return { passed, detail: `${count} words (expected ${min}-${max === Infinity ? '∞' : max})` };
43
+ }
44
+
45
+ function jsonValid(text) {
46
+ try {
47
+ JSON.parse(text);
48
+ return { passed: true, detail: 'valid JSON' };
49
+ } catch (e) {
50
+ return { passed: false, detail: `invalid JSON: ${e.message}` };
51
+ }
52
+ }
53
+
54
+ function yamlValid(text) {
55
+ try {
56
+ require('js-yaml').load(text);
57
+ return { passed: true, detail: 'valid YAML' };
58
+ } catch (e) {
59
+ return { passed: false, detail: `invalid YAML: ${e.message}` };
60
+ }
61
+ }
62
+
63
+ function lineCount(text, { min = 0, max = Infinity }) {
64
+ const count = text.split('\n').length;
65
+ const passed = count >= min && count <= max;
66
+ return { passed, detail: `${count} lines (expected ${min}-${max === Infinity ? '∞' : max})` };
67
+ }
68
+
69
+ // ── Dispatch ─────────────────────────────────────────────────────
70
+
71
+ const GRADERS = {
72
+ contains,
73
+ not_contains: notContains,
74
+ regex,
75
+ section_exists: sectionExists,
76
+ word_count: wordCount,
77
+ json_valid: jsonValid,
78
+ yaml_valid: yamlValid,
79
+ line_count: lineCount,
80
+ };
81
+
82
+ /**
83
+ * Run a single grading check.
84
+ * @param {string} text — the text to grade (prompt content or LLM output)
85
+ * @param {{ type: string, ...config }} check
86
+ * @returns {{ type, passed, detail }}
87
+ */
88
+ function runCheck(text, check) {
89
+ const grader = GRADERS[check.type];
90
+ if (!grader) return { type: check.type, passed: false, detail: `unknown grader type "${check.type}"` };
91
+ const result = grader(text, check);
92
+ return { type: check.type, ...result };
93
+ }
94
+
95
+ function escapeRegex(str) {
96
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ }
98
+
99
+ module.exports = { runCheck, GRADERS, contains, notContains, regex, sectionExists, wordCount, jsonValid, yamlValid, lineCount };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+ /**
3
+ * eval-runner.js — HC-019d eval runner for agent prompt quality.
4
+ *
5
+ * Loads eval scenarios from evals/<agent>/*.eval.yml, runs deterministic
6
+ * grading checks against agent prompt text (zero LLM tokens).
7
+ *
8
+ * Pure helper with injected I/O (readFile, listDir).
9
+ */
10
+ const { runCheck } = require('./eval-graders');
11
+
12
+ /**
13
+ * Load eval scenarios from the evals directory.
14
+ * @param {object} opts
15
+ * @param {string} opts.evalDir — path to evals/ directory
16
+ * @param {string} [opts.agent] — filter by agent name
17
+ * @param {string} [opts.tag] — filter by tag
18
+ * @param {string} [opts.scenarioId] — run single scenario by ID
19
+ * @param {(path: string) => string} opts.readFile
20
+ * @param {(path: string) => string[]} opts.listDir
21
+ * @param {(path: string) => boolean} opts.isDir
22
+ * @returns {Array<object>} scenarios
23
+ */
24
+ function loadScenarios({ evalDir, agent, tag, scenarioId, readFile, listDir, isDir, parseYaml }) {
25
+ const scenarios = [];
26
+ const seenIds = new Set();
27
+ const agentDirs = listDir(evalDir).filter(d => !d.startsWith('_'));
28
+
29
+ for (const dir of agentDirs) {
30
+ if (agent && dir !== agent) continue;
31
+ const dirPath = `${evalDir}/${dir}`;
32
+ if (!isDir(dirPath)) continue;
33
+
34
+ const files = listDir(dirPath).filter(f => f.endsWith('.eval.yml'));
35
+ for (const file of files) {
36
+ try {
37
+ const content = readFile(`${dirPath}/${file}`);
38
+ const scenario = parseYaml(content);
39
+ scenario.evalAgent = dir;
40
+ scenario.evalFile = file;
41
+
42
+ if (scenarioId && scenario.id !== scenarioId) continue;
43
+ if (tag && !(scenario.tags || []).includes(tag)) continue;
44
+
45
+ if (scenario.id && seenIds.has(scenario.id)) {
46
+ scenarios.push({
47
+ id: scenario.id, evalAgent: dir, evalFile: file,
48
+ loadError: `duplicate scenario ID "${scenario.id}" (first seen in another file)`,
49
+ });
50
+ continue;
51
+ }
52
+ if (scenario.id) seenIds.add(scenario.id);
53
+
54
+ scenarios.push(scenario);
55
+ } catch (e) {
56
+ scenarios.push({
57
+ id: file, evalAgent: dir, evalFile: file,
58
+ loadError: e.message,
59
+ });
60
+ }
61
+ }
62
+ }
63
+
64
+ return scenarios;
65
+ }
66
+
67
+ /**
68
+ * Run grading checks for a single scenario against prompt text.
69
+ * @param {object} scenario — parsed eval scenario
70
+ * @param {string} promptText — agent prompt content
71
+ * @returns {{ id, agent, name, result, checks, failures }}
72
+ */
73
+ function runScenario(scenario, promptText) {
74
+ if (scenario.loadError) {
75
+ return {
76
+ id: scenario.id,
77
+ agent: scenario.evalAgent,
78
+ name: scenario.evalFile,
79
+ result: 'error',
80
+ checks: 0,
81
+ checks_passed: 0,
82
+ failures: [{ type: 'load', passed: false, detail: scenario.loadError }],
83
+ };
84
+ }
85
+
86
+ const checks = scenario.grading?.checks || [];
87
+ const results = checks.map(check => runCheck(promptText, check));
88
+ const passed = results.filter(r => r.passed).length;
89
+ const failures = results.filter(r => !r.passed);
90
+
91
+ return {
92
+ id: scenario.id,
93
+ agent: scenario.evalAgent,
94
+ name: scenario.name || scenario.id,
95
+ result: failures.length === 0 ? 'pass' : 'fail',
96
+ checks: checks.length,
97
+ checks_passed: passed,
98
+ failures,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Run all scenarios against their agent prompts.
104
+ * @param {Array<object>} scenarios
105
+ * @param {object} agentPrompts — { agentName: promptText }
106
+ * @param {object} [opts]
107
+ * @param {boolean} [opts.failFast] — stop on first failure
108
+ * @returns {{ total, passed, failed, errors, scenarios: Array }}
109
+ */
110
+ function runAllScenarios(scenarios, agentPrompts, opts = {}) {
111
+ const results = [];
112
+
113
+ for (const scenario of scenarios) {
114
+ const agentName = scenario.evalAgent || scenario.agent;
115
+ const promptText = agentPrompts[agentName];
116
+
117
+ if (!promptText && !scenario.loadError) {
118
+ results.push({
119
+ id: scenario.id,
120
+ agent: agentName,
121
+ name: scenario.name || scenario.id,
122
+ result: 'error',
123
+ checks: 0,
124
+ checks_passed: 0,
125
+ failures: [{ type: 'missing_prompt', passed: false, detail: `agent "${agentName}" not found in AGENT_PROMPTS` }],
126
+ });
127
+ continue;
128
+ }
129
+
130
+ const result = runScenario(scenario, promptText || '');
131
+ results.push(result);
132
+
133
+ if (opts.failFast && result.result !== 'pass') break;
134
+ }
135
+
136
+ return {
137
+ total: results.length,
138
+ passed: results.filter(r => r.result === 'pass').length,
139
+ failed: results.filter(r => r.result === 'fail').length,
140
+ errors: results.filter(r => r.result === 'error').length,
141
+ scenarios: results,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Format results for display.
147
+ * @param {object} results — from runAllScenarios
148
+ * @param {'pretty'|'json'|'ci'} format
149
+ * @returns {string}
150
+ */
151
+ function formatResults(results, format = 'pretty') {
152
+ if (format === 'json') return JSON.stringify(results, null, 2);
153
+
154
+ const lines = ['', 'Hone AI — Agent Eval Runner', '================================', ''];
155
+
156
+ // Group by agent
157
+ const byAgent = {};
158
+ for (const s of results.scenarios) {
159
+ if (!byAgent[s.agent]) byAgent[s.agent] = [];
160
+ byAgent[s.agent].push(s);
161
+ }
162
+
163
+ for (const [agent, scenarios] of Object.entries(byAgent)) {
164
+ lines.push(`${agent} (${scenarios.length} scenarios)`);
165
+ for (const s of scenarios) {
166
+ const icon = s.result === 'pass' ? 'PASS' : s.result === 'fail' ? 'FAIL' : 'ERR ';
167
+ lines.push(` [${icon}] ${s.id}: ${s.name} (${s.checks_passed}/${s.checks} checks)`);
168
+ for (const f of s.failures) {
169
+ lines.push(` x ${f.type}: ${f.detail}`);
170
+ }
171
+ }
172
+ lines.push('');
173
+ }
174
+
175
+ lines.push('----------------------------------');
176
+ lines.push(`Summary: ${results.total} scenarios | ${results.passed} passed | ${results.failed} failed | ${results.errors} errors`);
177
+ lines.push(`Exit code: ${results.failed + results.errors > 0 ? 1 : 0}`);
178
+ lines.push('');
179
+
180
+ return lines.join('\n');
181
+ }
182
+
183
+ module.exports = { loadScenarios, runScenario, runAllScenarios, formatResults };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {