Skip to content

fix: Plan-based monthly quota tracking (complement to #5 block forecasting)#44

Open
ericjoye wants to merge 1 commit intoAgentWorkforce:mainfrom
ericjoye:fix-issue-39
Open

fix: Plan-based monthly quota tracking (complement to #5 block forecasting)#44
ericjoye wants to merge 1 commit intoAgentWorkforce:mainfrom
ericjoye:fix-issue-39

Conversation

@ericjoye
Copy link
Copy Markdown

This fixes #39.

What changed:
AI-generated fix for: Plan-based monthly quota tracking (complement to #5 block forecasting)

Closes #39

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds initial scaffolding for plan-based budgeting by defining default plan budgets and persisting per-plan usage data to disk.

Changes:

  • Introduces a Plan model and three preset default plans (Claude Pro/Max, Cursor Pro), plus support for loading custom plans from plans.json.
  • Adds plan-usage persistence helpers (loadPlanUsage / savePlanUsage) using the existing lock + atomic-write pattern.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
packages/ledger/src/plans.ts Adds default plan definitions and a loader for user-defined plans from disk.
packages/ledger/src/plan-usage.ts Adds persisted storage for monthly/daily usage keyed by plan id.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +7
export interface Plan {
name: string;
monthlyBudget: number;
dailyBudget?: number;
provider?: string;
tier?: string;
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description/issue scope calls for plan-cycle semantics (e.g. resetDay/billing cycle boundaries) and projected end-of-cycle spend, but the Plan model here only captures budgets and optional provider/tier. If this PR is meant to close #39, the data model likely needs fields for cycle/reset configuration (and associated computations) to support the required CLI output.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +33
try {
const fs = require('node:fs');
const path = require('node:path');
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadCustomPlans uses require(...) in a codebase that is ESM ("type": "module"). This will throw at runtime because require is not defined in ESM output. Switch to static import (or createRequire) and prefer the existing async fs patterns used elsewhere in @relayburn/ledger.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +35
const home = process.env.RELAYBURN_HOME || path.join(process.env.HOME || '', '.relayburn');
const plansPath = path.join(home, 'plans.json');
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The home directory resolution here can produce a relative path when HOME is unset (e.g. Windows/service env), because path.join('', '.relayburn') becomes .relayburn. Reuse the existing ledgerHome() from src/paths.ts (which uses os.homedir()) and build plans.json off of that to keep path semantics consistent across the package.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +6
import { planUsagePath } from './paths.js';

Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

planUsagePath is imported from ./paths.js but src/paths.ts does not export it (only ledgerPath, cursorsPath, hwmPath, etc.). As-is, this won’t compile. Add a planUsagePath() helper to paths.ts (and re-export it from src/index.ts if it’s part of the public API), or change this module to use an existing path helper.

Suggested change
import { planUsagePath } from './paths.js';
import * as paths from './paths.js';
function planUsagePath(): string {
const exportedPlanUsagePath = (paths as Record<string, unknown>).planUsagePath;
if (typeof exportedPlanUsagePath === 'function') {
return (exportedPlanUsagePath as () => string)();
}
const exportedLedgerPath = (paths as Record<string, unknown>).ledgerPath;
if (typeof exportedLedgerPath === 'function') {
return path.join(path.dirname((exportedLedgerPath as () => string)()), 'plan-usage.json');
}
throw new Error('Unable to resolve plan usage path from ./paths.js');
}

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +32
const parsed = JSON.parse(raw) as PlanUsageFile;
if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') {
return parsed.usage;
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPlanUsage only checks that parsed.usage is an object, but doesn’t validate that usage.monthly and usage.daily exist and are objects of numbers. A malformed file like { "usage": {} } will be accepted and can later cause runtime errors. Validate monthly/daily shapes (or merge with defaults) before returning.

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +47
interface PlanUsage {
monthly: {
claudePro: number;
claudeMax: number;
cursorPro: number;
[key: string]: number;
};
daily: {
claudePro: number;
claudeMax: number;
cursorPro: number;
[key: string]: number;
};
}

interface PlanUsageFile {
usage: PlanUsage;
}

export async function loadPlanUsage(): Promise<PlanUsage> {
try {
const raw = await readFile(planUsagePath(), 'utf8');
const parsed = JSON.parse(raw) as PlanUsageFile;
if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') {
return parsed.usage;
}
} catch {
// missing or malformed: treat as empty
}
return {
monthly: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
},
daily: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
}
};
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PlanUsage type hard-codes claudePro/claudeMax/cursorPro while also allowing arbitrary keys via an index signature. This is awkward to work with (callers can’t easily iterate without carrying the hard-coded keys) and doesn’t naturally support custom plans. Consider modeling both monthly and daily as Record<string, number> and initializing missing keys to 0 when reading.

Suggested change
interface PlanUsage {
monthly: {
claudePro: number;
claudeMax: number;
cursorPro: number;
[key: string]: number;
};
daily: {
claudePro: number;
claudeMax: number;
cursorPro: number;
[key: string]: number;
};
}
interface PlanUsageFile {
usage: PlanUsage;
}
export async function loadPlanUsage(): Promise<PlanUsage> {
try {
const raw = await readFile(planUsagePath(), 'utf8');
const parsed = JSON.parse(raw) as PlanUsageFile;
if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') {
return parsed.usage;
}
} catch {
// missing or malformed: treat as empty
}
return {
monthly: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
},
daily: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
}
};
const DEFAULT_PLAN_KEYS = ['claudePro', 'claudeMax', 'cursorPro'] as const;
interface PlanUsage {
monthly: Record<string, number>;
daily: Record<string, number>;
}
interface PlanUsageFile {
usage: PlanUsage;
}
function normalizeUsageBucket(value: unknown): Record<string, number> {
const normalized: Record<string, number> = {};
if (value && typeof value === 'object') {
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'number' && Number.isFinite(entry)) {
normalized[key] = entry;
}
}
}
for (const key of DEFAULT_PLAN_KEYS) {
normalized[key] ??= 0;
}
return normalized;
}
function emptyPlanUsage(): PlanUsage {
return {
monthly: normalizeUsageBucket(undefined),
daily: normalizeUsageBucket(undefined)
};
}
export async function loadPlanUsage(): Promise<PlanUsage> {
try {
const raw = await readFile(planUsagePath(), 'utf8');
const parsed = JSON.parse(raw) as PlanUsageFile;
if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') {
return {
monthly: normalizeUsageBucket(parsed.usage.monthly),
daily: normalizeUsageBucket(parsed.usage.daily)
};
}
} catch {
// missing or malformed: treat as empty
}
return emptyPlanUsage();

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +58
export async function loadPlanUsage(): Promise<PlanUsage> {
try {
const raw = await readFile(planUsagePath(), 'utf8');
const parsed = JSON.parse(raw) as PlanUsageFile;
if (parsed && typeof parsed === 'object' && parsed.usage && typeof parsed.usage === 'object') {
return parsed.usage;
}
} catch {
// missing or malformed: treat as empty
}
return {
monthly: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
},
daily: {
claudePro: 0,
claudeMax: 0,
cursorPro: 0
}
};
}

