feat(bf-3xp): add bead workspace scanner + project breakdown in /api/productivity
Some checks are pending
CI / test (18.x) (push) Waiting to run
CI / test (20.x) (push) Waiting to run
CI / test (22.x) (push) Waiting to run

Phase 9 implementation: Bead workspace scanner and project breakdown.

- Add beadWorkspaceScanner.ts to scan .beads/issues.jsonl files
- Count CLOSED beads per project, deriving project from bead id prefix
- Use close_reason/closed_at/assignee for productivity tracking
- Add configurable workspace list in config.ts (WorkspaceConfig interface)
- Extend GET /api/productivity to add byProject array
- Add By Project section to ProductivityPanel React component
- Add tests for bead workspace scanner

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-22 15:34:20 -04:00
parent d79a407468
commit 5e029c142c
5 changed files with 612 additions and 2 deletions

View file

@ -0,0 +1,230 @@
/**
* Tests for Bead Workspace Scanner
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { scanBeadWorkspaces, getProjectStats, getClosedBeadsForWorkspace, ProjectStats } from './beadWorkspaceScanner.js';
import { WorkspaceConfig } from './config.js';
// Mock fs module
vi.mock('fs');
const mockFs = vi.mocked(fs);
// Mock config module
vi.mock('./config.js', () => ({
loadWorkspaces: vi.fn(),
}));
describe('Bead Workspace Scanner', () => {
const mockWorkspaces: WorkspaceConfig[] = [
{
path: '/home/coding/FABRIC',
name: 'FABRIC',
prefix: 'bf',
},
{
path: '/home/coding/spaxel',
name: 'spaxel',
prefix: 'bf',
},
];
const mockBeadsData = {
'/home/coding/FABRIC/.beads/issues.jsonl': [
JSON.stringify({
id: 'bf-1abc',
title: 'Test bead 1',
status: 'closed',
closed_at: '2026-05-20T10:00:00Z',
assignee: 'claude-code-glm-4.7-alpha',
source_repo: '.',
}),
JSON.stringify({
id: 'bf-2def',
title: 'Test bead 2',
status: 'open',
source_repo: '.',
}),
JSON.stringify({
id: 'bf-3ghi',
title: 'Test bead 3',
status: 'closed',
closed_at: '2026-05-21T14:30:00Z',
assignee: 'claude-code-glm-4.7-beta',
source_repo: '.',
}),
].join('\n'),
'/home/coding/spaxel/.beads/issues.jsonl': [
JSON.stringify({
id: 'bf-4jkl',
title: 'Spaxel bead 1',
status: 'closed',
closed_at: '2026-05-19T08:00:00Z',
assignee: 'claude-code-glm-4.7-gamma',
source_repo: '.',
}),
JSON.stringify({
id: 'bf-5mno',
title: 'Spaxel bead 2',
status: 'closed',
closed_at: '2026-05-20T12:00:00Z',
assignee: 'claude-code-glm-4.7-alpha',
source_repo: '.',
}),
].join('\n'),
};
beforeEach(() => {
vi.clearAllMocks();
// Mock loadWorkspaces to return our test workspaces
const { loadWorkspaces } = vi.mocked(await import('./config.js'));
vi.mocked(loadWorkspaces).mockReturnValue(mockWorkspaces);
// Mock fs.existsSync to return true for our test files
mockFs.existsSync.mockImplementation((filePath) => {
const pathStr = String(filePath);
return Object.keys(mockBeadsData).includes(pathStr);
});
// Mock fs.readFileSync to return our test data
mockFs.readFileSync.mockImplementation((filePath) => {
const pathStr = String(filePath);
if (mockBeadsData[pathStr]) {
return mockBeadsData[pathStr];
}
throw new Error(`File not found: ${pathStr}`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('scanBeadWorkspaces', () => {
it('should scan all configured workspaces', () => {
const result = scanBeadWorkspaces();
expect(result.workspacesScanned).toBe(2);
expect(result.totalBeads).toBe(5);
expect(result.totalClosed).toBe(4);
});
it('should return project stats sorted by closed count', () => {
const result = scanBeadWorkspaces();
expect(result.byProject).toHaveLength(2);
expect(result.byProject[0].name).toBe('FABRIC');
expect(result.byProject[0].closedCount).toBe(2);
expect(result.byProject[1].name).toBe('spaxel');
expect(result.byProject[1].closedCount).toBe(2);
});
it('should track assignees per project', () => {
const result = scanBeadWorkspaces();
const fabric = result.byProject.find(p => p.name === 'FABRIC');
expect(fabric?.byAssignee['claude-code-glm-4.7-alpha']).toBe(1);
expect(fabric?.byAssignee['claude-code-glm-4.7-beta']).toBe(1);
const spaxel = result.byProject.find(p => p.name === 'spaxel');
expect(spaxel?.byAssignee['claude-code-glm-4.7-gamma']).toBe(1);
expect(spaxel?.byAssignee['claude-code-glm-4.7-alpha']).toBe(1);
});
it('should track most recent closure timestamp', () => {
const result = scanBeadWorkspaces();
const fabric = result.byProject.find(p => p.name === 'FABRIC');
expect(fabric?.lastClosedAt).toBe('2026-05-21T14:30:00Z');
const spaxel = result.byProject.find(p => p.name === 'spaxel');
expect(spaxel?.lastClosedAt).toBe('2026-05-20T12:00:00Z');
});
it('should handle missing workspace directories gracefully', () => {
mockFs.existsSync.mockImplementation((filePath) => {
const pathStr = String(filePath);
return pathStr.includes('spaxel'); // Only spaxel exists
});
const result = scanBeadWorkspaces();
expect(result.workspacesScanned).toBe(2);
expect(result.byProject).toHaveLength(1);
expect(result.byProject[0].name).toBe('spaxel');
});
it('should handle invalid JSON lines gracefully', () => {
mockFs.readFileSync.mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('FABRIC')) {
return [
JSON.stringify({
id: 'bf-1abc',
title: 'Valid bead',
status: 'closed',
closed_at: '2026-05-20T10:00:00Z',
source_repo: '.',
}),
'invalid json line',
JSON.stringify({
id: 'bf-2def',
title: 'Another valid bead',
status: 'closed',
closed_at: '2026-05-21T10:00:00Z',
source_repo: '.',
}),
].join('\n');
}
return '';
});
const result = scanBeadWorkspaces();
expect(result.totalClosed).toBe(2);
});
});
describe('getProjectStats', () => {
it('should return stats for a specific project', () => {
const stats = getProjectStats('FABRIC');
expect(stats).toBeDefined();
expect(stats?.name).toBe('FABRIC');
expect(stats?.closedCount).toBe(2);
expect(stats?.prefix).toBe('bf');
});
it('should return undefined for non-existent project', () => {
const stats = getProjectStats('nonexistent');
expect(stats).toBeUndefined();
});
});
describe('getClosedBeadsForWorkspace', () => {
it('should return closed beads for a workspace', () => {
const beads = getClosedBeadsForWorkspace('/home/coding/FABRIC');
expect(beads).toHaveLength(2);
expect(beads[0].status).toBe('closed');
expect(beads[1].status).toBe('closed');
});
it('should respect limit parameter', () => {
const beads = getClosedBeadsForWorkspace('/home/coding/FABRIC', 1);
expect(beads).toHaveLength(1);
});
it('should return empty array for non-existent workspace', () => {
mockFs.existsSync.mockReturnValue(false);
const beads = getClosedBeadsForWorkspace('/nonexistent/path');
expect(beads).toEqual([]);
});
});
});

209
src/beadWorkspaceScanner.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* Bead Workspace Scanner
*
* Reads .beads/issues.jsonl files from configured workspaces
* and counts CLOSED beads per project for productivity analytics.
*/
import * as fs from 'fs';
import * as path from 'path';
import { WorkspaceConfig, loadWorkspaces } from './config.js';
/**
* Bead record from issues.jsonl
*/
interface BeadRecord {
id: string;
title: string;
description: string;
design: string;
acceptance_criteria: string;
notes: string;
status: 'open' | 'in_progress' | 'blocked' | 'completed' | 'closed' | 'deferred';
priority: number;
issue_type: string;
created_at: string;
updated_at: string;
closed_at?: string;
close_reason?: string;
assignee?: string;
source_repo: string;
compaction_level: number;
labels?: string[];
dependencies?: Array<{
issue_id: string;
depends_on_id: string;
type: string;
created_at: string;
created_by: string;
thread_id: string;
}>;
comments?: Array<{
id: number;
issue_id: string;
author: string;
text: string;
created_at: string;
}>;
}
/**
* Project productivity stats
*/
export interface ProjectStats {
/** Project name */
name: string;
/** Bead ID prefix for this project */
prefix: string;
/** Total closed beads */
closedCount: number;
/** Beads closed by assignee */
byAssignee: Record<string, number>;
/** Most recent closure timestamp */
lastClosedAt?: string;
}
/**
* Scan result summary
*/
export interface ScanResult {
/** Total workspaces scanned */
workspacesScanned: number;
/** Total beads read */
totalBeads: number;
/** Total closed beads across all projects */
totalClosed: number;
/** Project breakdown */
byProject: ProjectStats[];
}
/**
* Scan all configured workspaces and count closed beads
*/
export function scanBeadWorkspaces(): ScanResult {
const workspaces = loadWorkspaces();
const projectStats = new Map<string, ProjectStats>();
let totalBeads = 0;
let totalClosed = 0;
for (const workspace of workspaces) {
const issuesFile = path.join(workspace.path, '.beads', 'issues.jsonl');
try {
if (!fs.existsSync(issuesFile)) {
continue;
}
const content = fs.readFileSync(issuesFile, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
totalBeads++;
let bead: BeadRecord;
try {
bead = JSON.parse(line) as BeadRecord;
} catch {
// Skip invalid JSON lines
continue;
}
// Only count closed beads
if (bead.status !== 'closed') {
continue;
}
totalClosed++;
// Get or create project stats
let stats = projectStats.get(workspace.name);
if (!stats) {
stats = {
name: workspace.name,
prefix: workspace.prefix,
closedCount: 0,
byAssignee: {},
};
projectStats.set(workspace.name, stats);
}
stats.closedCount++;
// Track by assignee
const assignee = bead.assignee || 'unassigned';
stats.byAssignee[assignee] = (stats.byAssignee[assignee] || 0) + 1;
// Track most recent closure
if (bead.closed_at) {
if (!stats.lastClosedAt || bead.closed_at > stats.lastClosedAt) {
stats.lastClosedAt = bead.closed_at;
}
}
}
} catch {
// Skip workspaces that can't be read
continue;
}
}
// Convert map to array and sort by closed count
const byProject = Array.from(projectStats.values()).sort(
(a, b) => b.closedCount - a.closedCount
);
return {
workspacesScanned: workspaces.length,
totalBeads,
totalClosed,
byProject,
};
}
/**
* Get project stats for a specific project name
*/
export function getProjectStats(projectName: string): ProjectStats | undefined {
const result = scanBeadWorkspaces();
return result.byProject.find(p => p.name === projectName);
}
/**
* Get closed beads for a specific workspace
*/
export function getClosedBeadsForWorkspace(
workspacePath: string,
limit?: number
): BeadRecord[] {
const issuesFile = path.join(workspacePath, '.beads', 'issues.jsonl');
const closedBeads: BeadRecord[] = [];
try {
if (!fs.existsSync(issuesFile)) {
return closedBeads;
}
const content = fs.readFileSync(issuesFile, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
let bead: BeadRecord;
try {
bead = JSON.parse(line) as BeadRecord;
} catch {
continue;
}
if (bead.status === 'closed') {
closedBeads.push(bead);
if (limit && closedBeads.length >= limit) {
break;
}
}
}
} catch {
// Return empty on error
}
return closedBeads;
}

