burnwatch 0.10.1 → 0.12.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/CHANGELOG.md CHANGED
@@ -5,6 +5,65 @@ All notable changes to burnwatch will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.0] - 2026-03-25
9
+
10
+ ### Added
11
+
12
+ - **10 new services in registry** (14 → 24): Firebase, Cloudflare, Neon Postgres, MongoDB Atlas, Twilio, SendGrid, Sentry, Clerk, DeepSeek, Replicate. All with plans, pricing, gotchas, detection patterns, and cost-impact SDK call patterns.
13
+ - **EST tier activated**: BLIND services with accumulated cost-impact data from file change analysis are now upgraded to EST tier in the spend brief. Uses the midpoint of projected cost range. The EST tier was defined since v0.1.0 but never produced — now it is.
14
+ - **Connector and probe tests**: 20 new tests with mocked HTTP covering all 6 billing connectors and 9 service probes. Total test count: 40 → 60.
15
+ - **Registry cache invalidation**: The registry cache now tracks file mtime and auto-invalidates when `registry.json` is modified. Prevents stale data in long-running processes (MCP server).
16
+ - **Community contribution guide**: Rewrote `CONTRIBUTING.md` with tiered contribution path — registry-only (5 min, JSON), billing connector (30 min, TypeScript), and probe (plan auto-detection). Documented `.burnwatch/registry.json` project-local override for custom services.
17
+
18
+ ### Fixed
19
+
20
+ - **Registry `apiTier` reconciled with actual connectors**: `browserbase` changed from `"est"` to `"live"` (has a working connector). `stripe` changed from `"live"` to `"calc"` (no billing connector — only has a probe for balance checking, not Stripe's fees to you).
21
+ - **MCP server version**: Was hardcoded at `"0.1.2"` since initial release. Now reads dynamically from `package.json`.
22
+
23
+ ### Changed
24
+
25
+ - Cost-impact SDK call patterns added for Firebase, Twilio, SendGrid, MongoDB, Clerk, and Replicate.
26
+
27
+ ## [0.11.0] - 2026-03-25
28
+
29
+ ### Fixed
30
+
31
+ - **CALC services showed ~$0.00 in session brief**: Session-start hook was passing raw `planCost` as spend instead of projecting based on day of month. Now correctly calculates `(planCost / daysInMonth) * dayOfMonth`.
32
+ - **envKeysFound always empty**: `.env` file scanning only ran conditionally. Now always scans `.env`, `.env.local`, `.env.development` on disk and populates `envKeysFound` regardless of global key status.
33
+ - **Box-drawing characters render poorly in agent/IDE contexts**: Added `formatBriefMarkdown()` using markdown tables. Session-start hook now uses markdown format.
34
+ - **Excluded services showing as BLIND in brief**: Added `filter(s => s.tier !== "excluded")` to brief display.
35
+ - **`configure --key` silent when LIVE not possible**: Now returns `tierNote` explaining why a key didn't enable LIVE tracking (e.g., no billing connector for that service).
36
+
37
+ ### Added
38
+
39
+ - **`burnwatch reset` command**: Removes `.burnwatch/`, skills from `.claude/skills/`, and hooks from `.claude/settings.json`. Preserves global API keys in `~/.config/burnwatch/`.
40
+ - **Interview JSON enrichment**: `hasConnector`, `canGoLive`, `envKeysFound[]`, `suggestedAction`, and `instructions{}` fields guide agent behavior during the interview.
41
+
42
+ ## [0.10.0] - 2026-03-25
43
+
44
+ ### Added
45
+
46
+ - **Supabase billing connector**: Uses Management API via Personal Access Token (PAT). Detects plan tier (free/pro/team), maps to monthly cost, and checks usage endpoint for overages.
47
+ - **Browserbase billing connector**: Uses projects/usage API to track session count and browser minutes. Estimates spend from minutes × rate.
48
+
49
+ ### Changed
50
+
51
+ - **Interview skill rewritten**: Complete rewrite of `/burnwatch-interview` SKILL.md. Now leads with API key discovery from `.env` files BEFORE asking questions. Enforces one-service-at-a-time pacing. Includes smart inference (e.g., `gpt-4o-mini` → low budget suggestion). Budget philosophy: LIVE → track actual + alert threshold; CALC → budget = plan cost; BLIND → safety net budget.
52
+ - **BLIND services no longer show $0.00**: Shows "—" instead of "$0.00" to avoid false confidence. Status label says "needs API key" instead of "no budget".
53
+ - **Brief footer says "No billing data: N"** instead of "Untracked: N" — because services ARE configured with budgets, they just lack billing API access.
54
+ - **`pollService` returns explicit error context** when LIVE fails instead of silently falling through to CALC/BLIND.
55
+
56
+ ## [0.9.0] - 2026-03-25
57
+
58
+ ### Added
59
+
60
+ - **Skill auto-installation during init**: `registerHooks()` now copies `/burnwatch-interview`, `/setup-burnwatch`, and `/spend` skills to `.claude/skills/` so agents can discover them.
61
+ - **Non-TTY init suggests `/burnwatch-interview`**: When running in an agent context, init output recommends the conversational interview skill instead of manual CLI commands.
62
+
63
+ ### Changed
64
+
65
+ - **Terminology update**: All references to "vibe coding" changed to "AI-assisted development" across package description, README, llms.txt, and source files.
66
+
8
67
  ## [0.8.0] - 2026-03-25
9
68
 
10
69
  ### Added
@@ -133,6 +192,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
133
192
  - Snapshot system for delta computation across sessions
134
193
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
135
194
 
195
+ [0.12.0]: https://github.com/RaleighSF/burnwatch/compare/v0.11.0...v0.12.0
196
+ [0.11.0]: https://github.com/RaleighSF/burnwatch/compare/v0.10.0...v0.11.0
197
+ [0.10.0]: https://github.com/RaleighSF/burnwatch/compare/v0.9.0...v0.10.0
198
+ [0.9.0]: https://github.com/RaleighSF/burnwatch/compare/v0.8.0...v0.9.0
136
199
  [0.8.0]: https://github.com/RaleighSF/burnwatch/compare/v0.7.0...v0.8.0
137
200
  [0.7.0]: https://github.com/RaleighSF/burnwatch/compare/v0.6.0...v0.7.0
138
201
  [0.6.0]: https://github.com/RaleighSF/burnwatch/compare/v0.5.2...v0.6.0
package/dist/cli.js CHANGED
@@ -363,9 +363,22 @@ import * as path2 from "path";
363
363
  import * as url from "url";
364
364
  var __dirname = path2.dirname(url.fileURLToPath(import.meta.url));
365
365
  var cachedRegistry = null;
366
+ var cachedRegistryPath = null;
367
+ var cachedRegistryMtime = null;
366
368
  function loadRegistry(projectRoot) {
369
+ if (cachedRegistry && cachedRegistryPath && cachedRegistryMtime !== null) {
370
+ try {
371
+ const stat = fs2.statSync(cachedRegistryPath);
372
+ if (stat.mtimeMs !== cachedRegistryMtime) {
373
+ cachedRegistry = null;
374
+ }
375
+ } catch {
376
+ cachedRegistry = null;
377
+ }
378
+ }
367
379
  if (cachedRegistry) return cachedRegistry;
368
380
  const registry = /* @__PURE__ */ new Map();
381
+ let resolvedPath = null;
369
382
  const candidates = [
370
383
  path2.resolve(__dirname, "../../registry.json"),
371
384
  // from src/core/
@@ -375,6 +388,7 @@ function loadRegistry(projectRoot) {
375
388
  for (const candidate of candidates) {
376
389
  if (fs2.existsSync(candidate)) {
377
390
  loadRegistryFile(candidate, registry);
391
+ resolvedPath = candidate;
378
392
  break;
379
393
  }
380
394
  }
@@ -382,9 +396,19 @@ function loadRegistry(projectRoot) {
382
396
  const localPath = path2.join(projectRoot, ".burnwatch", "registry.json");
383
397
  if (fs2.existsSync(localPath)) {
384
398
  loadRegistryFile(localPath, registry);
399
+ resolvedPath = localPath;
385
400
  }
386
401
  }
387
402
  cachedRegistry = registry;
403
+ if (resolvedPath) {
404
+ try {
405
+ cachedRegistryPath = resolvedPath;
406
+ cachedRegistryMtime = fs2.statSync(resolvedPath).mtimeMs;
407
+ } catch {
408
+ cachedRegistryPath = null;
409
+ cachedRegistryMtime = null;
410
+ }
411
+ }
388
412
  return registry;
389
413
  }
390
414
  function loadRegistryFile(filePath, registry) {
@@ -925,9 +949,37 @@ async function pollService(tracked) {
925
949
  serviceConfig
926
950
  );
927
951
  if (!result.error) return result;
928
- } catch {
952
+ return {
953
+ serviceId: tracked.serviceId,
954
+ spend: tracked.planCost ?? 0,
955
+ isEstimate: true,
956
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
957
+ error: `LIVE failed (${result.error}) \u2014 showing ${tracked.planCost !== void 0 ? "CALC" : "BLIND"} fallback`
958
+ };
959
+ } catch (err) {
960
+ return {
961
+ serviceId: tracked.serviceId,
962
+ spend: tracked.planCost ?? 0,
963
+ isEstimate: true,
964
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
965
+ error: `LIVE failed (${err instanceof Error ? err.message : "unknown"}) \u2014 showing ${tracked.planCost !== void 0 ? "CALC" : "BLIND"} fallback`
966
+ };
929
967
  }
930
968
  }
969
+ if (connector && tracked.hasApiKey && !serviceConfig?.apiKey) {
970
+ const projectedSpend = tracked.planCost !== void 0 ? (() => {
971
+ const now = /* @__PURE__ */ new Date();
972
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
973
+ return tracked.planCost / daysInMonth * now.getDate();
974
+ })() : 0;
975
+ return {
976
+ serviceId: tracked.serviceId,
977
+ spend: projectedSpend,
978
+ isEstimate: true,
979
+ tier: tracked.planCost !== void 0 ? "calc" : "blind",
980
+ error: "API key marked as configured but not found in ~/.config/burnwatch/ \u2014 re-run configure with --key"
981
+ };
982
+ }
931
983
  if (tracked.planCost !== void 0) {
932
984
  const now = /* @__PURE__ */ new Date();
933
985
  const daysInMonth = new Date(
@@ -999,7 +1051,7 @@ function formatBrief(brief) {
999
1051
  formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
1000
1052
  );
1001
1053
  lines.push(`\u2551 ${hrSingle} \u2551`);
1002
- for (const svc of brief.services) {
1054
+ for (const svc of brief.services.filter((s) => s.tier !== "excluded")) {
1003
1055
  const spendStr = svc.tier === "blind" && svc.spend === 0 ? "\u2014" : svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
1004
1056
  const badge = CONFIDENCE_BADGES[svc.tier];
1005
1057
  const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
@@ -1568,6 +1620,9 @@ async function main() {
1568
1620
  case "configure":
1569
1621
  await cmdConfigure();
1570
1622
  break;
1623
+ case "reset":
1624
+ cmdReset();
1625
+ break;
1571
1626
  case "help":
1572
1627
  case "--help":
1573
1628
  case "-h":
@@ -1693,37 +1748,32 @@ async function cmdInterview() {
1693
1748
  let keySource = null;
1694
1749
  const envKeysFound = [];
1695
1750
  const globalKey = globalConfig.services[serviceId]?.apiKey;
1696
- if (globalKey) {
1697
- keySource = "global_config";
1698
- } else {
1699
- for (const pattern of definition.envPatterns) {
1700
- if (process.env[pattern]) {
1701
- keySource = `env:${pattern}`;
1702
- envKeysFound.push(pattern);
1703
- break;
1704
- }
1751
+ for (const pattern of definition.envPatterns) {
1752
+ if (process.env[pattern]) {
1753
+ envKeysFound.push(pattern);
1754
+ if (!keySource && !globalKey) keySource = `env:${pattern}`;
1705
1755
  }
1706
- if (!keySource) {
1707
- const envFiles = [".env", ".env.local", ".env.development"];
1708
- for (const envFile of envFiles) {
1709
- try {
1710
- const envPath = path5.join(projectRoot, envFile);
1711
- const envContent = fs5.readFileSync(envPath, "utf-8");
1712
- for (const pattern of definition.envPatterns) {
1713
- const regex = new RegExp(`^${pattern}=(.+)$`, "m");
1714
- const match = envContent.match(regex);
1715
- if (match?.[1]) {
1716
- keySource = `file:${envFile}:${pattern}`;
1717
- envKeysFound.push(`${pattern} (in ${envFile})`);
1718
- break;
1719
- }
1756
+ }
1757
+ const envFiles = [".env", ".env.local", ".env.development"];
1758
+ for (const envFile of envFiles) {
1759
+ try {
1760
+ const envPath = path5.join(projectRoot, envFile);
1761
+ const envContent = fs5.readFileSync(envPath, "utf-8");
1762
+ for (const pattern of definition.envPatterns) {
1763
+ const regex = new RegExp(`^${pattern}=(.+)$`, "m");
1764
+ const match = envContent.match(regex);
1765
+ if (match?.[1]) {
1766
+ const label = `${pattern} (in ${envFile})`;
1767
+ if (!envKeysFound.some((k) => k.startsWith(pattern))) {
1768
+ envKeysFound.push(label);
1720
1769
  }
1721
- if (keySource) break;
1722
- } catch {
1770
+ if (!keySource && !globalKey) keySource = `file:${envFile}:${pattern}`;
1723
1771
  }
1724
1772
  }
1773
+ } catch {
1725
1774
  }
1726
1775
  }
1776
+ if (globalKey) keySource = "global_config";
1727
1777
  let apiKey = globalKey;
1728
1778
  if (!apiKey && keySource?.startsWith("env:")) {
1729
1779
  apiKey = process.env[keySource.slice(4)];
@@ -2132,6 +2182,47 @@ async function cmdReconcile() {
2132
2182
  }
2133
2183
  console.log("");
2134
2184
  }
2185
+ function cmdReset() {
2186
+ const projectRoot = process.cwd();
2187
+ const burnwatchDir = path5.join(projectRoot, ".burnwatch");
2188
+ const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2189
+ if (!fs5.existsSync(burnwatchDir)) {
2190
+ console.log("burnwatch is not initialized in this project.");
2191
+ return;
2192
+ }
2193
+ fs5.rmSync(burnwatchDir, { recursive: true, force: true });
2194
+ console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
2195
+ const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2196
+ for (const skill of skillNames) {
2197
+ const skillDir = path5.join(claudeSkillsDir, skill);
2198
+ if (fs5.existsSync(skillDir)) {
2199
+ fs5.rmSync(skillDir, { recursive: true, force: true });
2200
+ }
2201
+ }
2202
+ console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
2203
+ const settingsPath = path5.join(projectRoot, ".claude", "settings.json");
2204
+ if (fs5.existsSync(settingsPath)) {
2205
+ try {
2206
+ const settings = JSON.parse(fs5.readFileSync(settingsPath, "utf-8"));
2207
+ const hooks = settings["hooks"];
2208
+ if (hooks) {
2209
+ for (const [event, hookList] of Object.entries(hooks)) {
2210
+ hooks[event] = hookList.filter(
2211
+ (h) => !h.hooks?.some((inner) => inner.command?.includes("burnwatch"))
2212
+ );
2213
+ if (hooks[event].length === 0) delete hooks[event];
2214
+ }
2215
+ if (Object.keys(hooks).length === 0) delete settings["hooks"];
2216
+ fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2217
+ console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
2218
+ }
2219
+ } catch {
2220
+ console.log("\u26A0\uFE0F Could not clean .claude/settings.json \u2014 remove burnwatch hooks manually");
2221
+ }
2222
+ }
2223
+ console.log("\n\u2705 burnwatch fully reset. Global API keys in ~/.config/burnwatch/ were preserved.");
2224
+ console.log(" Run 'burnwatch init' to set up again.\n");
2225
+ }
2135
2226
  function cmdHelp() {
2136
2227
  console.log(`
2137
2228
  burnwatch \u2014 Passive cost memory for AI-assisted development
@@ -2146,6 +2237,7 @@ Usage:
2146
2237
  burnwatch reconcile Scan for untracked services
2147
2238
  burnwatch interview --json Export state for agent-driven interview
2148
2239
  burnwatch configure --service <id> [opts] Agent writes back interview answers
2240
+ burnwatch reset Remove all burnwatch config from this project
2149
2241
 
2150
2242
  Options for 'configure':
2151
2243
  --service <ID> Service to configure (required)