@jojonax/codex-copilot 1.3.6 → 1.4.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/bin/cli.js CHANGED
@@ -22,6 +22,8 @@ import { status } from '../src/commands/status.js';
22
22
  import { reset } from '../src/commands/reset.js';
23
23
  import { retry } from '../src/commands/retry.js';
24
24
  import { skip } from '../src/commands/skip.js';
25
+ import { usage } from '../src/commands/usage.js';
26
+ import { evolve } from '../src/commands/evolve.js';
25
27
  import { log } from '../src/utils/logger.js';
26
28
  import { checkForUpdates } from '../src/utils/update-check.js';
27
29
 
@@ -95,6 +97,20 @@ async function main() {
95
97
  break;
96
98
  }
97
99
 
100
+ case 'usage':
101
+ await usage();
102
+ process.exit(0);
103
+ break;
104
+
105
+ case 'evolve':
106
+ case 'next':
107
+ if (!existsSync(resolve(projectDir, '.codex-copilot/config.json'))) {
108
+ log.error('Not initialized. Run: codex-copilot init');
109
+ process.exit(1);
110
+ }
111
+ await evolve(projectDir);
112
+ break;
113
+
98
114
  case 'update':
99
115
  log.info('Updating to latest version...');
100
116
  try {
@@ -132,6 +148,8 @@ async function main() {
132
148
  console.log(' reset Reset state and start over');
133
149
  console.log(' retry Retry blocked tasks with enhanced prompts');
134
150
  console.log(' skip <id> Force-skip a task to unblock dependents');
151
+ console.log(' usage Show AI token usage for recent sessions');
152
+ console.log(' evolve Start next PRD iteration round (gap analysis → plan → run)');
135
153
  console.log(' update Update to latest version');
136
154
  console.log('');
137
155
  console.log(' Workflow:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.3.6",
3
+ "version": "1.4.0",
4
4
  "description": "PRD-driven automated development orchestrator for CodeX / Cursor",
5
5
  "bin": {
6
6
  "codex-copilot": "./bin/cli.js"
@@ -0,0 +1,321 @@
1
+ /**
2
+ * codex-copilot evolve - Multi-round PRD iteration
3
+ *
4
+ * Flow: Archive current round → Gap analysis → Plan next round → Reset → Run
5
+ *
6
+ * Each round is tracked in .codex-copilot/rounds.json with summaries of
7
+ * what was built, enabling intelligent gap analysis against the full PRD.
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
11
+ import { resolve } from 'path';
12
+ import { log } from '../utils/logger.js';
13
+ import { provider } from '../utils/provider.js';
14
+ import { readPRD } from '../utils/detect-prd.js';
15
+ import { closePrompt } from '../utils/prompt.js';
16
+ import { run } from './run.js';
17
+
18
+ function readJSON(path) {
19
+ return JSON.parse(readFileSync(path, 'utf-8'));
20
+ }
21
+
22
+ function writeJSON(path, data) {
23
+ writeFileSync(path, JSON.stringify(data, null, 2));
24
+ }
25
+
26
+ /**
27
+ * Load or initialize rounds.json
28
+ */
29
+ function loadRounds(copilotDir) {
30
+ const roundsPath = resolve(copilotDir, 'rounds.json');
31
+ if (existsSync(roundsPath)) {
32
+ return readJSON(roundsPath);
33
+ }
34
+ return { current_round: 0, rounds: [] };
35
+ }
36
+
37
+ function saveRounds(copilotDir, rounds) {
38
+ writeJSON(resolve(copilotDir, 'rounds.json'), rounds);
39
+ }
40
+
41
+ /**
42
+ * Archive the current round: count results, save summary
43
+ */
44
+ function archiveCurrentRound(copilotDir, tasks, roundNumber) {
45
+ const completed = tasks.tasks.filter(t => t.status === 'completed');
46
+ const blocked = tasks.tasks.filter(t => t.status === 'blocked');
47
+ const skipped = tasks.tasks.filter(t => t.status === 'skipped');
48
+
49
+ // Build a text summary of what was completed
50
+ const completedSummary = completed.map(t =>
51
+ `- Task #${t.id}: ${t.title}`
52
+ ).join('\n');
53
+
54
+ const blockedSummary = blocked.length > 0
55
+ ? '\n\nBlocked tasks:\n' + blocked.map(t => `- Task #${t.id}: ${t.title}`).join('\n')
56
+ : '';
57
+
58
+ const summary = `Round ${roundNumber} completed ${completed.length}/${tasks.total} tasks.\n\nCompleted:\n${completedSummary}${blockedSummary}`;
59
+
60
+ const roundEntry = {
61
+ round: roundNumber,
62
+ started_at: tasks.tasks[0]?.started_at || new Date().toISOString(),
63
+ completed_at: new Date().toISOString(),
64
+ tasks_total: tasks.total,
65
+ tasks_completed: completed.length,
66
+ tasks_blocked: blocked.length,
67
+ tasks_skipped: skipped.length,
68
+ summary,
69
+ // Store completed task titles + descriptions for gap analysis
70
+ completed_features: completed.map(t => ({
71
+ title: t.title,
72
+ description: t.description,
73
+ acceptance: t.acceptance,
74
+ })),
75
+ };
76
+
77
+ // Archive tasks.json → tasks_round_N.json
78
+ const archivePath = resolve(copilotDir, `tasks_round_${roundNumber}.json`);
79
+ copyFileSync(resolve(copilotDir, 'tasks.json'), archivePath);
80
+ log.dim(` Archived to tasks_round_${roundNumber}.json`);
81
+
82
+ return roundEntry;
83
+ }
84
+
85
+ /**
86
+ * Build the gap analysis + next-round planning prompt
87
+ */
88
+ function buildEvolvePrompt(prdContent, rounds, copilotDir) {
89
+ // Collect all completed features across all rounds
90
+ const allCompleted = [];
91
+ for (const r of rounds.rounds) {
92
+ if (r.completed_features) {
93
+ for (const f of r.completed_features) {
94
+ allCompleted.push(f);
95
+ }
96
+ }
97
+ }
98
+
99
+ const completedSection = allCompleted.length > 0
100
+ ? allCompleted.map((f, i) => `${i + 1}. **${f.title}**\n ${f.description?.substring(0, 200) || ''}`).join('\n\n')
101
+ : 'No features completed yet.';
102
+
103
+ const roundHistorySection = rounds.rounds.map(r =>
104
+ `### Round ${r.round}\n- Completed: ${r.tasks_completed}/${r.tasks_total} tasks\n- Summary: ${r.summary?.substring(0, 300) || 'N/A'}`
105
+ ).join('\n\n');
106
+
107
+ const nextRound = rounds.current_round + 1;
108
+
109
+ return `You are a senior software architect planning the next development iteration.
110
+
111
+ ## Context
112
+
113
+ This is Round ${nextRound} of an iterative PRD implementation.
114
+
115
+ ### Full PRD Document
116
+
117
+ ${prdContent}
118
+
119
+ ### Development History
120
+
121
+ ${roundHistorySection}
122
+
123
+ ### Features Already Completed (across all rounds)
124
+
125
+ ${completedSection}
126
+
127
+ ## Your Task
128
+
129
+ 1. **Gap Analysis**: Compare the PRD requirements against the completed features. Identify ALL remaining features, improvements, and details that have NOT been implemented yet.
130
+
131
+ 2. **Priority Planning**: From the gaps identified, select the most important batch for Round ${nextRound}. Consider:
132
+ - Dependencies (build foundations before features)
133
+ - User value (core features before polish)
134
+ - Technical feasibility (group related changes)
135
+ - Moderate batch size (8-15 tasks per round)
136
+
137
+ 3. **Generate tasks.json**: Output the next round's task plan.
138
+
139
+ ## Output Format
140
+
141
+ Write the result to \`.codex-copilot/tasks.json\` in the following format:
142
+
143
+ \`\`\`json
144
+ {
145
+ "project": "Project Name",
146
+ "round": ${nextRound},
147
+ "total": N,
148
+ "tasks": [
149
+ {
150
+ "id": 1,
151
+ "title": "Task title (brief)",
152
+ "description": "Detailed task description with specific features and technical details",
153
+ "acceptance": ["Acceptance criteria 1", "Acceptance criteria 2"],
154
+ "branch": "feature/${String(nextRound).padStart(2, '0')}01-task-slug",
155
+ "status": "pending",
156
+ "depends_on": []
157
+ }
158
+ ]
159
+ }
160
+ \`\`\`
161
+
162
+ ## Important Rules
163
+ 1. Do NOT re-implement features that are already completed
164
+ 2. Branch names should be prefixed with the round number: feature/${String(nextRound).padStart(2, '0')}XX-slug
165
+ 3. Each task must be specific and actionable (not vague like "improve performance")
166
+ 4. Include detailed descriptions that reference the PRD's specific requirements
167
+ 5. If the PRD has been fully implemented, output an empty tasks array with a comment
168
+ `;
169
+ }
170
+
171
+ export async function evolve(projectDir) {
172
+ log.title('🔄 Multi-Round PRD Evolution');
173
+ log.blank();
174
+
175
+ const copilotDir = resolve(projectDir, '.codex-copilot');
176
+ const tasksPath = resolve(copilotDir, 'tasks.json');
177
+ const configPath = resolve(copilotDir, 'config.json');
178
+
179
+ // Validate project state
180
+ if (!existsSync(copilotDir) || !existsSync(configPath)) {
181
+ log.error('Project not initialized. Run: codex-copilot init');
182
+ closePrompt();
183
+ process.exit(1);
184
+ }
185
+
186
+ const config = readJSON(configPath);
187
+ const providerId = config.provider || 'codex-cli';
188
+ const rounds = loadRounds(copilotDir);
189
+
190
+ // ===== Step 1: Archive current round =====
191
+ if (existsSync(tasksPath)) {
192
+ const tasks = readJSON(tasksPath);
193
+ const hasCompletedTasks = tasks.tasks?.some(t => t.status === 'completed');
194
+ const roundNumber = tasks.round || rounds.current_round || 1;
195
+
196
+ // Check if this round was already archived (prevent duplicate archiving)
197
+ const archiveExists = existsSync(resolve(copilotDir, `tasks_round_${roundNumber}.json`));
198
+ const alreadyInHistory = rounds.rounds.some(r => r.round === roundNumber);
199
+
200
+ if (hasCompletedTasks && !archiveExists && !alreadyInHistory) {
201
+ log.step(`Step 1: Archiving Round ${roundNumber}...`);
202
+
203
+ const roundEntry = archiveCurrentRound(copilotDir, tasks, roundNumber);
204
+ rounds.rounds.push(roundEntry);
205
+ rounds.current_round = roundNumber;
206
+ saveRounds(copilotDir, rounds);
207
+
208
+ const pct = Math.round((roundEntry.tasks_completed / roundEntry.tasks_total) * 100);
209
+ log.info(` Round ${roundNumber}: ${roundEntry.tasks_completed}/${roundEntry.tasks_total} tasks (${pct}%)`);
210
+ log.blank();
211
+ } else if (hasCompletedTasks && (archiveExists || alreadyInHistory)) {
212
+ log.dim(` Round ${roundNumber} already archived — skipping`);
213
+ // Ensure rounds.current_round is up to date
214
+ if (rounds.current_round < roundNumber) {
215
+ rounds.current_round = roundNumber;
216
+ saveRounds(copilotDir, rounds);
217
+ }
218
+ log.blank();
219
+ } else if (rounds.current_round === 0) {
220
+ log.warn('No completed tasks found. Starting from Round 1.');
221
+ rounds.current_round = 0;
222
+ saveRounds(copilotDir, rounds);
223
+ log.blank();
224
+ }
225
+ }
226
+
227
+ // ===== Step 2 + 3: Gap analysis & next-round planning =====
228
+ const nextRound = rounds.current_round + 1;
229
+ log.step(`Step 2: Planning Round ${nextRound} (Gap Analysis + Task Planning)...`);
230
+ log.blank();
231
+
232
+ // Read PRD
233
+ let prdContent;
234
+ try {
235
+ prdContent = readPRD(config.prd_path);
236
+ } catch {
237
+ log.error(`Cannot read PRD: ${config.prd_path}`);
238
+ closePrompt();
239
+ process.exit(1);
240
+ }
241
+
242
+ // Build the evolve prompt
243
+ const evolvePrompt = buildEvolvePrompt(prdContent, rounds, copilotDir);
244
+ const promptPath = resolve(copilotDir, 'evolve-prompt.md');
245
+ writeFileSync(promptPath, evolvePrompt);
246
+ log.info(` Evolve prompt saved: .codex-copilot/evolve-prompt.md`);
247
+ log.blank();
248
+
249
+ // Execute via AI provider
250
+ log.step(`Invoking AI to analyze gaps and plan Round ${nextRound}...`);
251
+ const ok = await provider.executePrompt(providerId, promptPath, projectDir);
252
+
253
+ if (!ok) {
254
+ log.error('AI invocation failed. You can manually edit the prompt and retry.');
255
+ closePrompt();
256
+ process.exit(1);
257
+ }
258
+
259
+ // ===== Step 4: Validate new tasks.json =====
260
+ if (!existsSync(tasksPath)) {
261
+ log.error('AI did not generate tasks.json. Check the evolve prompt output.');
262
+ closePrompt();
263
+ process.exit(1);
264
+ }
265
+
266
+ let newTasks;
267
+ try {
268
+ newTasks = readJSON(tasksPath);
269
+ } catch {
270
+ log.error('Generated tasks.json is invalid JSON.');
271
+ closePrompt();
272
+ process.exit(1);
273
+ }
274
+
275
+ if (!newTasks.tasks || newTasks.tasks.length === 0) {
276
+ log.blank();
277
+ log.title('🎉 PRD fully implemented!');
278
+ log.info('All features from the PRD have been built across all rounds.');
279
+ log.info(`Total rounds: ${rounds.current_round}`);
280
+
281
+ // Show round history
282
+ for (const r of rounds.rounds) {
283
+ log.dim(` Round ${r.round}: ${r.tasks_completed}/${r.tasks_total} tasks`);
284
+ }
285
+
286
+ closePrompt();
287
+ return;
288
+ }
289
+
290
+ // Update round tracking
291
+ rounds.current_round = nextRound;
292
+ saveRounds(copilotDir, rounds);
293
+
294
+ // Reset checkpoint for fresh round
295
+ const statePath = resolve(copilotDir, 'state.json');
296
+ writeJSON(statePath, {
297
+ current_task: 0,
298
+ phase: null,
299
+ phase_step: null,
300
+ current_pr: null,
301
+ review_round: 0,
302
+ branch: null,
303
+ status: 'initialized',
304
+ last_updated: new Date().toISOString(),
305
+ });
306
+
307
+ log.blank();
308
+ log.title(`✅ Round ${nextRound} planned — ${newTasks.tasks.length} tasks`);
309
+ log.blank();
310
+
311
+ for (const t of newTasks.tasks) {
312
+ log.dim(` ${t.id}. ${t.title}`);
313
+ }
314
+ log.blank();
315
+
316
+ // ===== Step 5: Auto-start run =====
317
+ log.step(`Starting Round ${nextRound} execution...`);
318
+ log.blank();
319
+
320
+ await run(projectDir);
321
+ }
@@ -93,6 +93,22 @@ export async function run(projectDir) {
93
93
  log.info(`⏩ Resuming task #${state.current_task} from: ${state.phase} → ${state.phase_step}`);
94
94
  }
95
95
 
96
+ // ===== Auto-retry blocked tasks =====
97
+ // On each run, reset blocked tasks to pending so they're retried in order.
98
+ // Checkpoint is NOT cleared — it stays at the last position for non-blocked tasks.
99
+ const blockedTasks = tasks.tasks.filter(t => t.status === 'blocked');
100
+ if (blockedTasks.length > 0) {
101
+ log.blank();
102
+ log.info(`🔄 Auto-retrying ${blockedTasks.length} blocked task(s)...`);
103
+ for (const bt of blockedTasks) {
104
+ bt.status = 'pending';
105
+ bt.retry_count = (bt.retry_count || 0) + 1;
106
+ bt._retrying = true; // Flag: don't skip even if below checkpoint
107
+ log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1})`);
108
+ }
109
+ writeJSON(tasksPath, tasks);
110
+ }
111
+
96
112
  const completedCount = tasks.tasks.filter(t => t.status === 'completed').length;
97
113
  log.blank();
98
114
  progressBar(completedCount, tasks.total, `${completedCount}/${tasks.total} tasks done`);
@@ -103,9 +119,12 @@ export async function run(projectDir) {
103
119
  // Skip fully completed tasks
104
120
  if (task.status === 'completed' || task.status === 'skipped') continue;
105
121
 
106
- // Skip tasks whose ID is below the completed threshold (and not the resuming task)
122
+ // Skip tasks whose ID is below the checkpoint (unless retrying a blocked task)
107
123
  const isResumingTask = state.current_task === task.id && state.phase;
108
- if (task.id < state.current_task && !isResumingTask) continue;
124
+ if (task.id < state.current_task && !isResumingTask && !task._retrying) continue;
125
+
126
+ // Clean up retry flag (used only to bypass checkpoint skip)
127
+ delete task._retrying;
109
128
 
110
129
  log.blank();
111
130
  log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
@@ -217,6 +236,17 @@ export async function run(projectDir) {
217
236
  log.blank();
218
237
  process.removeListener('SIGINT', gracefulShutdown);
219
238
  process.removeListener('SIGTERM', gracefulShutdown);
239
+
240
+ // ===== Auto-evolve: trigger next round if enabled =====
241
+ if (config.auto_evolve !== false) {
242
+ log.blank();
243
+ log.info('🔄 Auto-evolving to next round...');
244
+ log.blank();
245
+ const { evolve } = await import('./evolve.js');
246
+ await evolve(projectDir);
247
+ return; // evolve() calls run() internally
248
+ }
249
+
220
250
  closePrompt();
221
251
  }
222
252
 
@@ -24,6 +24,24 @@ export async function status(projectDir) {
24
24
  log.title(`📊 Project: ${tasks.project}`);
25
25
  log.blank();
26
26
 
27
+ // Round info
28
+ const roundsPath = resolve(projectDir, '.codex-copilot/rounds.json');
29
+ if (existsSync(roundsPath)) {
30
+ try {
31
+ const rounds = JSON.parse(readFileSync(roundsPath, 'utf-8'));
32
+ const currentRound = tasks.round || rounds.current_round || 1;
33
+ const totalCompleted = rounds.rounds.reduce((s, r) => s + r.tasks_completed, 0);
34
+ log.info(`📦 Round: ${currentRound} | Total across all rounds: ${totalCompleted} tasks`);
35
+ if (rounds.rounds.length > 0) {
36
+ for (const r of rounds.rounds) {
37
+ const pct = Math.round((r.tasks_completed / r.tasks_total) * 100);
38
+ log.dim(` Round ${r.round}: ${r.tasks_completed}/${r.tasks_total} (${pct}%)`);
39
+ }
40
+ }
41
+ log.blank();
42
+ } catch { /* ignore parse errors */ }
43
+ }
44
+
27
45
  // Progress bar
28
46
  const completed = tasks.tasks.filter(t => t.status === 'completed').length;
29
47
  const inProgress = tasks.tasks.filter(t => t.status === 'in_progress' || t.status === 'developed').length;
@@ -0,0 +1,329 @@
1
+ /**
2
+ * codex-copilot usage - Show token usage stats for recent AI sessions
3
+ *
4
+ * Data sources:
5
+ * Codex CLI: ~/.codex/state_5.sqlite (threads) + rollout JSONL (token breakdown)
6
+ * Cursor: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
7
+ * Claude Code: ~/.claude/stats-cache.json
8
+ * Antigravity: (no local usage data available)
9
+ */
10
+
11
+ import { execSync } from 'child_process';
12
+ import { readFileSync, existsSync, readdirSync } from 'fs';
13
+ import { resolve, join } from 'path';
14
+ import { log } from '../utils/logger.js';
15
+
16
+ const HOME = process.env.HOME || process.env.USERPROFILE || '~';
17
+
18
+ // ─── Helpers ─────────────────────────────────────────────
19
+
20
+ function formatTokens(n) {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
23
+ return String(n);
24
+ }
25
+
26
+ function padRight(s, len) {
27
+ const str = String(s);
28
+ return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length);
29
+ }
30
+
31
+ function padLeft(s, len) {
32
+ const str = String(s);
33
+ return str.length >= len ? str.substring(0, len) : ' '.repeat(len - str.length) + str;
34
+ }
35
+
36
+ function printTable(headers, rows, colWidths, alignRight = []) {
37
+ const topBorder = ' ┌─' + colWidths.map(w => '─'.repeat(w)).join('─┬─') + '─┐';
38
+ const headSep = ' ├─' + colWidths.map(w => '─'.repeat(w)).join('─┼─') + '─┤';
39
+ const botBorder = ' └─' + colWidths.map(w => '─'.repeat(w)).join('─┴─') + '─┘';
40
+
41
+ const headerLine = headers.map((h, i) => padRight(h, colWidths[i])).join(' │ ');
42
+ console.log(topBorder);
43
+ console.log(` │ ${headerLine} │`);
44
+ console.log(headSep);
45
+
46
+ for (const row of rows) {
47
+ const cells = row.map((cell, i) =>
48
+ alignRight.includes(i) ? padLeft(String(cell), colWidths[i]) : padRight(String(cell), colWidths[i])
49
+ );
50
+ console.log(` │ ${cells.join(' │ ')} │`);
51
+ }
52
+ console.log(botBorder);
53
+ }
54
+
55
+ function safeExec(cmd) {
56
+ try {
57
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
58
+ } catch { return ''; }
59
+ }
60
+
61
+ // ─── Codex CLI ───────────────────────────────────────────
62
+
63
+ function getCodexAccount() {
64
+ const authPath = resolve(HOME, '.codex/auth.json');
65
+ if (!existsSync(authPath)) return { email: null, sub: null };
66
+
67
+ try {
68
+ const auth = JSON.parse(readFileSync(authPath, 'utf-8'));
69
+ const token = auth.tokens?.access_token || '';
70
+ if (token.startsWith('eyJ')) {
71
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
72
+ return { email: payload.email || null, sub: payload.sub || null };
73
+ }
74
+ return { email: null, sub: null };
75
+ } catch { return { email: null, sub: null }; }
76
+ }
77
+
78
+ function getCodexPlan() {
79
+ // Get plan_type from the most recent rollout's token_count event
80
+ const sessionsDir = resolve(HOME, '.codex/sessions');
81
+ if (!existsSync(sessionsDir)) return null;
82
+
83
+ try {
84
+ // Find the most recent rollout file
85
+ const raw = safeExec(`find "${sessionsDir}" -name "rollout-*.jsonl" -type f | sort -r | head -1`);
86
+ if (!raw) return null;
87
+
88
+ const planLine = safeExec(`grep '"plan_type"' "${raw}" | tail -1`);
89
+ if (!planLine) return null;
90
+
91
+ const d = JSON.parse(planLine);
92
+ return d?.payload?.rate_limits?.plan_type || null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ function getCodexSessions(limit = 3) {
97
+ const dbPath = resolve(HOME, '.codex/state_5.sqlite');
98
+ if (!existsSync(dbPath)) return [];
99
+
100
+ try {
101
+ const query = `SELECT id, replace(replace(substr(title,1,50),char(10),' '),char(13),' ') as title, tokens_used, model, datetime(created_at,'unixepoch','localtime') as created, datetime(updated_at,'unixepoch','localtime') as updated, cwd, rollout_path FROM threads WHERE archived=0 ORDER BY updated_at DESC LIMIT ${limit}`;
102
+
103
+ const raw = safeExec(`sqlite3 -json "${dbPath}" "${query}"`);
104
+ if (!raw) return [];
105
+
106
+ return JSON.parse(raw).map(r => ({
107
+ id: r.id,
108
+ title: (r.title || '').substring(0, 50),
109
+ tokens: r.tokens_used || 0,
110
+ model: r.model || 'unknown',
111
+ created: r.created || '',
112
+ updated: r.updated || '',
113
+ cwd: r.cwd || '',
114
+ rolloutPath: r.rollout_path || '',
115
+ }));
116
+ } catch { return []; }
117
+ }
118
+
119
+ function getCodexTokenBreakdown(rolloutPath) {
120
+ if (!rolloutPath || !existsSync(rolloutPath)) return null;
121
+
122
+ try {
123
+ // Get the last token_count event with info (has cumulative totals)
124
+ const raw = safeExec(`grep '"token_count"' "${rolloutPath}" | grep '"total_token_usage"' | tail -1`);
125
+ if (!raw) return null;
126
+
127
+ const d = JSON.parse(raw);
128
+ const usage = d?.payload?.info?.total_token_usage;
129
+ const limits = d?.payload?.rate_limits;
130
+ if (!usage) return null;
131
+
132
+ return {
133
+ input: usage.input_tokens || 0,
134
+ cached: usage.cached_input_tokens || 0,
135
+ output: usage.output_tokens || 0,
136
+ reasoning: usage.reasoning_output_tokens || 0,
137
+ total: usage.total_tokens || 0,
138
+ rateLimits: limits ? {
139
+ primaryUsed: limits.primary?.used_percent,
140
+ secondaryUsed: limits.secondary?.used_percent,
141
+ primaryResets: limits.primary?.resets_at,
142
+ secondaryResets: limits.secondary?.resets_at,
143
+ } : null,
144
+ };
145
+ } catch { return null; }
146
+ }
147
+
148
+ // ─── Cursor ──────────────────────────────────────────────
149
+
150
+ function getCursorAccount() {
151
+ const dbPath = resolve(HOME, 'Library/Application Support/Cursor/User/globalStorage/state.vscdb');
152
+ if (!existsSync(dbPath)) return null;
153
+
154
+ try {
155
+ const email = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/cachedEmail'"`);
156
+ const plan = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/stripeMembershipType'"`);
157
+ if (!email) return null;
158
+ return { email, plan: plan || 'free' };
159
+ } catch { return null; }
160
+ }
161
+
162
+ // ─── Claude Code ─────────────────────────────────────────
163
+
164
+ function getClaudeAccount() {
165
+ // Claude Code doesn't store email locally in an easily accessible way
166
+ const settingsPath = resolve(HOME, '.claude/settings.json');
167
+ if (!existsSync(settingsPath)) return null;
168
+ try {
169
+ const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
170
+ return s.email || null;
171
+ } catch { return null; }
172
+ }
173
+
174
+ function getClaudeStats(days = 7) {
175
+ const statsPath = resolve(HOME, '.claude/stats-cache.json');
176
+ if (!existsSync(statsPath)) return null;
177
+
178
+ try {
179
+ const data = JSON.parse(readFileSync(statsPath, 'utf-8'));
180
+ if (!data.dailyActivity?.length) return null;
181
+
182
+ const recent = data.dailyActivity.slice(-days).filter(d => d.messageCount > 0);
183
+ return {
184
+ days: recent,
185
+ total: recent.reduce((s, d) => ({
186
+ messages: s.messages + (d.messageCount || 0),
187
+ sessions: s.sessions + (d.sessionCount || 0),
188
+ toolCalls: s.toolCalls + (d.toolCallCount || 0),
189
+ }), { messages: 0, sessions: 0, toolCalls: 0 }),
190
+ };
191
+ } catch { return null; }
192
+ }
193
+
194
+ // ─── Antigravity / Gemini ────────────────────────────────
195
+
196
+ function getAntigravityInfo() {
197
+ // Antigravity doesn't expose local usage data currently
198
+ return null;
199
+ }
200
+
201
+ // ─── Main ────────────────────────────────────────────────
202
+
203
+ export async function usage() {
204
+ log.title('📊 AI Usage Report');
205
+ log.blank();
206
+
207
+ let hasAny = false;
208
+
209
+ // ═══ Codex CLI ═══
210
+ const codexAccount = getCodexAccount();
211
+ const codexPlan = getCodexPlan();
212
+ const codexSessions = getCodexSessions(3);
213
+
214
+ if (codexSessions.length > 0) {
215
+ hasAny = true;
216
+ const accountDisplay = codexAccount.email || codexAccount.sub || 'N/A';
217
+ console.log(`\x1b[36m ◆ Codex CLI\x1b[0m`);
218
+ console.log(` Account: ${accountDisplay}`);
219
+ if (codexPlan) console.log(` Plan: ${codexPlan}`);
220
+ log.blank();
221
+
222
+ // Session overview table
223
+ const rows = codexSessions.map(s => {
224
+ const project = s.cwd.split('/').pop() || s.cwd;
225
+ const title = s.title.length > 36 ? s.title.substring(0, 33) + '...' : s.title;
226
+ return [title, s.model, formatTokens(s.tokens), project, s.updated.substring(0, 16)];
227
+ });
228
+
229
+ printTable(
230
+ ['Session', 'Model', 'Tokens', 'Project', 'Last Active'],
231
+ rows,
232
+ [36, 16, 10, 14, 16],
233
+ [2],
234
+ );
235
+
236
+ const totalTokens = codexSessions.reduce((sum, s) => sum + s.tokens, 0);
237
+ log.info(` Total (${codexSessions.length} sessions): ${formatTokens(totalTokens)} tokens`);
238
+ log.blank();
239
+
240
+ // Token breakdown per session
241
+ for (const session of codexSessions) {
242
+ const breakdown = getCodexTokenBreakdown(session.rolloutPath);
243
+ if (!breakdown) continue;
244
+
245
+ const title = session.title.length > 50 ? session.title.substring(0, 47) + '...' : session.title;
246
+ console.log(`\x1b[2m ┌ ${title}\x1b[0m`);
247
+
248
+ const bRows = [
249
+ ['Input', formatTokens(breakdown.input)],
250
+ [' ↳ Cached', formatTokens(breakdown.cached)],
251
+ ['Output', formatTokens(breakdown.output)],
252
+ [' ↳ Reasoning', formatTokens(breakdown.reasoning)],
253
+ ['Total', formatTokens(breakdown.total)],
254
+ ];
255
+ for (const [label, val] of bRows) {
256
+ console.log(`\x1b[2m │ ${padRight(label, 16)} ${padLeft(val, 10)}\x1b[0m`);
257
+ }
258
+
259
+ if (breakdown.rateLimits) {
260
+ const rl = breakdown.rateLimits;
261
+ const resetPrimary = rl.primaryResets ? new Date(rl.primaryResets * 1000).toLocaleString() : 'N/A';
262
+ console.log(`\x1b[2m │ Quota: ${rl.primaryUsed ?? '?'}% (5h) / ${rl.secondaryUsed ?? '?'}% (7d) Resets: ${resetPrimary}\x1b[0m`);
263
+ }
264
+ console.log(`\x1b[2m └──\x1b[0m`);
265
+ }
266
+ log.blank();
267
+ }
268
+
269
+ // ═══ Cursor ═══
270
+ const cursorAccount = getCursorAccount();
271
+ if (cursorAccount) {
272
+ hasAny = true;
273
+ console.log(`\x1b[33m ◆ Cursor\x1b[0m`);
274
+ console.log(` Account: ${cursorAccount.email}`);
275
+ console.log(` Plan: ${cursorAccount.plan}`);
276
+ console.log(`\x1b[2m Note: Cursor does not expose per-session token usage locally.\x1b[0m`);
277
+ console.log(`\x1b[2m Check usage at: https://www.cursor.com/settings\x1b[0m`);
278
+ log.blank();
279
+ }
280
+
281
+ // ═══ Claude Code ═══
282
+ const claudeStats = getClaudeStats(7);
283
+ const claudeAccount = getClaudeAccount();
284
+
285
+ if (claudeStats) {
286
+ hasAny = true;
287
+ console.log(`\x1b[35m ◆ Claude Code\x1b[0m`);
288
+ console.log(` Account: ${claudeAccount || 'N/A (check ~/.claude/settings.json)'}`);
289
+ log.blank();
290
+
291
+ const rows = claudeStats.days.slice(-5).map(d => [
292
+ d.date, String(d.sessionCount), String(d.messageCount), String(d.toolCallCount),
293
+ ]);
294
+
295
+ if (rows.length > 0) {
296
+ printTable(
297
+ ['Date', 'Sessions', 'Messages', 'Tool Calls'],
298
+ rows,
299
+ [12, 10, 10, 12],
300
+ [1, 2, 3],
301
+ );
302
+ }
303
+
304
+ log.info(` Total (7d): ${claudeStats.total.messages} msgs, ${claudeStats.total.toolCalls} tool calls, ${claudeStats.total.sessions} sessions`);
305
+ console.log(`\x1b[2m Note: Claude Code tracks message/tool counts, not token counts locally.\x1b[0m`);
306
+ console.log(`\x1b[2m Token-level billing: https://console.anthropic.com/settings/billing\x1b[0m`);
307
+ log.blank();
308
+ }
309
+
310
+ // ═══ Antigravity / Gemini ═══
311
+ const agInfo = getAntigravityInfo();
312
+ if (!agInfo) {
313
+ console.log(`\x1b[2m ◆ Antigravity / Gemini: No local usage data available.\x1b[0m`);
314
+ console.log(`\x1b[2m Check: https://console.cloud.google.com/billing\x1b[0m`);
315
+ log.blank();
316
+ }
317
+
318
+ // ═══ Codex CLI vs Client Shared Pool Note ═══
319
+ if (codexSessions.length > 0) {
320
+ log.blank();
321
+ console.log(`\x1b[2m ℹ Codex CLI and the Codex VS Code extension share the same OpenAI account token pool.\x1b[0m`);
322
+ console.log(`\x1b[2m Usage shown above is CLI-only. Combined usage: https://platform.openai.com/usage\x1b[0m`);
323
+ }
324
+
325
+ if (!hasAny) {
326
+ log.warn('No AI tool usage data found.');
327
+ log.info('Supported: Codex CLI, Cursor, Claude Code');
328
+ }
329
+ }
@@ -306,14 +306,73 @@ export function isPrivateRepo(cwd) {
306
306
  }
307
307
 
308
308
  /**
309
- * Request bots to re-review the PR by posting a /review comment.
310
- * Triggers Gemini Code Assist and similar bots to run a fresh review.
309
+ * Request bots to re-review the PR.
310
+ * Auto-detects which review bots have previously commented on the PR
311
+ * and triggers only those. Falls back to all known bot triggers.
311
312
  */
312
313
  export function requestReReview(cwd, prNumber) {
314
+ // Known review bots and their re-review trigger commands
315
+ const REVIEW_BOTS = [
316
+ { login: 'gemini-code-assist', trigger: '/gemini review' },
317
+ { login: 'coderabbitai', trigger: '@coderabbitai review' },
318
+ { login: 'sourcery-ai', trigger: '@sourcery-ai review' },
319
+ { login: 'deepsource', trigger: '@deepsource review' },
320
+ { login: 'sweep-ai', trigger: 'sweep: review' },
321
+ { login: 'openai-codex', trigger: '@codex review' },
322
+ { login: 'claude', trigger: '@claude review' },
323
+ { login: 'copilot', trigger: '@copilot review' },
324
+ ];
325
+
313
326
  try {
314
327
  const num = validatePRNumber(prNumber);
315
- gh(`pr comment ${num} --body "/review"`, cwd);
316
- return true;
328
+
329
+ // Detect which bots have previously interacted with this PR
330
+ const reviews = getReviews(cwd, num);
331
+ const comments = getIssueComments(cwd, num);
332
+ const reviewComments = getReviewComments(cwd, num);
333
+
334
+ const allLogins = new Set();
335
+ for (const r of reviews) if (r.user?.login) allLogins.add(r.user.login);
336
+ for (const c of comments) if (c.user?.login) allLogins.add(c.user.login);
337
+ for (const c of reviewComments) if (c.user?.login) allLogins.add(c.user.login);
338
+
339
+ // Find matching bots that have been active on this PR
340
+ const activeBots = REVIEW_BOTS.filter(bot =>
341
+ [...allLogins].some(login => login.includes(bot.login))
342
+ );
343
+
344
+ // Determine which triggers to fire
345
+ const triggers = activeBots.length > 0
346
+ ? activeBots.filter(b => b.trigger).map(b => b.trigger)
347
+ : REVIEW_BOTS.filter(b => b.trigger).map(b => b.trigger).slice(0, 1); // fallback: try gemini
348
+
349
+ // Fire triggers as separate comments
350
+ let triggered = 0;
351
+ for (const trigger of triggers) {
352
+ try {
353
+ gh(`pr comment ${num} --body "${trigger}"`, cwd);
354
+ triggered++;
355
+ } catch { /* individual trigger failure is non-critical */ }
356
+ }
357
+
358
+ // Also try to request re-review from previous human reviewers via GitHub API
359
+ try {
360
+ const prReviews = ghJSON(`pr view ${num} --json reviews --jq '.reviews[].author.login'`, cwd);
361
+ if (prReviews && typeof prReviews === 'string') {
362
+ const logins = [...new Set(prReviews.trim().split('\n').filter(l => l && !l.includes('[bot]')))];
363
+ if (logins.length > 0) {
364
+ const owner = execSync('gh repo view --json owner --jq .owner.login', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
365
+ const repo = execSync('gh repo view --json name --jq .name', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
366
+ execSync(`gh api repos/${owner}/${repo}/pulls/${num}/requested_reviewers -f "reviewers[]=${logins[0]}" -X POST`, {
367
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
368
+ });
369
+ }
370
+ }
371
+ } catch {
372
+ // Non-critical: human re-review request failed
373
+ }
374
+
375
+ return triggered > 0;
317
376
  } catch {
318
377
  return false;
319
378
  }
@@ -225,7 +225,7 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
225
225
  });
226
226
 
227
227
  let lastFile = '';
228
- let statusText = 'Working...';
228
+ let statusText = command.substring(0, 80);
229
229
  let lineBuffer = '';
230
230
  const FILE_EXT = /(?:^|\s|['"|(/])([a-zA-Z0-9_.\/-]+\.(?:rs|ts|js|jsx|tsx|py|go|toml|yaml|yml|json|md|css|html|sh|sql|prisma|vue|svelte))\b/;
231
231
 
@@ -234,7 +234,7 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
234
234
  let spinIdx = 0;
235
235
  const spinTimer = setInterval(() => {
236
236
  const frame = SPINNER[spinIdx % SPINNER.length];
237
- process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m ${statusText}`);
237
+ process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m ${statusText}`);
238
238
  spinIdx++;
239
239
  }, 80);
240
240