165ff3a859
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.5 KiB
JavaScript
104 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Stop-hook — cost-daily.json updater.
|
|
*
|
|
* On each Stop event, aggregates today's episodes from the current month's
|
|
* JSONL and writes/updates `~/.claude/runtime/cost-daily.json`.
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §12.
|
|
* Closes brain-retro #9 Candidate 4 («cost-daily.json пуст — cost-tracker не пишет»).
|
|
*
|
|
* Fail-quiet: all I/O is try/catch; never crashes the Stop-hook pipeline.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { homedir } from 'os';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
import { aggregateDay } from './cost-aggregator.mjs';
|
|
import { PRICING } from './cost-pricing.mjs';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
export function todayISO(now = new Date()) {
|
|
const y = now.getUTCFullYear();
|
|
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getUTCDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
export function currentMonthFile(now = new Date(), repoRoot = process.cwd(), stateDir = 'docs/observer') {
|
|
const y = now.getUTCFullYear();
|
|
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
return join(repoRoot, stateDir, `episodes-${y}-${m}.jsonl`);
|
|
}
|
|
|
|
export function readEpisodesJsonl(path) {
|
|
if (!path || !existsSync(path)) return [];
|
|
try {
|
|
const raw = readFileSync(path, 'utf-8');
|
|
const out = [];
|
|
for (const line of raw.split('\n')) {
|
|
const s = line.trim();
|
|
if (!s) continue;
|
|
try { out.push(JSON.parse(s)); } catch { /* skip malformed */ }
|
|
}
|
|
return out;
|
|
} catch { return []; }
|
|
}
|
|
|
|
export function loadCostDaily(path) {
|
|
if (!path || !existsSync(path)) return {};
|
|
try {
|
|
const raw = readFileSync(path, 'utf-8');
|
|
const j = JSON.parse(raw);
|
|
return (j && typeof j === 'object') ? j : {};
|
|
} catch { return {}; }
|
|
}
|
|
|
|
export function runUpdate({ episodes, dateISO, existing, pricing }) {
|
|
const day = aggregateDay(episodes, dateISO, pricing);
|
|
return { ...existing, [dateISO]: day };
|
|
}
|
|
|
|
function writeCostDaily(path, data) {
|
|
try {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, JSON.stringify(data, null, 2));
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const repoRoot = process.cwd();
|
|
const now = new Date();
|
|
const date = todayISO(now);
|
|
let stateDir = 'docs/observer';
|
|
try {
|
|
const { loadConfig, resolveStateDir } = await import('./brain-config.mjs');
|
|
({ stateDir } = resolveStateDir(loadConfig(repoRoot).state_dir));
|
|
} catch (e) {
|
|
console.warn('[cost-stop] brain-config недоступен, fallback docs/observer:', e && e.message);
|
|
}
|
|
const monthFile = currentMonthFile(now, repoRoot, stateDir);
|
|
const episodes = readEpisodesJsonl(monthFile);
|
|
const costDailyPath = join(homedir(), '.claude', 'runtime', 'cost-daily.json');
|
|
const existing = loadCostDaily(costDailyPath);
|
|
const updated = runUpdate({ episodes, dateISO: date, existing, pricing: PRICING });
|
|
writeCostDaily(costDailyPath, updated);
|
|
// Stop-hook contract: print empty JSON to stdout on success, exit 0.
|
|
process.stdout.write('{}');
|
|
process.exit(0);
|
|
} catch {
|
|
// Fail-quiet: never crash the Stop pipeline.
|
|
process.stdout.write('{}');
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/cost-stop-hook.mjs');
|
|
if (isCli) main();
|