brain-dev 0.1.2 → 0.3.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.
@@ -0,0 +1,397 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { estimateTokens } = require('./tokens.cjs');
6
+
7
+ // Token budgets per agent type
8
+ const CONTEXT_BUDGETS = {
9
+ executor: 80000,
10
+ planner: 60000,
11
+ verifier: 40000,
12
+ debugger: 60000,
13
+ researcher: 30000
14
+ };
15
+
16
+ // Priority tiers for context files
17
+ const PRIORITY_TIERS = {
18
+ P0_PLAN: 0,
19
+ P1_STATE: 1,
20
+ P2_PHASE_CONTEXT: 2,
21
+ P3_PRIOR_SUMMARIES: 3,
22
+ P4_RESEARCH: 4,
23
+ P5_ARCHITECTURE: 5,
24
+ P6_ADRS: 6,
25
+ P7_ROADMAP: 7
26
+ };
27
+
28
+ /**
29
+ * Find the phase directory for a given phase number.
30
+ * Mirrors the pattern from stuck.cjs.
31
+ * @param {string} brainDir
32
+ * @param {number} phaseNumber
33
+ * @returns {string|null}
34
+ */
35
+ function findPhaseDir(brainDir, phaseNumber) {
36
+ const phasesDir = path.join(brainDir, 'phases');
37
+ if (!fs.existsSync(phasesDir)) return null;
38
+ const padded = String(phaseNumber).padStart(2, '0');
39
+ const dirs = fs.readdirSync(phasesDir).filter(d => d.startsWith(padded + '-'));
40
+ return dirs.length > 0 ? path.join(phasesDir, dirs[0]) : null;
41
+ }
42
+
43
+ /**
44
+ * Safely read a file, returning empty string on any failure.
45
+ * @param {string} filePath
46
+ * @returns {string}
47
+ */
48
+ function safeRead(filePath) {
49
+ try {
50
+ return fs.readFileSync(filePath, 'utf8');
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Condense a SUMMARY-NN.md to ~80 tokens.
58
+ * Extracts: status line, key files, key decisions, provides, patterns.
59
+ * @param {string} summaryContent
60
+ * @returns {string}
61
+ */
62
+ function condenseSummary(summaryContent) {
63
+ if (!summaryContent || typeof summaryContent !== 'string') return '';
64
+
65
+ const lines = summaryContent.split('\n');
66
+ const parts = [];
67
+
68
+ // Extract status from frontmatter or first heading
69
+ const statusMatch = summaryContent.match(/status:\s*(.+)/i);
70
+ if (statusMatch) parts.push(`Status: ${statusMatch[1].trim()}`);
71
+
72
+ // Extract key files (lines with file paths)
73
+ const fileLines = lines.filter(l => l.match(/^\s*-\s+.*\.(js|ts|cjs|mjs|json|md|sh|yaml|yml)\b/));
74
+ if (fileLines.length > 0) {
75
+ parts.push('Files: ' + fileLines.slice(0, 5).map(l => l.trim().replace(/^-\s*/, '')).join(', '));
76
+ }
77
+
78
+ // Extract decisions (lines under ## Decisions or ## Key Decisions)
79
+ let inDecisions = false;
80
+ const decisions = [];
81
+ for (const line of lines) {
82
+ if (line.match(/^##\s*(Key\s+)?Decisions/i)) { inDecisions = true; continue; }
83
+ if (inDecisions && line.startsWith('## ')) { inDecisions = false; continue; }
84
+ if (inDecisions && line.trim().startsWith('- ')) {
85
+ decisions.push(line.trim());
86
+ }
87
+ }
88
+ if (decisions.length > 0) {
89
+ parts.push('Decisions: ' + decisions.slice(0, 3).join('; '));
90
+ }
91
+
92
+ // Extract provides/patterns
93
+ const providesMatch = summaryContent.match(/provides:\s*(.+)/i);
94
+ if (providesMatch) parts.push(`Provides: ${providesMatch[1].trim()}`);
95
+
96
+ const patternsMatch = summaryContent.match(/patterns?:\s*(.+)/i);
97
+ if (patternsMatch) parts.push(`Patterns: ${patternsMatch[1].trim()}`);
98
+
99
+ const condensed = parts.join(' | ');
100
+ // Truncate to roughly 80 tokens if needed (~320 chars)
101
+ if (condensed.length > 320) {
102
+ return condensed.slice(0, 317) + '...';
103
+ }
104
+ return condensed;
105
+ }
106
+
107
+ /**
108
+ * Collect and condense SUMMARY-NN.md files for plans before planNum.
109
+ * @param {string} phaseDir
110
+ * @param {number} upToPlan - Exclusive upper bound (plans 1..upToPlan-1)
111
+ * @returns {string} Condensed summaries block
112
+ */
113
+ function collectPriorSummaries(phaseDir, upToPlan) {
114
+ if (!phaseDir || upToPlan <= 1) return '';
115
+
116
+ const summaries = [];
117
+ for (let i = 1; i < upToPlan; i++) {
118
+ const padded = String(i).padStart(2, '0');
119
+ const summaryPath = path.join(phaseDir, `SUMMARY-${padded}.md`);
120
+ const content = safeRead(summaryPath);
121
+ if (content) {
122
+ const condensed = condenseSummary(content);
123
+ if (condensed) {
124
+ summaries.push(`Plan ${padded}: ${condensed}`);
125
+ }
126
+ }
127
+ }
128
+ return summaries.join('\n');
129
+ }
130
+
131
+ /**
132
+ * Build a priority-ordered manifest of context files to include.
133
+ * @param {string} brainDir
134
+ * @param {string} phaseDir
135
+ * @param {number} planNum
136
+ * @returns {Array<{ tier: number, label: string, path: string, required: boolean }>}
137
+ */
138
+ function buildContextManifest(brainDir, phaseDir, planNum) {
139
+ const manifest = [];
140
+ const padded = String(planNum).padStart(2, '0');
141
+
142
+ // P0: Plan content (required)
143
+ if (phaseDir) {
144
+ manifest.push({
145
+ tier: PRIORITY_TIERS.P0_PLAN,
146
+ label: 'Plan',
147
+ path: path.join(phaseDir, `PLAN-${padded}.md`),
148
+ required: true
149
+ });
150
+ }
151
+
152
+ // P1: STATE.md excerpt (required)
153
+ manifest.push({
154
+ tier: PRIORITY_TIERS.P1_STATE,
155
+ label: 'Project State',
156
+ path: path.join(brainDir, 'STATE.md'),
157
+ required: true
158
+ });
159
+
160
+ // P2: Phase CONTEXT.md decisions (required)
161
+ if (phaseDir) {
162
+ manifest.push({
163
+ tier: PRIORITY_TIERS.P2_PHASE_CONTEXT,
164
+ label: 'Phase Decisions',
165
+ path: path.join(phaseDir, 'CONTEXT.md'),
166
+ required: true
167
+ });
168
+ }
169
+
170
+ // P3: Prior summaries (required, synthetic - handled separately)
171
+ // Represented as a placeholder; actual content assembled by collectPriorSummaries
172
+ manifest.push({
173
+ tier: PRIORITY_TIERS.P3_PRIOR_SUMMARIES,
174
+ label: 'Prior Plan Results',
175
+ path: '__SYNTHETIC_PRIOR_SUMMARIES__',
176
+ required: true
177
+ });
178
+
179
+ // P4: Phase RESEARCH.md
180
+ if (phaseDir) {
181
+ manifest.push({
182
+ tier: PRIORITY_TIERS.P4_RESEARCH,
183
+ label: 'Research',
184
+ path: path.join(phaseDir, 'RESEARCH.md'),
185
+ required: false
186
+ });
187
+ }
188
+
189
+ // P5: codebase/ARCHITECTURE.md
190
+ manifest.push({
191
+ tier: PRIORITY_TIERS.P5_ARCHITECTURE,
192
+ label: 'Architecture',
193
+ path: path.join(brainDir, 'codebase', 'ARCHITECTURE.md'),
194
+ required: false
195
+ });
196
+
197
+ // P6: Relevant ADRs
198
+ const adrDir = path.join(brainDir, 'decisions');
199
+ try {
200
+ if (fs.existsSync(adrDir)) {
201
+ const adrs = fs.readdirSync(adrDir)
202
+ .filter(f => f.endsWith('.md'))
203
+ .sort();
204
+ for (const adr of adrs) {
205
+ manifest.push({
206
+ tier: PRIORITY_TIERS.P6_ADRS,
207
+ label: `ADR: ${adr}`,
208
+ path: path.join(adrDir, adr),
209
+ required: false
210
+ });
211
+ }
212
+ }
213
+ } catch { /* ADR dir may not exist */ }
214
+
215
+ // P7: ROADMAP.md excerpt
216
+ manifest.push({
217
+ tier: PRIORITY_TIERS.P7_ROADMAP,
218
+ label: 'Roadmap',
219
+ path: path.join(brainDir, 'ROADMAP.md'),
220
+ required: false
221
+ });
222
+
223
+ return manifest;
224
+ }
225
+
226
+ /**
227
+ * Calculate the available context token budget for an agent.
228
+ * Subtracts a complexity overhead from the base budget.
229
+ * @param {string} agentType - executor|planner|verifier|debugger|researcher
230
+ * @param {string} planContent - Raw plan content for complexity estimation
231
+ * @param {object} [brainConfig] - Optional brain config with budget overrides
232
+ * @returns {number} Available token budget
233
+ */
234
+ function calculateContextBudget(agentType, planContent, brainConfig) {
235
+ const baseBudget = (brainConfig && brainConfig.context_budgets && brainConfig.context_budgets[agentType])
236
+ || CONTEXT_BUDGETS[agentType]
237
+ || CONTEXT_BUDGETS.executor;
238
+
239
+ // Reserve tokens proportional to plan complexity
240
+ // Larger plans need more room for the agent's own reasoning
241
+ const planTokens = estimateTokens(planContent || '');
242
+ const complexityOverhead = Math.min(Math.round(planTokens * 0.1), 5000);
243
+
244
+ return baseBudget - complexityOverhead;
245
+ }
246
+
247
+ /**
248
+ * Detect whether any manifest files have been modified after a reference timestamp.
249
+ * @param {Array<{ path: string }>} manifest
250
+ * @param {string} referenceTimestamp - ISO timestamp of assembly time
251
+ * @returns {{ stale: boolean, staleFiles: string[] }}
252
+ */
253
+ function detectStaleContext(manifest, referenceTimestamp) {
254
+ const refTime = new Date(referenceTimestamp).getTime();
255
+ const staleFiles = [];
256
+
257
+ for (const entry of manifest) {
258
+ if (entry.path === '__SYNTHETIC_PRIOR_SUMMARIES__') continue;
259
+ try {
260
+ const stat = fs.statSync(entry.path);
261
+ if (stat.mtime.getTime() > refTime) {
262
+ staleFiles.push(entry.path);
263
+ }
264
+ } catch { /* file may not exist */ }
265
+ }
266
+
267
+ return { stale: staleFiles.length > 0, staleFiles };
268
+ }
269
+
270
+ /**
271
+ * Main context assembly function.
272
+ * Reads files in priority order within token budget, returns assembled context block.
273
+ *
274
+ * @param {string} brainDir - Path to .brain/ directory
275
+ * @param {string} phaseDir - Path to the current phase directory (or null)
276
+ * @param {number} planNum - Current plan number
277
+ * @param {object} [opts] - Options
278
+ * @param {string} [opts.agentType='executor'] - Agent type for budget calculation
279
+ * @param {object} [opts.brainConfig] - Brain config with optional budget overrides
280
+ * @returns {{ context: string, manifest: Array, tokenEstimate: number, truncated: boolean }}
281
+ */
282
+ function assembleTaskContext(brainDir, phaseDir, planNum, opts) {
283
+ const options = opts || {};
284
+ const agentType = options.agentType || 'executor';
285
+ const brainConfig = options.brainConfig || null;
286
+
287
+ const manifest = buildContextManifest(brainDir, phaseDir, planNum);
288
+
289
+ // Read plan content first to calculate budget
290
+ const planEntry = manifest.find(e => e.tier === PRIORITY_TIERS.P0_PLAN);
291
+ const planContent = planEntry ? safeRead(planEntry.path) : '';
292
+ const budget = calculateContextBudget(agentType, planContent, brainConfig);
293
+
294
+ const sections = [];
295
+ let totalTokens = 0;
296
+ let truncated = false;
297
+ const includedFiles = [];
298
+
299
+ for (const entry of manifest) {
300
+ let content;
301
+ let sectionHeader;
302
+
303
+ if (entry.path === '__SYNTHETIC_PRIOR_SUMMARIES__') {
304
+ content = collectPriorSummaries(phaseDir, planNum);
305
+ sectionHeader = '## Prior Plan Results';
306
+ } else {
307
+ content = safeRead(entry.path);
308
+ if (entry.tier === PRIORITY_TIERS.P0_PLAN) {
309
+ sectionHeader = '## Current Plan';
310
+ } else if (entry.tier === PRIORITY_TIERS.P1_STATE) {
311
+ sectionHeader = '## Project State';
312
+ } else if (entry.tier === PRIORITY_TIERS.P2_PHASE_CONTEXT) {
313
+ sectionHeader = '## Phase Decisions';
314
+ } else if (entry.tier === PRIORITY_TIERS.P4_RESEARCH) {
315
+ sectionHeader = '## Research';
316
+ } else if (entry.tier === PRIORITY_TIERS.P5_ARCHITECTURE) {
317
+ sectionHeader = '## Architecture';
318
+ } else if (entry.tier === PRIORITY_TIERS.P6_ADRS) {
319
+ sectionHeader = `## ${entry.label}`;
320
+ } else if (entry.tier === PRIORITY_TIERS.P7_ROADMAP) {
321
+ sectionHeader = '## Roadmap';
322
+ } else {
323
+ sectionHeader = `## ${entry.label}`;
324
+ }
325
+ }
326
+
327
+ if (!content) {
328
+ if (entry.required) {
329
+ sections.push(`${sectionHeader}\n[Not available]`);
330
+ }
331
+ continue;
332
+ }
333
+
334
+ const sectionText = `${sectionHeader}\n${content}`;
335
+ const sectionTokens = estimateTokens(sectionText);
336
+
337
+ if (totalTokens + sectionTokens > budget) {
338
+ if (entry.required) {
339
+ // Truncate required content to fit
340
+ const remaining = budget - totalTokens;
341
+ const charLimit = remaining * 4; // CHARS_PER_TOKEN = 4
342
+ if (charLimit > 100) {
343
+ const truncatedContent = content.slice(0, charLimit - 20) + '\n[...truncated]';
344
+ sections.push(`${sectionHeader}\n${truncatedContent}`);
345
+ totalTokens += estimateTokens(`${sectionHeader}\n${truncatedContent}`);
346
+ truncated = true;
347
+ includedFiles.push({ ...entry, truncated: true });
348
+ }
349
+ } else {
350
+ truncated = true;
351
+ }
352
+ continue;
353
+ }
354
+
355
+ sections.push(sectionText);
356
+ totalTokens += sectionTokens;
357
+ includedFiles.push({ ...entry, truncated: false });
358
+ }
359
+
360
+ const assembledAt = new Date().toISOString();
361
+
362
+ // Build the wrapped context block
363
+ const metadata = [
364
+ '## Context Metadata',
365
+ `- Assembled: ${assembledAt}`,
366
+ `- Token estimate: ${totalTokens}`,
367
+ `- Truncated: ${truncated ? 'yes' : 'no'}`,
368
+ `- Agent type: ${agentType}`,
369
+ `- Budget: ${budget}`
370
+ ].join('\n');
371
+
372
+ sections.push(metadata);
373
+
374
+ const context = [
375
+ '---BRAIN-CONTEXT-START---',
376
+ sections.join('\n\n'),
377
+ '---BRAIN-CONTEXT-END---'
378
+ ].join('\n');
379
+
380
+ return {
381
+ context,
382
+ manifest: includedFiles,
383
+ tokenEstimate: totalTokens,
384
+ truncated
385
+ };
386
+ }
387
+
388
+ module.exports = {
389
+ assembleTaskContext,
390
+ collectPriorSummaries,
391
+ condenseSummary,
392
+ buildContextManifest,
393
+ calculateContextBudget,
394
+ detectStaleContext,
395
+ CONTEXT_BUDGETS,
396
+ PRIORITY_TIERS
397
+ };
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { atomicWriteSync } = require('./state.cjs');
6
+ const { estimateTokens } = require('./tokens.cjs');
7
+
8
+ const METRICS_FILE = 'metrics.json';
9
+
10
+ // Default per-million-token pricing (conservative estimates)
11
+ const DEFAULT_PRICING = {
12
+ 'claude-opus-4-6': { input: 15.00, output: 75.00 },
13
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
14
+ 'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
15
+ 'claude-haiku-4-5-20251001': { input: 0.25, output: 1.25 },
16
+ 'inherit': { input: 15.00, output: 75.00 } // Conservative default
17
+ };
18
+
19
+ /**
20
+ * Create a default metrics object for a new brain project.
21
+ * @returns {object} Default metrics structure
22
+ */
23
+ function createDefaultMetrics() {
24
+ return {
25
+ $schema: 'brain-metrics/v1',
26
+ budget: { ceiling_usd: null, warn_at_pct: 80, mode: 'warn' },
27
+ pricing: { ...DEFAULT_PRICING },
28
+ totals: { input_tokens: 0, output_tokens: 0, estimated_cost_usd: 0.00, agent_invocations: 0 },
29
+ by_agent: {},
30
+ by_phase: {},
31
+ ledger: []
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Read metrics.json from the given directory.
37
+ * Returns default metrics if file does not exist or is corrupted.
38
+ * @param {string} brainDir - Path to .brain/ directory
39
+ * @returns {object} Metrics object
40
+ */
41
+ function readMetrics(brainDir) {
42
+ try {
43
+ const metricsPath = path.join(brainDir, METRICS_FILE);
44
+ return JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
45
+ } catch {
46
+ return createDefaultMetrics();
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Write metrics.json atomically.
52
+ * @param {string} brainDir - Path to .brain/ directory
53
+ * @param {object} metrics - Metrics object to write
54
+ */
55
+ function writeMetrics(brainDir, metrics) {
56
+ const metricsPath = path.join(brainDir, METRICS_FILE);
57
+ atomicWriteSync(metricsPath, JSON.stringify(metrics, null, 2));
58
+ }
59
+
60
+ /**
61
+ * Estimate cost in USD for a given model invocation.
62
+ * @param {string} model - Model identifier
63
+ * @param {number} inputTokens - Number of input tokens
64
+ * @param {number} outputTokens - Number of output tokens
65
+ * @param {object} [pricing] - Optional pricing table override
66
+ * @returns {number} Estimated cost in USD (rounded to 4 decimal places)
67
+ */
68
+ function estimateCost(model, inputTokens, outputTokens, pricing) {
69
+ const p = pricing || DEFAULT_PRICING;
70
+ const modelPricing = p[model] || p['inherit'] || { input: 15.00, output: 75.00 };
71
+ const inputCost = (inputTokens / 1_000_000) * modelPricing.input;
72
+ const outputCost = (outputTokens / 1_000_000) * modelPricing.output;
73
+ return Math.round((inputCost + outputCost) * 10000) / 10000;
74
+ }
75
+
76
+ /**
77
+ * Record an agent invocation in the metrics ledger.
78
+ * Updates totals, by_agent, and by_phase aggregates.
79
+ * @param {string} brainDir - Path to .brain/ directory
80
+ * @param {object} entry - Invocation data
81
+ * @param {string} entry.agent - Agent name (e.g. 'researcher', 'executor')
82
+ * @param {number} entry.phase - Phase number
83
+ * @param {string} [entry.plan] - Plan identifier
84
+ * @param {string} [entry.model] - Model used (defaults to 'inherit')
85
+ * @param {number} [entry.input_tokens] - Input token count
86
+ * @param {number} [entry.output_tokens] - Output token count
87
+ * @returns {object} Ledger entry with budget_status appended
88
+ */
89
+ function recordInvocation(brainDir, entry) {
90
+ const metrics = readMetrics(brainDir);
91
+
92
+ const cost = estimateCost(
93
+ entry.model || 'inherit',
94
+ entry.input_tokens || 0,
95
+ entry.output_tokens || 0,
96
+ metrics.pricing
97
+ );
98
+
99
+ const ledgerEntry = {
100
+ timestamp: new Date().toISOString(),
101
+ agent: entry.agent,
102
+ phase: entry.phase,
103
+ plan: entry.plan || null,
104
+ model: entry.model || 'inherit',
105
+ input_tokens: entry.input_tokens || 0,
106
+ output_tokens: entry.output_tokens || 0,
107
+ cost_usd: cost,
108
+ source: 'estimate'
109
+ };
110
+
111
+ metrics.ledger.push(ledgerEntry);
112
+
113
+ // Update totals
114
+ metrics.totals.input_tokens += ledgerEntry.input_tokens;
115
+ metrics.totals.output_tokens += ledgerEntry.output_tokens;
116
+ metrics.totals.estimated_cost_usd = Math.round((metrics.totals.estimated_cost_usd + cost) * 10000) / 10000;
117
+ metrics.totals.agent_invocations += 1;
118
+
119
+ // Update by_agent
120
+ const agentKey = entry.agent || 'unknown';
121
+ if (!metrics.by_agent[agentKey]) {
122
+ metrics.by_agent[agentKey] = { invocations: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
123
+ }
124
+ metrics.by_agent[agentKey].invocations += 1;
125
+ metrics.by_agent[agentKey].input_tokens += ledgerEntry.input_tokens;
126
+ metrics.by_agent[agentKey].output_tokens += ledgerEntry.output_tokens;
127
+ metrics.by_agent[agentKey].cost_usd = Math.round((metrics.by_agent[agentKey].cost_usd + cost) * 10000) / 10000;
128
+
129
+ // Update by_phase
130
+ const phaseKey = String(entry.phase || 0);
131
+ if (!metrics.by_phase[phaseKey]) {
132
+ metrics.by_phase[phaseKey] = { invocations: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
133
+ }
134
+ metrics.by_phase[phaseKey].invocations += 1;
135
+ metrics.by_phase[phaseKey].input_tokens += ledgerEntry.input_tokens;
136
+ metrics.by_phase[phaseKey].output_tokens += ledgerEntry.output_tokens;
137
+ metrics.by_phase[phaseKey].cost_usd = Math.round((metrics.by_phase[phaseKey].cost_usd + cost) * 10000) / 10000;
138
+
139
+ writeMetrics(brainDir, metrics);
140
+
141
+ return { ...ledgerEntry, budget_status: checkBudget(metrics) };
142
+ }
143
+
144
+ /**
145
+ * Check budget status against ceiling.
146
+ * @param {object} metrics - Metrics object
147
+ * @returns {object} Budget status with within_budget, spent, ceiling, pct_used, remaining, status
148
+ */
149
+ function checkBudget(metrics) {
150
+ const ceiling = metrics.budget?.ceiling_usd;
151
+ const spent = metrics.totals?.estimated_cost_usd || 0;
152
+
153
+ if (ceiling === null || ceiling === undefined) {
154
+ return { within_budget: true, spent, ceiling: null, pct_used: 0, remaining: null, status: 'ok' };
155
+ }
156
+
157
+ const pct_used = ceiling > 0 ? Math.round((spent / ceiling) * 100) : 100;
158
+ const remaining = Math.round((ceiling - spent) * 10000) / 10000;
159
+ const warn_at = metrics.budget?.warn_at_pct || 80;
160
+
161
+ let status = 'ok';
162
+ if (pct_used >= 100) status = 'exceeded';
163
+ else if (pct_used >= warn_at) status = 'warning';
164
+
165
+ return { within_budget: pct_used < 100, spent, ceiling, pct_used, remaining, status };
166
+ }
167
+
168
+ /**
169
+ * Check whether spending an estimated cost is allowed under the budget.
170
+ * Respects budget mode: 'warn' (allow with warning), 'hard-stop' (block), 'pause' (block).
171
+ * @param {string} brainDir - Path to .brain/ directory
172
+ * @param {number} estimatedCost - Estimated cost of the next operation
173
+ * @returns {object} { allowed, reason, budget_status }
174
+ */
175
+ function canSpend(brainDir, estimatedCost) {
176
+ const metrics = readMetrics(brainDir);
177
+ const budgetStatus = checkBudget(metrics);
178
+
179
+ if (budgetStatus.ceiling === null) {
180
+ return { allowed: true, reason: null, budget_status: budgetStatus };
181
+ }
182
+
183
+ const wouldExceed = (budgetStatus.spent + estimatedCost) > budgetStatus.ceiling;
184
+ if (wouldExceed) {
185
+ const mode = metrics.budget?.mode || 'warn';
186
+ if (mode === 'hard-stop') {
187
+ return { allowed: false, reason: `Budget exceeded: $${budgetStatus.spent.toFixed(2)} + ~$${estimatedCost.toFixed(2)} > $${budgetStatus.ceiling.toFixed(2)} ceiling`, budget_status: budgetStatus };
188
+ }
189
+ if (mode === 'pause') {
190
+ return { allowed: false, reason: `Budget would be exceeded. Pausing. Spent: $${budgetStatus.spent.toFixed(2)}, Ceiling: $${budgetStatus.ceiling.toFixed(2)}`, budget_status: budgetStatus };
191
+ }
192
+ // 'warn' mode — allowed but with warning
193
+ return { allowed: true, reason: `WARNING: Budget will be exceeded ($${budgetStatus.spent.toFixed(2)} + ~$${estimatedCost.toFixed(2)} > $${budgetStatus.ceiling.toFixed(2)})`, budget_status: budgetStatus };
194
+ }
195
+
196
+ return { allowed: true, reason: null, budget_status: budgetStatus };
197
+ }
198
+
199
+ /**
200
+ * Project remaining cost based on average spend per completed phase.
201
+ * @param {object} metrics - Metrics object
202
+ * @param {number} totalPhases - Total planned phases
203
+ * @param {number} currentPhase - Current phase number
204
+ * @returns {object} Projection with spent, projected_remaining, projected_total, avg_per_phase, confidence
205
+ */
206
+ function projectCost(metrics, totalPhases, currentPhase) {
207
+ const completedPhases = Object.keys(metrics.by_phase).length;
208
+ const spent = metrics.totals?.estimated_cost_usd || 0;
209
+
210
+ if (completedPhases === 0) {
211
+ return { spent, projected_remaining: 0, projected_total: spent, avg_per_phase: 0, confidence: 'low' };
212
+ }
213
+
214
+ const avg_per_phase = spent / completedPhases;
215
+ const remainingPhases = Math.max(0, totalPhases - currentPhase);
216
+ const projected_remaining = Math.round(avg_per_phase * remainingPhases * 100) / 100;
217
+ const projected_total = Math.round((spent + projected_remaining) * 100) / 100;
218
+
219
+ let confidence = 'low';
220
+ if (completedPhases >= 5) confidence = 'high';
221
+ else if (completedPhases >= 3) confidence = 'medium';
222
+
223
+ return { spent, projected_remaining, projected_total, avg_per_phase: Math.round(avg_per_phase * 100) / 100, confidence };
224
+ }
225
+
226
+ /**
227
+ * Get cost breakdown by agent, sorted by cost descending.
228
+ * @param {object} metrics - Metrics object
229
+ * @returns {object[]} Array of { agent, cost, pct, invocations }
230
+ */
231
+ function getAgentBreakdown(metrics) {
232
+ return Object.entries(metrics.by_agent || {})
233
+ .map(([agent, data]) => ({
234
+ agent,
235
+ cost: data.cost_usd,
236
+ pct: metrics.totals.estimated_cost_usd > 0 ? Math.round((data.cost_usd / metrics.totals.estimated_cost_usd) * 100) : 0,
237
+ invocations: data.invocations
238
+ }))
239
+ .sort((a, b) => b.cost - a.cost);
240
+ }
241
+
242
+ /**
243
+ * Get cost breakdown by phase, sorted by phase number ascending.
244
+ * @param {object} metrics - Metrics object
245
+ * @returns {object[]} Array of { phase, cost, invocations, input_tokens, output_tokens }
246
+ */
247
+ function getPhaseBreakdown(metrics) {
248
+ return Object.entries(metrics.by_phase || {})
249
+ .map(([phase, data]) => ({
250
+ phase: parseInt(phase, 10),
251
+ cost: data.cost_usd,
252
+ invocations: data.invocations,
253
+ input_tokens: data.input_tokens,
254
+ output_tokens: data.output_tokens
255
+ }))
256
+ .sort((a, b) => a.phase - b.phase);
257
+ }
258
+
259
+ module.exports = {
260
+ createDefaultMetrics,
261
+ readMetrics,
262
+ writeMetrics,
263
+ estimateCost,
264
+ estimateTokens, // re-export for convenience
265
+ recordInvocation,
266
+ checkBudget,
267
+ canSpend,
268
+ projectCost,
269
+ getAgentBreakdown,
270
+ getPhaseBreakdown,
271
+ DEFAULT_PRICING,
272
+ METRICS_FILE
273
+ };