View file

@ -25,8 +25,96 @@ const CONFIG_FILES = {
presets: path.join(CONFIG_DIR, 'focus-presets.json'),
recentCommands: path.join(CONFIG_DIR, 'recent-commands.json'),
filterState: path.join(HOME, '.fabric-filter-state.json'),
workspaces: path.join(CONFIG_DIR, 'workspaces.json'),
};
/**
* Workspace configuration for bead scanning
*/
export interface WorkspaceConfig {
/** Workspace path (e.g., /home/coding/FABRIC) */
path: string;
/** Project name derived from path or override */
name: string;
/** Bead ID prefix for this workspace (e.g., 'bf' for global, 'kt' for kalshi-tape) */
prefix: string;
}
/**
* Load workspace configurations
*/
export function loadWorkspaces(): WorkspaceConfig[] {
try {
if (fs.existsSync(CONFIG_FILES.workspaces)) {
const data = fs.readFileSync(CONFIG_FILES.workspaces, 'utf-8');
const config = JSON.parse(data) as { workspaces: WorkspaceConfig[] };
return config.workspaces || [];
}
} catch {
// Ignore errors
}
// Default workspace list
return getDefaultWorkspaces();
}
/**
* Get default workspace list by scanning /home/coding for .beads directories
*/
function getDefaultWorkspaces(): WorkspaceConfig[] {
const workspaces: WorkspaceConfig[] = [];
const codingDir = path.join(HOME);
try {
if (!fs.existsSync(codingDir)) {
return workspaces;
}
const entries = fs.readdirSync(codingDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const projectPath = path.join(codingDir, entry.name);
const beadsDir = path.join(projectPath, '.beads');
const issuesFile = path.join(beadsDir, 'issues.jsonl');
if (fs.existsSync(issuesFile)) {
// Extract project name from directory name
const name = entry.name;
// Determine prefix from bead ID format in the file
const prefix = detectBeadPrefix(issuesFile) || 'bf';
workspaces.push({
path: projectPath,
name,
prefix,
});
}
}
} catch {
// Ignore errors
}
return workspaces;
}
/**
* Detect bead ID prefix by reading the first line of issues.jsonl
*/
function detectBeadPrefix(issuesFile: string): string | null {
try {
const content = fs.readFileSync(issuesFile, 'utf-8');
const firstLine = content.split('\n')[0];
if (!firstLine) return null;
const match = firstLine.match(/"id":"([a-z]+)-/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Show current configuration
*/
@ -43,6 +131,21 @@ function showConfig(): void {
console.log(` File: ${CONFIG_FILES.theme}`);
console.log('');
// Workspaces
console.log('Workspaces:');
const workspaces = loadWorkspaces();
if (workspaces.length === 0) {
console.log(' No workspaces configured');
} else {
console.log(` Count: ${workspaces.length}`);
workspaces.forEach(w => {
console.log(` - ${w.name} (${w.prefix}-*)`);
console.log(` Path: ${w.path}`);
});
}
console.log(` File: ${CONFIG_FILES.workspaces}`);
console.log('');
// Presets
console.log('Focus Presets:');
const presetManager = createTuiPresetManager();

View file

@ -11,9 +11,18 @@ interface WorkerStat {
beadsPerHour: number;
}
interface ProjectStat {
name: string;
prefix: string;
closedCount: number;
byAssignee: Record<string, number>;
lastClosedAt?: string;
}
interface ProductivityData {
daily: DailyCount[];
workers: WorkerStat[];
byProject: ProjectStat[];
}
interface ProductivityPanelProps {
@ -119,6 +128,7 @@ const ProductivityPanel: React.FC<ProductivityPanelProps> = ({ visible, onClose
const totalToday = data?.daily[data.daily.length - 1]?.count ?? 0;
const total30d = data?.daily.reduce((s, d) => s + d.count, 0) ?? 0;
const maxProjectCount = Math.max(...(data?.byProject.map(p => p.closedCount) || [0]), 1);
return (
<div className="analytics-panel productivity-panel">
@ -185,6 +195,60 @@ const ProductivityPanel: React.FC<ProductivityPanelProps> = ({ visible, onClose
)}
</div>
</div>
<div className="analytics-section">
<h3 className="analytics-section-title">By Project</h3>
<div className="analytics-section-body">
{data.byProject.length === 0 ? (
<p className="analytics-empty">No project data available.</p>
) : (
<table className="productivity-projects">
<thead>
<tr>
<th>Project</th>
<th>Closed Beads</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{data.byProject.map((project) => {
const width = (project.closedCount / maxProjectCount) * 100;
const assignees = Object.entries(project.byAssignee)
.sort(([, a], [, b]) => b - a)
.slice(0, 3);
return (
<tr key={project.name}>
<td className="productivity-project-name">
{project.name}
<span className="productivity-project-prefix"> ({project.prefix}-*)</span>
</td>
<td className="productivity-count">{project.closedCount}</td>
<td className="productivity-bar-cell">
<div className="productivity-bar-bg">
<div
className="productivity-bar-fill"
style={{ width: `${width}%` }}
title={`${project.closedCount} beads closed`}
/>
</div>
{assignees.length > 0 && (
<div className="productivity-assignees" title={`Top assignees: ${assignees.map(([a]) => a).join(', ')}`}>
{assignees.map(([assignee, count]) => (
<span key={assignee} className="productivity-assignee">
{assignee.split('-').pop()}: {count}
</span>
))}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>

View file

@ -24,6 +24,7 @@ import { generatePRPreview } from '../tui/utils/prPreview.js';
import { getMemoryProfiler } from '../memoryProfiler.js';
import { getRecentHeapDiff, analyzeTrend, formatTrendAsMarkdown, saveTrendReport } from '../heapDiff.js';
import { computeRetentionState, pruneLogs, formatPruneResult, PruneOptions } from '../logPruner.js';
import { scanBeadWorkspaces } from '../beadWorkspaceScanner.js';
/** Get the v8 module (available in Node.js) */
function getV8() {
@ -1096,7 +1097,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
// Productivity analytics — daily throughput + worker leaderboard
// Productivity analytics — daily throughput + worker leaderboard + project breakdown
app.get('/api/productivity', (_req: Request, res: Response) => {
try {
const now = Date.now();
@ -1133,7 +1134,10 @@ export function createWebServer(options: WebServerOptions): WebServer {
});
workers.sort((a, b) => b.beadsCompleted - a.beadsCompleted);
res.json({ daily, workers });
// Project breakdown from bead workspace scanner
const { byProject } = scanBeadWorkspaces();
res.json({ daily, workers, byProject });
} catch (err) {
res.status(500).json({ error: String(err) });
}