mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -64,12 +64,17 @@ Lightweight planner that analyzes project structure, decomposes documentation wo
|
||||
```bash
|
||||
# Get target path, project name, and root
|
||||
bash(pwd && basename "$(pwd)" && git rev-parse --show-toplevel 2>/dev/null || pwd && date +%Y%m%d-%H%M%S)
|
||||
```
|
||||
|
||||
# Create session directories (replace timestamp)
|
||||
bash(mkdir -p .workflow/active/WFS-docs-{timestamp}/.{task,process,summaries})
|
||||
```javascript
|
||||
// Create docs session (type: docs)
|
||||
SlashCommand(command="/workflow:session:start --type docs --new \"{project_name}-docs-{timestamp}\"")
|
||||
// Parse output to get sessionId
|
||||
```
|
||||
|
||||
# Create workflow-session.json (replace values)
|
||||
bash(echo '{"session_id":"WFS-docs-{timestamp}","project":"{project} documentation","status":"planning","timestamp":"2024-01-20T14:30:22+08:00","path":".","target_path":"{target_path}","project_root":"{project_root}","project_name":"{project_name}","mode":"full","tool":"gemini","cli_execute":false}' | jq '.' > .workflow/active/WFS-docs-{timestamp}/workflow-session.json)
|
||||
```bash
|
||||
# Update workflow-session.json with docs-specific fields
|
||||
bash(jq '. + {"target_path":"{target_path}","project_root":"{project_root}","project_name":"{project_name}","mode":"full","tool":"gemini","cli_execute":false}' .workflow/active/{sessionId}/workflow-session.json > tmp.json && mv tmp.json .workflow/active/{sessionId}/workflow-session.json)
|
||||
```
|
||||
|
||||
### Phase 2: Analyze Structure
|
||||
|
||||
@@ -188,8 +188,8 @@ const CATEGORIES = {
|
||||
|
||||
**Step 1: Session Creation**
|
||||
```javascript
|
||||
// Create workflow session for this review
|
||||
SlashCommand(command="/workflow:session:start \"Code review for [target_pattern]\"")
|
||||
// Create workflow session for this review (type: review)
|
||||
SlashCommand(command="/workflow:session:start --type review \"Code review for [target_pattern]\"")
|
||||
|
||||
// Parse output
|
||||
const sessionId = output.match(/SESSION_ID: (WFS-[^\s]+)/)[1];
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: start
|
||||
description: Discover existing sessions or start new workflow session with intelligent session management and conflict detection
|
||||
argument-hint: [--auto|--new] [optional: task description for new session]
|
||||
argument-hint: [--type <workflow|review|tdd|test|docs>] [--auto|--new] [optional: task description for new session]
|
||||
examples:
|
||||
- /workflow:session:start
|
||||
- /workflow:session:start --auto "implement OAuth2 authentication"
|
||||
- /workflow:session:start --new "fix login bug"
|
||||
- /workflow:session:start --type review "Code review for auth module"
|
||||
- /workflow:session:start --type tdd --auto "implement user authentication"
|
||||
- /workflow:session:start --type test --new "test payment flow"
|
||||
---
|
||||
|
||||
# Start Workflow Session (/workflow:session:start)
|
||||
@@ -17,6 +19,23 @@ Manages workflow sessions with three operation modes: discovery (manual), auto (
|
||||
1. **Project-level initialization** (first-time only): Creates `.workflow/project.json` for feature registry
|
||||
2. **Session-level initialization** (always): Creates session directory structure
|
||||
|
||||
## Session Types
|
||||
|
||||
The `--type` parameter classifies sessions for CCW dashboard organization:
|
||||
|
||||
| Type | Description | Default For |
|
||||
|------|-------------|-------------|
|
||||
| `workflow` | Standard implementation (default) | `/workflow:plan` |
|
||||
| `review` | Code review sessions | `/workflow:review-module-cycle` |
|
||||
| `tdd` | TDD-based development | `/workflow:tdd-plan` |
|
||||
| `test` | Test generation/fix sessions | `/workflow:test-fix-gen` |
|
||||
| `docs` | Documentation sessions | `/memory:docs` |
|
||||
|
||||
**Validation**: If `--type` is provided with invalid value, return error:
|
||||
```
|
||||
ERROR: Invalid session type. Valid types: workflow, review, tdd, test, docs
|
||||
```
|
||||
|
||||
## Step 0: Initialize Project State (First-time Only)
|
||||
|
||||
**Executed before all modes** - Ensures project-level state file exists by calling `/workflow:init`.
|
||||
@@ -86,8 +105,8 @@ bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.process)
|
||||
bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.task)
|
||||
bash(mkdir -p .workflow/active/WFS-implement-oauth2-auth/.summaries)
|
||||
|
||||
# Create metadata
|
||||
bash(echo '{"session_id":"WFS-implement-oauth2-auth","project":"implement OAuth2 auth","status":"planning"}' > .workflow/active/WFS-implement-oauth2-auth/workflow-session.json)
|
||||
# Create metadata (include type field, default to "workflow" if not specified)
|
||||
bash(echo '{"session_id":"WFS-implement-oauth2-auth","project":"implement OAuth2 auth","status":"planning","type":"workflow","created_at":"2024-12-04T08:00:00Z"}' > .workflow/active/WFS-implement-oauth2-auth/workflow-session.json)
|
||||
```
|
||||
|
||||
**Output**: `SESSION_ID: WFS-implement-oauth2-auth`
|
||||
@@ -143,7 +162,8 @@ bash(mkdir -p .workflow/active/WFS-fix-login-bug/.summaries)
|
||||
|
||||
### Step 3: Create Metadata
|
||||
```bash
|
||||
bash(echo '{"session_id":"WFS-fix-login-bug","project":"fix login bug","status":"planning"}' > .workflow/active/WFS-fix-login-bug/workflow-session.json)
|
||||
# Include type field from --type parameter (default: "workflow")
|
||||
bash(echo '{"session_id":"WFS-fix-login-bug","project":"fix login bug","status":"planning","type":"workflow","created_at":"2024-12-04T08:00:00Z"}' > .workflow/active/WFS-fix-login-bug/workflow-session.json)
|
||||
```
|
||||
|
||||
**Output**: `SESSION_ID: WFS-fix-login-bug`
|
||||
|
||||
@@ -44,7 +44,7 @@ allowed-tools: SlashCommand(*), TodoWrite(*), Read(*), Bash(*)
|
||||
**Step 1.1: Dispatch** - Session discovery and initialization
|
||||
|
||||
```javascript
|
||||
SlashCommand(command="/workflow:session:start --auto \"TDD: [structured-description]\"")
|
||||
SlashCommand(command="/workflow:session:start --type tdd --auto \"TDD: [structured-description]\"")
|
||||
```
|
||||
|
||||
**TDD Structured Format**:
|
||||
|
||||
@@ -159,19 +159,19 @@ Read(".workflow/active/[sourceSessionId]/.process/context-package.json")
|
||||
|
||||
```javascript
|
||||
// Session Mode - Include original task description to enable semantic CLI selection
|
||||
SlashCommand(command="/workflow:session:start --new \"Test validation for [sourceSessionId]: [originalTaskDescription]\"")
|
||||
SlashCommand(command="/workflow:session:start --type test --new \"Test validation for [sourceSessionId]: [originalTaskDescription]\"")
|
||||
|
||||
// Prompt Mode - User's description already contains their intent
|
||||
SlashCommand(command="/workflow:session:start --new \"Test generation for: [description]\"")
|
||||
SlashCommand(command="/workflow:session:start --type test --new \"Test generation for: [description]\"")
|
||||
```
|
||||
|
||||
**Input**: User argument (session ID, description, or file path)
|
||||
|
||||
**Expected Behavior**:
|
||||
- Creates new session: `WFS-test-[slug]`
|
||||
- Writes `workflow-session.json` metadata:
|
||||
- **Session Mode**: Includes `workflow_type: "test_session"`, `source_session_id: "[sourceId]"`, description with original user intent
|
||||
- **Prompt Mode**: Includes `workflow_type: "test_session"` only (user's description already contains intent)
|
||||
- Writes `workflow-session.json` metadata with `type: "test"`
|
||||
- **Session Mode**: Additionally includes `source_session_id: "[sourceId]"`, description with original user intent
|
||||
- **Prompt Mode**: Uses user's description (already contains intent)
|
||||
- Returns new session ID
|
||||
|
||||
**Parse Output**:
|
||||
@@ -579,11 +579,11 @@ WFS-test-[session]/
|
||||
**File**: `workflow-session.json`
|
||||
|
||||
**Session Mode** includes:
|
||||
- `workflow_type: "test_session"`
|
||||
- `type: "test"` (set by session:start --type test)
|
||||
- `source_session_id: "[sourceSessionId]"` (enables automatic cross-session context)
|
||||
|
||||
**Prompt Mode** includes:
|
||||
- `workflow_type: "test_session"`
|
||||
- `type: "test"` (set by session:start --type test)
|
||||
- No `source_session_id` field
|
||||
|
||||
### Execution Flow Diagram
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command } from 'commander';
|
||||
import { viewCommand } from './commands/view.js';
|
||||
import { serveCommand } from './commands/serve.js';
|
||||
import { installCommand } from './commands/install.js';
|
||||
import { uninstallCommand } from './commands/uninstall.js';
|
||||
import { listCommand } from './commands/list.js';
|
||||
@@ -51,12 +52,21 @@ export function run(argv) {
|
||||
// View command
|
||||
program
|
||||
.command('view')
|
||||
.description('Open workflow dashboard in browser')
|
||||
.description('Open workflow dashboard in browser (static HTML)')
|
||||
.option('-p, --path <path>', 'Path to project directory', '.')
|
||||
.option('--no-browser', 'Generate dashboard without opening browser')
|
||||
.option('-o, --output <file>', 'Output path for generated HTML')
|
||||
.action(viewCommand);
|
||||
|
||||
// Serve command
|
||||
program
|
||||
.command('serve')
|
||||
.description('Start dashboard server with live path switching')
|
||||
.option('-p, --path <path>', 'Initial project directory')
|
||||
.option('--port <port>', 'Server port', '3456')
|
||||
.option('--no-browser', 'Start server without opening browser')
|
||||
.action(serveCommand);
|
||||
|
||||
// Install command
|
||||
program
|
||||
.command('install')
|
||||
|
||||
67
ccw/src/commands/serve.js
Normal file
67
ccw/src/commands/serve.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { startServer } from '../core/server.js';
|
||||
import { launchBrowser } from '../utils/browser-launcher.js';
|
||||
import { resolvePath, validatePath } from '../utils/path-resolver.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Serve command handler - starts dashboard server with live path switching
|
||||
* @param {Object} options - Command options
|
||||
*/
|
||||
export async function serveCommand(options) {
|
||||
const port = options.port || 3456;
|
||||
|
||||
// Validate project path
|
||||
let initialPath = process.cwd();
|
||||
if (options.path) {
|
||||
const pathValidation = validatePath(options.path, { mustExist: true });
|
||||
if (!pathValidation.valid) {
|
||||
console.error(chalk.red(`\n Error: ${pathValidation.error}\n`));
|
||||
process.exit(1);
|
||||
}
|
||||
initialPath = pathValidation.path;
|
||||
}
|
||||
|
||||
console.log(chalk.blue.bold('\n CCW Dashboard Server\n'));
|
||||
console.log(chalk.gray(` Initial project: ${initialPath}`));
|
||||
console.log(chalk.gray(` Port: ${port}\n`));
|
||||
|
||||
try {
|
||||
// Start server
|
||||
console.log(chalk.cyan(' Starting server...'));
|
||||
const server = await startServer({ port, initialPath });
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(chalk.green(` Server running at ${url}`));
|
||||
|
||||
// Open browser
|
||||
if (options.browser !== false) {
|
||||
console.log(chalk.cyan(' Opening in browser...'));
|
||||
try {
|
||||
await launchBrowser(url);
|
||||
console.log(chalk.green.bold('\n Dashboard opened in browser!'));
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`\n Could not open browser: ${err.message}`));
|
||||
console.log(chalk.gray(` Open manually: ${url}`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.gray('\n Press Ctrl+C to stop the server\n'));
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n Shutting down server...'));
|
||||
server.close(() => {
|
||||
console.log(chalk.green(' Server stopped.\n'));
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`\n Error: ${error.message}\n`));
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
console.error(chalk.yellow(` Port ${port} is already in use.`));
|
||||
console.error(chalk.gray(` Try a different port: ccw serve --port ${port + 1}\n`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { scanSessions } from '../core/session-scanner.js';
|
||||
import { aggregateData } from '../core/data-aggregator.js';
|
||||
import { generateDashboard } from '../core/dashboard-generator.js';
|
||||
import { launchBrowser, isHeadlessEnvironment } from '../utils/browser-launcher.js';
|
||||
import { resolvePath, ensureDir, getWorkflowDir, validatePath, validateOutputPath } from '../utils/path-resolver.js';
|
||||
import { resolvePath, ensureDir, getWorkflowDir, validatePath, validateOutputPath, trackRecentPath, getRecentPaths, normalizePathForDisplay } from '../utils/path-resolver.js';
|
||||
import chalk from 'chalk';
|
||||
import { writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
@@ -22,6 +22,9 @@ export async function viewCommand(options) {
|
||||
const workingDir = pathValidation.path;
|
||||
const workflowDir = join(workingDir, '.workflow');
|
||||
|
||||
// Track this path in recent paths
|
||||
trackRecentPath(workingDir);
|
||||
|
||||
console.log(chalk.blue.bold('\n CCW Dashboard Generator\n'));
|
||||
console.log(chalk.gray(` Project: ${workingDir}`));
|
||||
console.log(chalk.gray(` Workflow: ${workflowDir}\n`));
|
||||
@@ -36,14 +39,19 @@ export async function viewCommand(options) {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: null,
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0
|
||||
}
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
},
|
||||
projectPath: normalizePathForDisplay(workingDir),
|
||||
recentPaths: getRecentPaths()
|
||||
};
|
||||
|
||||
await generateAndOpen(emptyData, workflowDir, options);
|
||||
@@ -64,6 +72,10 @@ export async function viewCommand(options) {
|
||||
console.log(chalk.cyan(' Aggregating data...'));
|
||||
const dashboardData = await aggregateData(sessions, workflowDir);
|
||||
|
||||
// Add project path and recent paths
|
||||
dashboardData.projectPath = normalizePathForDisplay(workingDir);
|
||||
dashboardData.recentPaths = getRecentPaths();
|
||||
|
||||
// Log statistics
|
||||
const stats = dashboardData.statistics;
|
||||
console.log(chalk.gray(` Tasks: ${stats.completedTasks}/${stats.totalTasks} completed`));
|
||||
|
||||
@@ -6,6 +6,9 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Bundled template paths
|
||||
const UNIFIED_TEMPLATE = join(__dirname, '../templates/dashboard.html');
|
||||
const CSS_FILE = join(__dirname, '../templates/dashboard.css');
|
||||
const JS_FILE = join(__dirname, '../templates/dashboard.js');
|
||||
const WORKFLOW_TEMPLATE = join(__dirname, '../templates/workflow-dashboard.html');
|
||||
const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.html');
|
||||
|
||||
@@ -16,15 +19,68 @@ const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.htm
|
||||
* @returns {Promise<string>} - Generated HTML
|
||||
*/
|
||||
export async function generateDashboard(data) {
|
||||
// Use bundled workflow template
|
||||
// Use new unified template (with sidebar layout)
|
||||
if (existsSync(UNIFIED_TEMPLATE)) {
|
||||
return generateFromUnifiedTemplate(data);
|
||||
}
|
||||
|
||||
// Fallback to legacy workflow template
|
||||
if (existsSync(WORKFLOW_TEMPLATE)) {
|
||||
return generateFromBundledTemplate(data, WORKFLOW_TEMPLATE);
|
||||
}
|
||||
|
||||
// Fallback to inline dashboard if template missing
|
||||
// Fallback to inline dashboard if templates missing
|
||||
return generateInlineDashboard(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard using unified template (new sidebar layout)
|
||||
* @param {Object} data - Dashboard data
|
||||
* @returns {string} - Generated HTML
|
||||
*/
|
||||
function generateFromUnifiedTemplate(data) {
|
||||
let html = readFileSync(UNIFIED_TEMPLATE, 'utf8');
|
||||
|
||||
// Read CSS and JS files
|
||||
const cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
||||
let jsContent = existsSync(JS_FILE) ? readFileSync(JS_FILE, 'utf8') : '';
|
||||
|
||||
// Prepare complete workflow data
|
||||
const workflowData = {
|
||||
generatedAt: data.generatedAt || new Date().toISOString(),
|
||||
activeSessions: data.activeSessions || [],
|
||||
archivedSessions: data.archivedSessions || [],
|
||||
liteTasks: data.liteTasks || { litePlan: [], liteFix: [] },
|
||||
reviewData: data.reviewData || { dimensions: {} },
|
||||
statistics: data.statistics || {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Get project path and recent paths
|
||||
const projectPath = data.projectPath || process.cwd();
|
||||
const recentPaths = data.recentPaths || [projectPath];
|
||||
|
||||
// Replace JS placeholders with actual data
|
||||
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(recentPaths));
|
||||
|
||||
// Inject CSS and JS into HTML template
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
|
||||
// Also replace any remaining placeholders in HTML
|
||||
html = html.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard using bundled template
|
||||
* @param {Object} data - Dashboard data
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { glob } from 'glob';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
import { scanLiteTasks } from './lite-scanner.js';
|
||||
|
||||
/**
|
||||
* Aggregate all data for dashboard rendering
|
||||
@@ -13,13 +14,19 @@ export async function aggregateData(sessions, workflowDir) {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: {
|
||||
litePlan: [],
|
||||
liteFix: []
|
||||
},
|
||||
reviewData: null,
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,6 +55,16 @@ export async function aggregateData(sessions, workflowDir) {
|
||||
data.statistics.totalSessions = sessions.active.length + sessions.archived.length;
|
||||
data.statistics.activeSessions = sessions.active.length;
|
||||
|
||||
// Scan and include lite tasks
|
||||
try {
|
||||
const liteTasks = await scanLiteTasks(workflowDir);
|
||||
data.liteTasks = liteTasks;
|
||||
data.statistics.litePlanCount = liteTasks.litePlan.length;
|
||||
data.statistics.liteFixCount = liteTasks.liteFix.length;
|
||||
} catch (err) {
|
||||
console.error('Error scanning lite tasks:', err.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -62,8 +79,10 @@ async function processSession(session, isActive) {
|
||||
session_id: session.session_id,
|
||||
project: session.project || session.session_id,
|
||||
status: session.status || (isActive ? 'active' : 'archived'),
|
||||
created_at: formatDate(session.created_at),
|
||||
archived_at: formatDate(session.archived_at),
|
||||
type: session.type || 'workflow', // Session type (workflow, review, test, docs)
|
||||
workflow_type: session.workflow_type || null, // Original workflow_type for reference
|
||||
created_at: session.created_at || null, // Raw ISO string - let frontend format
|
||||
archived_at: session.archived_at || null, // Raw ISO string - let frontend format
|
||||
path: session.path,
|
||||
tasks: [],
|
||||
taskCount: 0,
|
||||
@@ -249,26 +268,8 @@ async function safeGlob(pattern, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
* @param {string|null} dateStr - ISO date string
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
// formatDate removed - dates are now passed as raw ISO strings
|
||||
// Frontend (dashboard.js) handles all date formatting
|
||||
|
||||
/**
|
||||
* Sort task IDs numerically (IMPL-1, IMPL-2, IMPL-1.1, etc.)
|
||||
|
||||
290
ccw/src/core/lite-scanner.js
Normal file
290
ccw/src/core/lite-scanner.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Scan lite-plan and lite-fix directories for task sessions
|
||||
* @param {string} workflowDir - Path to .workflow directory
|
||||
* @returns {Promise<Object>} - Lite tasks data
|
||||
*/
|
||||
export async function scanLiteTasks(workflowDir) {
|
||||
const litePlanDir = join(workflowDir, '.lite-plan');
|
||||
const liteFixDir = join(workflowDir, '.lite-fix');
|
||||
|
||||
return {
|
||||
litePlan: scanLiteDir(litePlanDir, 'lite-plan'),
|
||||
liteFix: scanLiteDir(liteFixDir, 'lite-fix')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a lite task directory
|
||||
* @param {string} dir - Directory path
|
||||
* @param {string} type - Task type ('lite-plan' or 'lite-fix')
|
||||
* @returns {Array} - Array of lite task sessions
|
||||
*/
|
||||
function scanLiteDir(dir, type) {
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
try {
|
||||
const sessions = readdirSync(dir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => {
|
||||
const sessionPath = join(dir, d.name);
|
||||
const session = {
|
||||
id: d.name,
|
||||
type,
|
||||
path: sessionPath,
|
||||
createdAt: getCreatedTime(sessionPath),
|
||||
plan: loadPlanJson(sessionPath),
|
||||
tasks: loadTaskJsons(sessionPath)
|
||||
};
|
||||
|
||||
// Calculate progress
|
||||
session.progress = calculateProgress(session.tasks);
|
||||
|
||||
return session;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
return sessions;
|
||||
} catch (err) {
|
||||
console.error(`Error scanning ${dir}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plan.json from session directory
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Object|null} - Plan data or null
|
||||
*/
|
||||
function loadPlanJson(sessionPath) {
|
||||
const planPath = join(sessionPath, 'plan.json');
|
||||
if (!existsSync(planPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all task JSON files from session directory
|
||||
* Supports multiple task formats:
|
||||
* 1. .task/IMPL-*.json files
|
||||
* 2. tasks array in plan.json
|
||||
* 3. task-*.json files in session root
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Array} - Array of task objects
|
||||
*/
|
||||
function loadTaskJsons(sessionPath) {
|
||||
let tasks = [];
|
||||
|
||||
// Method 1: Check .task/IMPL-*.json files
|
||||
const taskDir = join(sessionPath, '.task');
|
||||
if (existsSync(taskDir)) {
|
||||
try {
|
||||
const implTasks = readdirSync(taskDir)
|
||||
.filter(f => f.endsWith('.json') && (
|
||||
f.startsWith('IMPL-') ||
|
||||
f.startsWith('TASK-') ||
|
||||
f.startsWith('task-') ||
|
||||
/^T\d+\.json$/i.test(f)
|
||||
))
|
||||
.map(f => {
|
||||
const taskPath = join(taskDir, f);
|
||||
try {
|
||||
const content = readFileSync(taskPath, 'utf8');
|
||||
return normalizeTask(JSON.parse(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
tasks = tasks.concat(implTasks);
|
||||
} catch {
|
||||
// Continue to other methods
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Check plan.json for embedded tasks array
|
||||
if (tasks.length === 0) {
|
||||
const planPath = join(sessionPath, 'plan.json');
|
||||
if (existsSync(planPath)) {
|
||||
try {
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf8'));
|
||||
if (Array.isArray(plan.tasks)) {
|
||||
tasks = plan.tasks.map(t => normalizeTask(t));
|
||||
}
|
||||
} catch {
|
||||
// Continue to other methods
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check for task-*.json files in session root
|
||||
if (tasks.length === 0) {
|
||||
try {
|
||||
const rootTasks = readdirSync(sessionPath)
|
||||
.filter(f => f.endsWith('.json') && (
|
||||
f.startsWith('task-') ||
|
||||
f.startsWith('TASK-') ||
|
||||
/^T\d+\.json$/i.test(f)
|
||||
))
|
||||
.map(f => {
|
||||
const taskPath = join(sessionPath, f);
|
||||
try {
|
||||
const content = readFileSync(taskPath, 'utf8');
|
||||
return normalizeTask(JSON.parse(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
tasks = tasks.concat(rootTasks);
|
||||
} catch {
|
||||
// No tasks found
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tasks by ID
|
||||
return tasks.sort((a, b) => {
|
||||
const aNum = parseInt(a.id?.replace(/\D/g, '') || '0');
|
||||
const bNum = parseInt(b.id?.replace(/\D/g, '') || '0');
|
||||
return aNum - bNum;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize task object to consistent structure
|
||||
* @param {Object} task - Raw task object
|
||||
* @returns {Object} - Normalized task
|
||||
*/
|
||||
function normalizeTask(task) {
|
||||
if (!task) return null;
|
||||
|
||||
// Determine status - support various status formats
|
||||
let status = task.status || 'pending';
|
||||
if (typeof status === 'object') {
|
||||
status = status.state || status.value || 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id || task.task_id || 'unknown',
|
||||
title: task.title || task.name || task.summary || 'Untitled Task',
|
||||
status: status.toLowerCase(),
|
||||
// Preserve original fields for flexible rendering
|
||||
meta: task.meta || {
|
||||
type: task.type || task.action || 'task',
|
||||
agent: task.agent || null,
|
||||
scope: task.scope || null,
|
||||
module: task.module || null
|
||||
},
|
||||
context: task.context || {
|
||||
requirements: task.requirements || task.description ? [task.description] : [],
|
||||
focus_paths: task.focus_paths || task.modification_points?.map(m => m.file) || [],
|
||||
acceptance: task.acceptance || [],
|
||||
depends_on: task.depends_on || []
|
||||
},
|
||||
flow_control: task.flow_control || {
|
||||
implementation_approach: task.implementation?.map((step, i) => ({
|
||||
step: `Step ${i + 1}`,
|
||||
action: step
|
||||
})) || []
|
||||
},
|
||||
// Keep all original fields for raw JSON view
|
||||
_raw: task
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory creation time
|
||||
* @param {string} dirPath - Directory path
|
||||
* @returns {string} - ISO date string
|
||||
*/
|
||||
function getCreatedTime(dirPath) {
|
||||
try {
|
||||
const stat = statSync(dirPath);
|
||||
return stat.birthtime.toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress from tasks
|
||||
* @param {Array} tasks - Array of task objects
|
||||
* @returns {Object} - Progress info
|
||||
*/
|
||||
function calculateProgress(tasks) {
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return { total: 0, completed: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
|
||||
return { total, completed, percentage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed lite task info
|
||||
* @param {string} workflowDir - Workflow directory
|
||||
* @param {string} type - 'lite-plan' or 'lite-fix'
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Object|null} - Detailed task info
|
||||
*/
|
||||
export function getLiteTaskDetail(workflowDir, type, sessionId) {
|
||||
const dir = type === 'lite-plan'
|
||||
? join(workflowDir, '.lite-plan', sessionId)
|
||||
: join(workflowDir, '.lite-fix', sessionId);
|
||||
|
||||
if (!existsSync(dir)) return null;
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
type,
|
||||
path: dir,
|
||||
plan: loadPlanJson(dir),
|
||||
tasks: loadTaskJsons(dir),
|
||||
explorations: loadExplorations(dir),
|
||||
clarifications: loadClarifications(dir)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load exploration results
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Array} - Exploration results
|
||||
*/
|
||||
function loadExplorations(sessionPath) {
|
||||
const explorePath = join(sessionPath, 'explorations.json');
|
||||
if (!existsSync(explorePath)) return [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(explorePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load clarification data
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @returns {Object|null} - Clarification data
|
||||
*/
|
||||
function loadClarifications(sessionPath) {
|
||||
const clarifyPath = join(sessionPath, 'clarifications.json');
|
||||
if (!existsSync(clarifyPath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(clarifyPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
328
ccw/src/core/server.js
Normal file
328
ccw/src/core/server.js
Normal file
@@ -0,0 +1,328 @@
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { scanSessions } from './session-scanner.js';
|
||||
import { aggregateData } from './data-aggregator.js';
|
||||
import { resolvePath, getRecentPaths, trackRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js';
|
||||
|
||||
const TEMPLATE_PATH = join(import.meta.dirname, '../templates/dashboard.html');
|
||||
const CSS_FILE = join(import.meta.dirname, '../templates/dashboard.css');
|
||||
const JS_FILE = join(import.meta.dirname, '../templates/dashboard.js');
|
||||
|
||||
/**
|
||||
* Create and start the dashboard server
|
||||
* @param {Object} options - Server options
|
||||
* @param {number} options.port - Port to listen on (default: 3456)
|
||||
* @param {string} options.initialPath - Initial project path
|
||||
* @returns {Promise<http.Server>}
|
||||
*/
|
||||
export async function startServer(options = {}) {
|
||||
const port = options.port || 3456;
|
||||
const initialPath = options.initialPath || process.cwd();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${port}`);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS headers for API requests
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// API: Get workflow data for a path
|
||||
if (pathname === '/api/data') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
const data = await getWorkflowData(projectPath);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get recent paths
|
||||
if (pathname === '/api/recent-paths') {
|
||||
const paths = getRecentPaths();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ paths }));
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Get session detail data (context, summaries, impl-plan, review)
|
||||
if (pathname === '/api/session-detail') {
|
||||
const sessionPath = url.searchParams.get('path');
|
||||
const dataType = url.searchParams.get('type') || 'all';
|
||||
|
||||
if (!sessionPath) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session path is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await getSessionDetailData(sessionPath, dataType);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(detail));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard HTML
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
const html = generateServerDashboard(initialPath);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Server error:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
console.log(`Dashboard server running at http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow data for a project path
|
||||
* @param {string} projectPath
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getWorkflowData(projectPath) {
|
||||
const resolvedPath = resolvePath(projectPath);
|
||||
const workflowDir = join(resolvedPath, '.workflow');
|
||||
|
||||
// Track this path
|
||||
trackRecentPath(resolvedPath);
|
||||
|
||||
// Check if .workflow exists
|
||||
if (!existsSync(workflowDir)) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
statistics: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
reviewFindings: 0,
|
||||
litePlanCount: 0,
|
||||
liteFixCount: 0
|
||||
},
|
||||
projectPath: normalizePathForDisplay(resolvedPath),
|
||||
recentPaths: getRecentPaths()
|
||||
};
|
||||
}
|
||||
|
||||
// Scan and aggregate data
|
||||
const sessions = await scanSessions(workflowDir);
|
||||
const data = await aggregateData(sessions, workflowDir);
|
||||
|
||||
data.projectPath = normalizePathForDisplay(resolvedPath);
|
||||
data.recentPaths = getRecentPaths();
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session detail data (context, summaries, impl-plan, review)
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
* @param {string} dataType - Type of data to load: context, summary, impl-plan, review, or all
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getSessionDetailData(sessionPath, dataType) {
|
||||
const result = {};
|
||||
|
||||
// Normalize path
|
||||
const normalizedPath = sessionPath.replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
// Load context-package.json (in .process/ subfolder)
|
||||
if (dataType === 'context' || dataType === 'all') {
|
||||
// Try .process/context-package.json first (common location)
|
||||
let contextFile = join(normalizedPath, '.process', 'context-package.json');
|
||||
if (!existsSync(contextFile)) {
|
||||
// Fallback to session root
|
||||
contextFile = join(normalizedPath, 'context-package.json');
|
||||
}
|
||||
if (existsSync(contextFile)) {
|
||||
try {
|
||||
result.context = JSON.parse(readFileSync(contextFile, 'utf8'));
|
||||
} catch (e) {
|
||||
result.context = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load task JSONs from .task/ folder
|
||||
if (dataType === 'tasks' || dataType === 'all') {
|
||||
const taskDir = join(normalizedPath, '.task');
|
||||
result.tasks = [];
|
||||
if (existsSync(taskDir)) {
|
||||
const files = readdirSync(taskDir).filter(f => f.endsWith('.json') && f.startsWith('IMPL-'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(join(taskDir, file), 'utf8'));
|
||||
result.tasks.push({
|
||||
filename: file,
|
||||
task_id: file.replace('.json', ''),
|
||||
...content
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
// Sort by task ID
|
||||
result.tasks.sort((a, b) => a.task_id.localeCompare(b.task_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Load summaries from .summaries/
|
||||
if (dataType === 'summary' || dataType === 'all') {
|
||||
const summariesDir = join(normalizedPath, '.summaries');
|
||||
result.summaries = [];
|
||||
if (existsSync(summariesDir)) {
|
||||
const files = readdirSync(summariesDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(summariesDir, file), 'utf8');
|
||||
result.summaries.push({ name: file.replace('.md', ''), content });
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load IMPL_PLAN.md
|
||||
if (dataType === 'impl-plan' || dataType === 'all') {
|
||||
const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md');
|
||||
if (existsSync(implPlanFile)) {
|
||||
try {
|
||||
result.implPlan = readFileSync(implPlanFile, 'utf8');
|
||||
} catch (e) {
|
||||
result.implPlan = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load review data from .review/
|
||||
if (dataType === 'review' || dataType === 'all') {
|
||||
const reviewDir = join(normalizedPath, '.review');
|
||||
result.review = { dimensions: {} };
|
||||
if (existsSync(reviewDir)) {
|
||||
const dimensionsDir = join(reviewDir, 'dimensions');
|
||||
if (existsSync(dimensionsDir)) {
|
||||
const files = readdirSync(dimensionsDir).filter(f => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const dimName = file.replace('.json', '');
|
||||
const content = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
|
||||
result.review.dimensions[dimName] = content.findings || content;
|
||||
} catch (e) {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading session detail:', error);
|
||||
result.error = error.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate dashboard HTML for server mode
|
||||
* @param {string} initialPath
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateServerDashboard(initialPath) {
|
||||
let html = readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
|
||||
// Read CSS and JS files
|
||||
const cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
|
||||
let jsContent = existsSync(JS_FILE) ? readFileSync(JS_FILE, 'utf8') : '';
|
||||
|
||||
// Inject CSS content
|
||||
html = html.replace('{{CSS_CONTENT}}', cssContent);
|
||||
|
||||
// Prepare JS content with empty initial data (will be loaded dynamically)
|
||||
const emptyData = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
liteTasks: { litePlan: [], liteFix: [] },
|
||||
reviewData: { dimensions: {} },
|
||||
statistics: { totalSessions: 0, activeSessions: 0, totalTasks: 0, completedTasks: 0, reviewFindings: 0, litePlanCount: 0, liteFixCount: 0 }
|
||||
};
|
||||
|
||||
// Replace JS placeholders
|
||||
jsContent = jsContent.replace('{{WORKFLOW_DATA}}', JSON.stringify(emptyData, null, 2));
|
||||
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(getRecentPaths()));
|
||||
|
||||
// Add server mode flag and dynamic loading functions at the start of JS
|
||||
const serverModeScript = `
|
||||
// Server mode - load data dynamically
|
||||
window.SERVER_MODE = true;
|
||||
window.INITIAL_PATH = '${normalizePathForDisplay(initialPath).replace(/\\/g, '/')}';
|
||||
|
||||
async function loadDashboardData(path) {
|
||||
try {
|
||||
const res = await fetch('/api/data?path=' + encodeURIComponent(path));
|
||||
if (!res.ok) throw new Error('Failed to load data');
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentPaths() {
|
||||
try {
|
||||
const res = await fetch('/api/recent-paths');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.paths || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
// Prepend server mode script to JS content
|
||||
jsContent = serverModeScript + jsContent;
|
||||
|
||||
// Inject JS content
|
||||
html = html.replace('{{JS_CONTENT}}', jsContent);
|
||||
|
||||
// Replace any remaining placeholders in HTML
|
||||
html = html.replace(/\{\{PROJECT_PATH\}\}/g, normalizePathForDisplay(initialPath).replace(/\\/g, '/'));
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -90,6 +90,55 @@ async function findWfsSessions(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timestamp from session name
|
||||
* Supports formats: WFS-xxx-20251128172537 or WFS-xxx-20251120-170640
|
||||
* @param {string} sessionName - Session directory name
|
||||
* @returns {string|null} - ISO date string or null
|
||||
*/
|
||||
function parseTimestampFromName(sessionName) {
|
||||
// Format: 14-digit timestamp (YYYYMMDDHHmmss)
|
||||
const match14 = sessionName.match(/(\d{14})$/);
|
||||
if (match14) {
|
||||
const ts = match14[1];
|
||||
return `${ts.slice(0,4)}-${ts.slice(4,6)}-${ts.slice(6,8)}T${ts.slice(8,10)}:${ts.slice(10,12)}:${ts.slice(12,14)}Z`;
|
||||
}
|
||||
|
||||
// Format: 8-digit date + 6-digit time separated by hyphen (YYYYMMDD-HHmmss)
|
||||
const match8_6 = sessionName.match(/(\d{8})-(\d{6})$/);
|
||||
if (match8_6) {
|
||||
const d = match8_6[1];
|
||||
const t = match8_6[2];
|
||||
return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}T${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}Z`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer session type from session name pattern
|
||||
* @param {string} sessionName - Session directory name
|
||||
* @returns {string} - Inferred type
|
||||
*/
|
||||
function inferTypeFromName(sessionName) {
|
||||
const name = sessionName.toLowerCase();
|
||||
|
||||
if (name.includes('-review-') || name.includes('-code-review-')) {
|
||||
return 'review';
|
||||
}
|
||||
if (name.includes('-test-')) {
|
||||
return 'test';
|
||||
}
|
||||
if (name.includes('-docs-')) {
|
||||
return 'docs';
|
||||
}
|
||||
if (name.includes('-tdd-')) {
|
||||
return 'tdd';
|
||||
}
|
||||
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session data from workflow-session.json or create minimal from directory
|
||||
* @param {string} sessionPath - Path to session directory
|
||||
@@ -97,17 +146,27 @@ async function findWfsSessions(dir) {
|
||||
*/
|
||||
function readSessionData(sessionPath) {
|
||||
const sessionFile = join(sessionPath, 'workflow-session.json');
|
||||
const sessionName = basename(sessionPath);
|
||||
|
||||
if (existsSync(sessionFile)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
||||
|
||||
// Multi-level type detection: JSON type > workflow_type > infer from name
|
||||
let type = data.type || data.workflow_type || inferTypeFromName(sessionName);
|
||||
|
||||
// Normalize workflow_type values
|
||||
if (type === 'test_session') type = 'test';
|
||||
if (type === 'implementation') type = 'workflow';
|
||||
|
||||
return {
|
||||
session_id: data.session_id || basename(sessionPath),
|
||||
session_id: data.session_id || sessionName,
|
||||
project: data.project || data.description || '',
|
||||
status: data.status || 'active',
|
||||
created_at: data.created_at || data.initialized_at || null,
|
||||
created_at: data.created_at || data.initialized_at || data.timestamp || null,
|
||||
archived_at: data.archived_at || null,
|
||||
type: data.type || 'workflow'
|
||||
type: type,
|
||||
workflow_type: data.workflow_type || null // Keep original for reference
|
||||
};
|
||||
} catch {
|
||||
// Fall through to minimal session
|
||||
@@ -115,17 +174,34 @@ function readSessionData(sessionPath) {
|
||||
}
|
||||
|
||||
// Fallback: create minimal session from directory info
|
||||
// Try to extract timestamp from session name first
|
||||
const timestampFromName = parseTimestampFromName(sessionName);
|
||||
const inferredType = inferTypeFromName(sessionName);
|
||||
|
||||
try {
|
||||
const stats = statSync(sessionPath);
|
||||
return {
|
||||
session_id: basename(sessionPath),
|
||||
session_id: sessionName,
|
||||
project: '',
|
||||
status: 'unknown',
|
||||
created_at: stats.birthtime.toISOString(),
|
||||
created_at: timestampFromName || stats.birthtime.toISOString(),
|
||||
archived_at: null,
|
||||
type: 'workflow'
|
||||
type: inferredType,
|
||||
workflow_type: null
|
||||
};
|
||||
} catch {
|
||||
// Even if stat fails, return with name-extracted data
|
||||
if (timestampFromName) {
|
||||
return {
|
||||
session_id: sessionName,
|
||||
project: '',
|
||||
status: 'unknown',
|
||||
created_at: timestampFromName,
|
||||
archived_at: null,
|
||||
type: inferredType,
|
||||
workflow_type: null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
3963
ccw/src/templates/dashboard.css
Normal file
3963
ccw/src/templates/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
182
ccw/src/templates/dashboard.html
Normal file
182
ccw/src/templates/dashboard.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CCW Dashboard</title>
|
||||
<style>
|
||||
{{CSS_CONTENT}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Top Bar -->
|
||||
<header class="top-bar">
|
||||
<div class="top-bar-left">
|
||||
<button class="menu-toggle" id="menuToggle">☰</button>
|
||||
<div class="logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<span class="logo-text">Claude Code Workflow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Path Selector -->
|
||||
<div class="path-selector">
|
||||
<label>Project:</label>
|
||||
<div class="path-dropdown">
|
||||
<button class="path-current" id="pathButton">
|
||||
<span class="path-text" id="currentPath">{{PROJECT_PATH}}</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div class="path-menu" id="pathMenu">
|
||||
<div class="menu-label">Recent Projects</div>
|
||||
<div id="recentPaths">
|
||||
<!-- Dynamic recent paths -->
|
||||
</div>
|
||||
<div class="path-actions">
|
||||
<button class="path-browse" id="browsePath">
|
||||
📂 Browse...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Actions -->
|
||||
<div class="header-actions">
|
||||
<button class="theme-toggle" id="themeToggle" title="Toggle theme">🌙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar Overlay (mobile) -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="main-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Sessions Section -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-section-header">
|
||||
<span class="nav-icon">📁</span>
|
||||
<span class="nav-section-title">Sessions</span>
|
||||
</div>
|
||||
<ul class="nav-items">
|
||||
<li class="nav-item active tooltip" data-filter="all" data-tooltip="All Sessions">
|
||||
<span class="nav-icon">📋</span>
|
||||
<span class="nav-text">All</span>
|
||||
<span class="badge" id="badgeAll">0</span>
|
||||
</li>
|
||||
<li class="nav-item tooltip" data-filter="active" data-tooltip="Active Sessions">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span class="nav-text">Active</span>
|
||||
<span class="badge success" id="badgeActive">0</span>
|
||||
</li>
|
||||
<li class="nav-item tooltip" data-filter="archived" data-tooltip="Archived Sessions">
|
||||
<span class="nav-icon">📦</span>
|
||||
<span class="nav-text">Archived</span>
|
||||
<span class="badge" id="badgeArchived">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Lite Tasks Section -->
|
||||
<div class="nav-section" id="liteTasksNav">
|
||||
<div class="nav-section-header">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span class="nav-section-title">Lite Tasks</span>
|
||||
</div>
|
||||
<ul class="nav-items">
|
||||
<li class="nav-item tooltip" data-lite="lite-plan" data-tooltip="Lite Plan Sessions">
|
||||
<span class="nav-icon">📝</span>
|
||||
<span class="nav-text">lite-plan</span>
|
||||
<span class="badge" id="badgeLitePlan">0</span>
|
||||
</li>
|
||||
<li class="nav-item tooltip" data-lite="lite-fix" data-tooltip="Lite Fix Sessions">
|
||||
<span class="nav-icon">🔧</span>
|
||||
<span class="nav-text">lite-fix</span>
|
||||
<span class="badge" id="badgeLiteFix">0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<button class="sidebar-toggle" id="sidebarToggle">
|
||||
<span class="toggle-icon">◀</span>
|
||||
<span class="toggle-text">Collapse</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="content">
|
||||
<!-- Stats Grid -->
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value" id="statTotalSessions">0</div>
|
||||
<div class="stat-label">Total Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🟢</div>
|
||||
<div class="stat-value" id="statActiveSessions">0</div>
|
||||
<div class="stat-label">Active Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-value" id="statTotalTasks">0</div>
|
||||
<div class="stat-label">Total Tasks</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value" id="statCompletedTasks">0</div>
|
||||
<div class="stat-label">Completed Tasks</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Content Header -->
|
||||
<div class="content-header">
|
||||
<h2 class="content-title" id="contentTitle">All Sessions</h2>
|
||||
<div class="search-box">
|
||||
<input type="text" placeholder="Search..." id="searchInput">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<section class="main-content" id="mainContent">
|
||||
<!-- Dynamic content: sessions grid or session detail page -->
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bottom-bar">
|
||||
<div>Generated: <span id="generatedAt">-</span></div>
|
||||
<div>CCW Dashboard v1.0</div>
|
||||
</footer>
|
||||
|
||||
<!-- Task Detail Drawer -->
|
||||
<div class="task-detail-drawer" id="taskDetailDrawer">
|
||||
<div class="drawer-header">
|
||||
<h3 class="drawer-title" id="drawerTaskTitle">Task Details</h3>
|
||||
<button class="drawer-close" onclick="closeTaskDrawer()">×</button>
|
||||
</div>
|
||||
<div class="drawer-content" id="drawerContent">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-overlay" id="drawerOverlay" onclick="closeTaskDrawer()"></div>
|
||||
</div>
|
||||
|
||||
<!-- D3.js for Flowchart -->
|
||||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||
|
||||
<script>
|
||||
{{JS_CONTENT}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2763
ccw/src/templates/dashboard.js
Normal file
2763
ccw/src/templates/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { resolve, join, relative, isAbsolute } from 'path';
|
||||
import { existsSync, mkdirSync, realpathSync, statSync } from 'fs';
|
||||
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
@@ -193,3 +193,62 @@ export function getWorkflowDir(projectPath) {
|
||||
export function normalizePathForDisplay(filePath) {
|
||||
return filePath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
// Recent paths storage file
|
||||
const RECENT_PATHS_FILE = join(homedir(), '.ccw-recent-paths.json');
|
||||
const MAX_RECENT_PATHS = 10;
|
||||
|
||||
/**
|
||||
* Get recent project paths
|
||||
* @returns {string[]} - Array of recent paths
|
||||
*/
|
||||
export function getRecentPaths() {
|
||||
try {
|
||||
if (existsSync(RECENT_PATHS_FILE)) {
|
||||
const content = readFileSync(RECENT_PATHS_FILE, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
return Array.isArray(data.paths) ? data.paths : [];
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, return empty array
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a project path (add to recent paths)
|
||||
* @param {string} projectPath - Path to track
|
||||
*/
|
||||
export function trackRecentPath(projectPath) {
|
||||
try {
|
||||
const normalized = normalizePathForDisplay(resolvePath(projectPath));
|
||||
let paths = getRecentPaths();
|
||||
|
||||
// Remove if already exists (will be added to front)
|
||||
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
|
||||
|
||||
// Add to front
|
||||
paths.unshift(normalized);
|
||||
|
||||
// Limit to max
|
||||
paths = paths.slice(0, MAX_RECENT_PATHS);
|
||||
|
||||
// Save
|
||||
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recent paths
|
||||
*/
|
||||
export function clearRecentPaths() {
|
||||
try {
|
||||
if (existsSync(RECENT_PATHS_FILE)) {
|
||||
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths: [] }, null, 2), 'utf8');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user