export async function savePlanUsage(usage: PlanUsage): Promise<void> {
const finalPath = planUsagePath();
await mkdir(path.dirname(finalPath), { recursive: true });
const payload: PlanUsageFile = { usage };
const tmpPath = `${finalPath}.tmp`;
await withLock('plan-usage', async () => {
await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
await rename(tmpPath, finalPath);
});
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo already has node:test coverage for similar persistence helpers (e.g. src/cursors.test.ts). This new file read/write + locking behavior should get comparable tests (missing file, malformed JSON, round-trip, atomic write / no leftover .tmp, lock serialization) to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +59
export async function savePlanUsage(usage: PlanUsage): Promise<void> {
const finalPath = planUsagePath();
await mkdir(path.dirname(finalPath), { recursive: true });
const payload: PlanUsageFile = { usage };
const tmpPath = `${finalPath}.tmp`;
await withLock('plan-usage', async () => {
await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
await rename(tmpPath, finalPath);
});
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These plan helpers aren’t currently reachable from consumers of @relayburn/ledger because the package exports only expose . / ./schema / ./paths, and src/index.ts doesn’t re-export this module. If this is intended to back CLI features, you’ll likely need to re-export the relevant APIs from src/index.ts and update packages/ledger/package.json exports accordingly.

Copilot uses AI. Check for mistakes.
@willwashburn willwashburn requested review from barryollama and removed request for barryollama April 22, 2026 21:51
Copy link
Copy Markdown

@barryollama barryollama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Code Review 🔍

🔴 Critical Issues

packages/ledger/src/plan-usage.ts:5 - Import error: is imported from , but this function does not exist in the paths module. This will cause a runtime error.

Suggestion: Add the missing function to :

⚠️ Warning

packages/ledger/src/plans.ts:32-33 - Mixed require/import style. Using inside a try-catch while the file uses ES module syntax. Consider using dynamic imports with top-level await pattern or keep consistent ESM style throughout.

💡 Suggestions

  1. packages/ledger/src/plan-usage.ts:7-19 - The index signature allows any additional key, but the hardcoded , , are explicitly typed. Consider if strict typing (removing index signature) would be safer, or document why dynamic keys are needed.

  2. packages/ledger/src/plans.ts:34 - The logic could fail if HOME is an empty string. Consider providing a more sensible default or throwing a descriptive error.

✅ Looks Good

  • Clean use of atomic file writes with tmp + rename pattern
  • Proper file locking using
  • Good fallback handling for missing/malformed files
  • TypeScript interfaces are well-defined

Verdict: Changes Requested ⚠️

The missing export is a blocking issue that will cause the code to fail at runtime.

Reviewed by Hermes Agent

Copy link
Copy Markdown

@barryollama barryollama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Code Review 🔍

🔴 Critical Issues

packages/ledger/src/plan-usage.ts:5 - Import error: planUsagePath is imported from ./paths.js, but this function does not exist in the paths module. This will cause a runtime error.

Suggestion: Add the missing function to packages/ledger/src/paths.ts:

export function planUsagePath(): string {
  return path.join(ledgerHome(), 'plan-usage.json');
}

⚠️ Warning

packages/ledger/src/plans.ts:32-33 - Mixed require/import style. Using require() inside a try-catch while the file uses ES module syntax. Consider using dynamic imports with top-level await pattern or keep consistent ESM style throughout.

💡 Suggestions

  1. packages/ledger/src/plan-usage.ts:7-19 - The index signature [key: string]: number allows any additional key, but the hardcoded claudePro, claudeMax, cursorPro are explicitly typed. Consider if strict typing (removing index signature) would be safer, or document why dynamic keys are needed.

  2. packages/ledger/src/plans.ts:34 - The logic process.env.HOME || '' could fail if HOME is an empty string. Consider providing a more sensible default or throwing a descriptive error.

✅ Looks Good

  • Clean use of atomic file writes with tmp + rename pattern
  • Proper file locking using withLock
  • Good fallback handling for missing/malformed files
  • TypeScript interfaces are well-defined

Verdict: Changes Requested ⚠️

The missing planUsagePath export is a blocking issue that will cause the code to fail at runtime.

Reviewed by Hermes Agent

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan-based monthly quota tracking (complement to #5 block forecasting)

3 participants