Compare commits

...

24 Commits

Author SHA1 Message Date
catlog22
5dca69fbec chore: bump version to 6.0.1 2025-12-07 21:42:50 +08:00
catlog22
ac626e5895 feat: Review Session增加Fix进度跟踪卡片,移除独立Dashboard模板
- 新增Fix Progress跟踪卡片(走马灯样式)显示修复进度
- 添加/api/file端点支持读取fix-plan.json
- 移除review-fix/module-cycle/session-cycle中的独立dashboard生成
- 删除废弃的workflow-dashboard.html和review-cycle-dashboard.html模板
- 统一使用ccw view命令查看进度

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 21:41:43 +08:00
catlog22
cb78758839 docs: 更新 README 添加 npm 安装和 ccw CLI 说明
- 版本更新到 v6.0.0
- 添加 npm badge 和安装说明
- 新增 CCW CLI Tool 章节,说明所有命令
- 更新 description 为 JSON-driven multi-agent framework
- 修复 package.json 循环依赖

安装: npm install -g claude-code-workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:56:58 +08:00
catlog22
844a2412b2 chore: 配置 npm 发布 @dyw1234/claude-code-workflow@6.0.0
- 添加 package.json 到项目根目录
- 配置 scoped package @dyw1234/claude-code-workflow
- 添加 .npmignore 排除不必要文件
- 发布 v6.0.0 到 npm registry

安装方式: npm install -g @dyw1234/claude-code-workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:42:23 +08:00
catlog22
650d877430 feat: Dashboard 增强 - MCP管理器、Review Session 和 UI 改进
- 添加 MCP Manager 组件,支持服务器状态管理
- 增强 Review Session 视图,添加 conflict/review tabs
- 新增 _conflict_tab.js 和 _review_tab.js 组件
- 改进 carousel、tabs-other 等组件
- 大量 CSS 样式更新和优化
- home.js 添加新功能支持

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:07:29 +08:00
catlog22
f459061ad5 refactor: 简化 ccw 安装流程,移除远程下载功能
- 删除 version-fetcher.js,移除 GitHub API 依赖
- install.js: 移除远程版本选择,只保留本地安装
- upgrade.js: 重写为本地升级,比对包版本与已安装版本
- cli.js: 移除 -v/-t/-b 等版本相关选项
- 添加 CLAUDE.md 复制到 .claude 目录的逻辑

版本管理统一到 npm:npm install -g ccw@版本号

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-07 20:03:10 +08:00
catlog22
a6f9701679 Add exploration field rendering helpers for dynamic content display
- Implemented `renderExpField` to handle various data types for exploration fields.
- Created `renderExpArray` to format arrays, including support for objects with specific properties.
- Developed `renderExpObject` for recursive rendering of object values, filtering out private keys.
- Introduced HTML escaping for safe rendering of user-generated content.
2025-12-07 18:07:28 +08:00
catlog22
26a325efff feat: 添加最近路径管理功能,包括删除路径的API和前端交互 2025-12-07 17:35:10 +08:00
catlog22
0a96ee16a8 Add tool strategy documentation with triggering mechanisms and text processing references
- Introduced auto and manual triggering mechanisms for Exa.
- Added quick reference guides for sed and awk text processing.
- Established a fallback strategy for handling edit failures.
2025-12-07 17:09:07 +08:00
catlog22
43c962b48b feat: Add Notifications Component with WebSocket and Auto Refresh
- Implemented a Notifications component for real-time updates using WebSocket.
- Added silent refresh functionality to update data without notification bubbles.
- Introduced auto-refresh mechanism to periodically check for changes in workflow data.
- Enhanced data handling with session and task updates, ensuring UI reflects the latest state.

feat: Create Hook Manager View for Managing Hooks

- Developed a Hook Manager view to manage project and global hooks.
- Added functionality to create, edit, and delete hooks with a user-friendly interface.
- Implemented quick install templates for common hooks to streamline user experience.
- Included environment variables reference for hooks to assist users in configuration.

feat: Implement MCP Manager View for Server Management

- Created an MCP Manager view for managing MCP servers within projects.
- Enabled adding and removing servers from projects with a clear UI.
- Displayed available servers from other projects for easy access and management.
- Provided an overview of all projects and their associated MCP servers.

feat: Add Version Fetcher Utility for GitHub Releases

- Implemented a version fetcher utility to retrieve release information from GitHub.
- Added functions to fetch the latest release, recent releases, and latest commit details.
- Included functionality to download and extract repository zip files.
- Ensured cleanup of temporary directories after downloads to maintain system hygiene.
2025-12-07 15:48:39 +08:00
catlog22
724545ebd6 Remove backup HTML template for workflow dashboard 2025-12-07 12:59:59 +08:00
catlog22
a9a2004d4a feat(dashboard): add context sections for assets, dependencies, test context, and conflict detection 2025-12-06 21:04:50 +08:00
catlog22
5b14c8a832 feat: add project overview section and enhance task item styles
- Introduced a new project overview section in the dashboard, displaying project details, technology stack, architecture, key components, and development history.
- Updated the server logic to include project overview data.
- Enhanced task item styles with status-based background colors for better visual distinction.
- Improved markdown modal functionality for viewing context and implementation plan with normalized line endings.
- Refactored task rendering logic to simplify task item display and improve performance.
2025-12-06 20:50:23 +08:00
catlog22
e2c5a514cb fix(data-aggregator): remove extra closing brace causing syntax error
- Fixed loadDimensionData function that had duplicate closing brace
- This was preventing review session dimension data from being parsed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:59:45 +08:00
catlog22
296761a34e style(dashboard): enhance step numbers and mod-point card styling
- Change step numbers from circles to rounded rectangles
- Add shadow to step number badges for depth
- Enhance mod-point-card with full border and stronger left accent
- Add hover effect with elevated shadow on mod-point-card

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 16:52:36 +08:00
catlog22
1d3436d51b feat(dashboard): simplify lite task list and add dedicated drawer
- Remove META/CONTEXT/FLOW_CONTROL collapsible sections from task list
- Add compact task item with action/scope/mods/steps badges
- Create dedicated renderLiteTaskDrawerContent for plan.json parsing
- Add Overview tab with description, scope, acceptance, dependencies
- Add Implementation tab with steps and modification points
- Add proper file list extraction from modification_points
- Add CSS styles for lite task badges and drawer components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:51:37 +08:00
catlog22
60bb11c315 feat(dashboard): simplify lite task UI and add exploration context support
- Remove status indicators from lite task cards (progress bars, percentages)
- Remove status icons and badges from task detail items
- Remove stats bar showing completed/in-progress/pending counts
- Add Plan tab in drawer for plan.json data display
- Add exploration-*.json parsing for context tab
- Add collapsible sections for architecture, dependencies, patterns
- Fix currentPath selector bug causing TypeError

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:47:49 +08:00
catlog22
72fe6195af fix: update Codex multiplier to reflect new time allocation guidelines 2025-12-05 11:14:42 +08:00
catlog22
04fb3b7ee3 feat(dashboard): add plan data loading and lite task detail styling
- Add plan.json data loading to getSessionDetailData function for lite tasks
- Implement plan tab content styling with summary and approach sections
- Add plan metadata grid layout for displaying task planning information
- Create lite task detail page styles with task stats bar and status indicators
- Add context tab content styling with improved section hierarchy
- Implement path tags and JSON content display styles for plan details
- Add collapsible sections for organizing plan information
- Create comprehensive styling for context fields, modification points, and implementation steps
- Add array and nested object rendering styles for JSON data visualization
- Implement button styles for JSON view toggle functionality
2025-12-04 22:55:56 +08:00
catlog22
942fca7ad8 refactor(dashboard): optimize template structure and enhance data aggregation
- Reorder CSS and JS file loading in dashboard-generator.js for consistency
- Simplify dashboard.css by removing redundant styles and consolidating to Tailwind-based approach
- Add backup files for dashboard.html, dashboard.css, and review-cycle-dashboard.html
- Create new Tailwind-based dashboard template (dashboard_tailwind.html) and test variant
- Add tailwind.config.js for Tailwind CSS configuration
- Enhance data-aggregator.js to load full task data for archived sessions (previously only counted)
- Add meta, context, and flow_control fields to task objects for richer data representation
- Implement review data loading for archived sessions to match active session behavior
- Improve task sorting consistency across active and archived sessions
- Reduce CSS file size by ~70% through Tailwind utility consolidation while maintaining visual parity
2025-12-04 21:41:30 +08:00
catlog22
39df995e37 Refactor code structure for improved readability and maintainability 2025-12-04 17:22:25 +08:00
catlog22
efaa8b6620 fix: Refine action-planning-agent documentation for clarity and structure 2025-12-04 09:47:53 +08:00
catlog22
35bd0aa8f6 feat: Add workflow dashboard template and utility functions
- Implemented a new HTML template for the workflow dashboard, featuring a responsive design with dark/light theme support, session statistics, and task management UI.
- Created a browser launcher utility to open HTML files in the default browser across platforms.
- Developed file utility functions for safe reading and writing of JSON and text files.
- Added path resolver utilities to validate and resolve file paths, ensuring security against path traversal attacks.
- Introduced UI utilities for displaying styled messages and banners in the console.
2025-12-04 09:40:12 +08:00
catlog22
0f9adc59f9 docs: Remove deprecated CLI commands, clarify semantic invocation
Addresses issue #33 - /cli:mode:bug-diagnosis command not found

Changes:
- Remove deprecated /cli:* commands (/cli:analyze, /cli:chat, /cli:execute,
  /cli:codex-execute, /cli:discuss-plan, /cli:mode:*) from documentation
- Only /cli:cli-init remains as the sole CLI command
- Update all references to use /workflow:lite-plan, /workflow:lite-fix
- Clarify that CLI tools are now invoked through semantic invocation
  (natural language) - Claude auto-selects Gemini/Qwen/Codex with templates
- Update COMMAND_SPEC.md, COMMAND_REFERENCE.md, GETTING_STARTED*.md,
  FAQ.md, WORKFLOW_DECISION_GUIDE*.md, workflow-architecture.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:05:40 +08:00
88 changed files with 29624 additions and 2894 deletions

View File

@@ -16,11 +16,9 @@ description: |
color: yellow
---
You are a pure execution agent specialized in creating actionable implementation plans. You receive requirements and control flags from the command layer and execute planning tasks without complex decision-making logic.
## Overview
**Agent Role**: Transform user requirements and brainstorming artifacts into structured, executable implementation plans with quantified deliverables and measurable acceptance criteria.
**Agent Role**: Pure execution agent that transforms user requirements and brainstorming artifacts into structured, executable implementation plans with quantified deliverables and measurable acceptance criteria. Receives requirements and control flags from the command layer and executes planning tasks without complex decision-making logic.
**Core Capabilities**:
- Load and synthesize context from multiple sources (session metadata, context packages, brainstorming artifacts)
@@ -33,7 +31,7 @@ You are a pure execution agent specialized in creating actionable implementation
---
## 1. Execution Process
## 1. Input & Execution
### 1.1 Input Processing
@@ -50,7 +48,7 @@ You are a pure execution agent specialized in creating actionable implementation
- **Control flags**: DEEP_ANALYSIS_REQUIRED, etc.
- **Task requirements**: Direct task description
### 1.2 Two-Phase Execution Flow
### 1.2 Execution Flow
#### Phase 1: Context Loading & Assembly
@@ -88,6 +86,27 @@ You are a pure execution agent specialized in creating actionable implementation
6. Assess task complexity (simple/medium/complex)
```
**MCP Integration** (when `mcp_capabilities` available):
```javascript
// Exa Code Context (mcp_capabilities.exa_code = true)
mcp__exa__get_code_context_exa(
query="TypeScript OAuth2 JWT authentication patterns",
tokensNum="dynamic"
)
// Integration in flow_control.pre_analysis
{
"step": "local_codebase_exploration",
"action": "Explore codebase structure",
"commands": [
"bash(rg '^(function|class|interface).*[task_keyword]' --type ts -n --max-count 15)",
"bash(find . -name '*[task_keyword]*' -type f | grep -v node_modules | head -10)"
],
"output_to": "codebase_structure"
}
```
**Context Package Structure** (fields defined by context-search-agent):
**Always Present**:
@@ -169,30 +188,6 @@ if (contextPackage.brainstorm_artifacts?.role_analyses?.length > 0) {
5. Update session state for execution readiness
```
### 1.3 MCP Integration Guidelines
**Exa Code Context** (`mcp_capabilities.exa_code = true`):
```javascript
// Get best practices and examples
mcp__exa__get_code_context_exa(
query="TypeScript OAuth2 JWT authentication patterns",
tokensNum="dynamic"
)
```
**Integration in flow_control.pre_analysis**:
```json
{
"step": "local_codebase_exploration",
"action": "Explore codebase structure",
"commands": [
"bash(rg '^(function|class|interface).*[task_keyword]' --type ts -n --max-count 15)",
"bash(find . -name '*[task_keyword]*' -type f | grep -v node_modules | head -10)"
],
"output_to": "codebase_structure"
}
```
---
## 2. Output Specifications
@@ -213,7 +208,11 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
```
**Field Descriptions**:
- `id`: Task identifier (format: `IMPL-N`)
- `id`: Task identifier
- Single module format: `IMPL-N` (e.g., IMPL-001, IMPL-002)
- Multi-module format: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1, IMPL-C1)
- Prefix: A, B, C... (assigned by module detection order)
- Sequence: 1, 2, 3... (per-module increment)
- `title`: Descriptive task name summarizing the work
- `status`: Task state - `pending` (not started), `active` (in progress), `completed` (done), `blocked` (waiting on dependencies)
- `context_package_path`: Path to smart context package containing project structure, dependencies, and brainstorming artifacts catalog
@@ -225,7 +224,8 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
"meta": {
"type": "feature|bugfix|refactor|test-gen|test-fix|docs",
"agent": "@code-developer|@action-planning-agent|@test-fix-agent|@universal-executor",
"execution_group": "parallel-abc123|null"
"execution_group": "parallel-abc123|null",
"module": "frontend|backend|shared|null"
}
}
```
@@ -234,6 +234,7 @@ Generate individual `.task/IMPL-*.json` files with the following structure:
- `type`: Task category - `feature` (new functionality), `bugfix` (fix defects), `refactor` (restructure code), `test-gen` (generate tests), `test-fix` (fix failing tests), `docs` (documentation)
- `agent`: Assigned agent for execution
- `execution_group`: Parallelization group ID (tasks with same ID can run concurrently) or `null` for sequential tasks
- `module`: Module identifier for multi-module projects (e.g., `frontend`, `backend`, `shared`) or `null` for single-module
**Test Task Extensions** (for type="test-gen" or type="test-fix"):
@@ -604,10 +605,42 @@ Agent determines CLI tool usage per-step based on user semantics and task nature
- Analysis results (technical approach, architecture decisions)
- Brainstorming artifacts (role analyses, guidance specifications)
**Multi-Module Format** (when modules detected):
When multiple modules are detected (frontend/backend, etc.), organize IMPL_PLAN.md by module:
```markdown
# Implementation Plan
## Module A: Frontend (N tasks)
### IMPL-A1: [Task Title]
[Task details...]
### IMPL-A2: [Task Title]
[Task details...]
## Module B: Backend (N tasks)
### IMPL-B1: [Task Title]
[Task details...]
### IMPL-B2: [Task Title]
[Task details...]
## Cross-Module Dependencies
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
- IMPL-A2 → IMPL-B2 (UI state depends on Backend service)
```
**Cross-Module Dependency Notation**:
- During parallel planning, use `CROSS::{module}::{pattern}` format
- Example: `depends_on: ["CROSS::B::api-endpoint"]`
- Integration phase resolves to actual task IDs: `CROSS::B::api → IMPL-B1`
### 2.3 TODO_LIST.md Structure
Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
**Single Module Format**:
```markdown
# Tasks: {Session Topic}
@@ -621,30 +654,54 @@ Generate at `.workflow/active/{session_id}/TODO_LIST.md`:
- `- [x]` = Completed task
```
**Multi-Module Format** (hierarchical by module):
```markdown
# Tasks: {Session Topic}
## Module A (Frontend)
- [ ] **IMPL-A1**: [Task Title] → [📋](./.task/IMPL-A1.json)
- [ ] **IMPL-A2**: [Task Title] → [📋](./.task/IMPL-A2.json)
## Module B (Backend)
- [ ] **IMPL-B1**: [Task Title] → [📋](./.task/IMPL-B1.json)
- [ ] **IMPL-B2**: [Task Title] → [📋](./.task/IMPL-B2.json)
## Cross-Module Dependencies
- IMPL-A1 → IMPL-B1 (Frontend depends on Backend API)
## Status Legend
- `- [ ]` = Pending task
- `- [x]` = Completed task
```
**Linking Rules**:
- Todo items → task JSON: `[📋](./.task/IMPL-XXX.json)`
- Completed tasks → summaries: `[✅](./.summaries/IMPL-XXX-summary.md)`
- Consistent ID schemes: IMPL-XXX
- Consistent ID schemes: `IMPL-N` (single) or `IMPL-{prefix}{seq}` (multi-module)
### 2.4 Complexity-Based Structure Selection
### 2.4 Complexity & Structure Selection
Use `analysis_results.complexity` or task count to determine structure:
**Simple Tasks** (≤5 tasks):
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
- All tasks at same level
**Single Module Mode**:
- **Simple Tasks** (≤5 tasks): Flat structure
- **Medium Tasks** (6-12 tasks): Flat structure
- **Complex Tasks** (>12 tasks): Re-scope required (maximum 12 tasks hard limit)
**Medium Tasks** (6-12 tasks):
- Flat structure: IMPL_PLAN.md + TODO_LIST.md + task JSONs
- All tasks at same level
**Multi-Module Mode** (N+1 parallel planning):
- **Per-module limit**: ≤9 tasks per module
- **Total limit**: Sum of all module tasks ≤27 (3 modules × 9 tasks)
- **Task ID format**: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
- **Structure**: Hierarchical by module in IMPL_PLAN.md and TODO_LIST.md
**Complex Tasks** (>12 tasks):
- **Re-scope required**: Maximum 12 tasks hard limit
- If analysis_results contains >12 tasks, consolidate or request re-scoping
**Multi-Module Detection Triggers**:
- Explicit frontend/backend separation (`src/frontend`, `src/backend`)
- Monorepo structure (`packages/*`, `apps/*`)
- Context-package dependency clustering (2+ distinct module groups)
---
## 3. Quality & Standards
## 3. Quality Standards
### 3.1 Quantification Requirements (MANDATORY)
@@ -670,47 +727,46 @@ Use `analysis_results.complexity` or task count to determine structure:
- [ ] Each implementation step has its own acceptance criteria
**Examples**:
- GOOD: `"Implement 5 commands: [cmd1, cmd2, cmd3, cmd4, cmd5]"`
- BAD: `"Implement new commands"`
- GOOD: `"5 files created: verify by ls .claude/commands/*.md | wc -l = 5"`
- BAD: `"All commands implemented successfully"`
- GOOD: `"Implement 5 commands: [cmd1, cmd2, cmd3, cmd4, cmd5]"`
- BAD: `"Implement new commands"`
- GOOD: `"5 files created: verify by ls .claude/commands/*.md | wc -l = 5"`
- BAD: `"All commands implemented successfully"`
### 3.2 Planning Principles
### 3.2 Planning & Organization Standards
**Planning Principles**:
- Each stage produces working, testable code
- Clear success criteria for each deliverable
- Dependencies clearly identified between stages
- Incremental progress over big bangs
### 3.3 File Organization
**File Organization**:
- Session naming: `WFS-[topic-slug]`
- Task IDs: IMPL-XXX (flat structure only)
- Directory structure: flat task organization
### 3.4 Document Standards
- Task IDs:
- Single module: `IMPL-N` (e.g., IMPL-001, IMPL-002)
- Multi-module: `IMPL-{prefix}{seq}` (e.g., IMPL-A1, IMPL-B1)
- Directory structure: flat task organization (all tasks in `.task/`)
**Document Standards**:
- Proper linking between documents
- Consistent navigation and references
---
## 4. Key Reminders
### 3.3 Guidelines Checklist
**ALWAYS:**
- **Apply Quantification Requirements**: All requirements, acceptance criteria, and modification points MUST include explicit counts and enumerations
- **Load IMPL_PLAN template**: Read(~/.claude/workflows/cli-templates/prompts/workflow/impl-plan-template.txt) before generating IMPL_PLAN.md
- **Use provided context package**: Extract all information from structured context
- **Respect memory-first rule**: Use provided content (already loaded from memory/file)
- **Follow 6-field schema**: All task JSONs must have id, title, status, context_package_path, meta, context, flow_control
- **Map artifacts**: Use artifacts_inventory to populate task.context.artifacts array
- **Add MCP integration**: Include MCP tool steps in flow_control.pre_analysis when capabilities available
- **Validate task count**: Maximum 12 tasks hard limit, request re-scope if exceeded
- **Use session paths**: Construct all paths using provided session_id
- **Link documents properly**: Use correct linking format (📋 for JSON, ✅ for summaries)
- **Run validation checklist**: Verify all quantification requirements before finalizing task JSONs
- **Apply 举一反三 principle**: Adapt pre-analysis patterns to task-specific needs dynamically
- **Follow template validation**: Complete IMPL_PLAN.md template validation checklist before finalization
- Apply Quantification Requirements to all requirements, acceptance criteria, and modification points
- Load IMPL_PLAN template: `Read(~/.claude/workflows/cli-templates/prompts/workflow/impl-plan-template.txt)` before generating IMPL_PLAN.md
- Use provided context package: Extract all information from structured context
- Respect memory-first rule: Use provided content (already loaded from memory/file)
- Follow 6-field schema: All task JSONs must have id, title, status, context_package_path, meta, context, flow_control
- Map artifacts: Use artifacts_inventory to populate task.context.artifacts array
- Add MCP integration: Include MCP tool steps in flow_control.pre_analysis when capabilities available
- Validate task count: Maximum 12 tasks hard limit, request re-scope if exceeded
- Use session paths: Construct all paths using provided session_id
- Link documents properly: Use correct linking format (📋 for JSON, ✅ for summaries)
- Run validation checklist: Verify all quantification requirements before finalizing task JSONs
- Apply 举一反三 principle: Adapt pre-analysis patterns to task-specific needs dynamically
- Follow template validation: Complete IMPL_PLAN.md template validation checklist before finalization
**NEVER:**
- Load files directly (use provided context package instead)

View File

@@ -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

View File

@@ -46,8 +46,7 @@ Automated fix orchestrator with **two-phase architecture**: AI-powered planning
1. **Intelligent Planning**: AI-powered analysis identifies optimal grouping and execution strategy
2. **Multi-stage Coordination**: Supports complex parallel + serial execution with dependency management
3. **Conservative Safety**: Mandatory test verification with automatic rollback on failure
4. **Real-time Visibility**: Dashboard shows planning progress, stage timeline, and active agents
5. **Resume Support**: Checkpoint-based recovery for interrupted sessions
4. **Resume Support**: Checkpoint-based recovery for interrupted sessions
### Orchestrator Boundary (CRITICAL)
- **ONLY command** for automated review finding fixes
@@ -59,14 +58,14 @@ Automated fix orchestrator with **two-phase architecture**: AI-powered planning
```
Phase 1: Discovery & Initialization
└─ Validate export file, create fix session structure, initialize state files → Generate fix-dashboard.html
└─ Validate export file, create fix session structure, initialize state files
Phase 2: Planning Coordination (@cli-planning-agent)
├─ Analyze findings for patterns and dependencies
├─ Group by file + dimension + root cause similarity
├─ Determine execution strategy (parallel/serial/hybrid)
├─ Generate fix timeline with stages
└─ Output: fix-plan.json (dashboard auto-polls for status)
└─ Output: fix-plan.json
Phase 3: Execution Orchestration (Stage-based)
For each timeline stage:
@@ -198,12 +197,10 @@ if (result.passRate < 100%) {
- Session creation: Generate fix-session-id (`fix-{timestamp}`)
- Directory structure: Create `{review-dir}/fixes/{fix-session-id}/` with subdirectories
- State files: Initialize active-fix-session.json (session marker)
- Dashboard generation: Create fix-dashboard.html from template (see Dashboard Generation below)
- TodoWrite initialization: Set up 4-phase tracking
**Phase 2: Planning Coordination**
- Launch @cli-planning-agent with findings data and project context
- Monitor planning progress (dashboard shows "Planning fixes..." indicator)
- Validate fix-plan.json output (schema conformance, includes metadata with session status)
- Load plan into memory for execution phase
- TodoWrite update: Mark planning complete, start execution
@@ -216,7 +213,6 @@ if (result.passRate < 100%) {
- Assign agent IDs (agents update their fix-progress-{N}.json)
- Handle agent failures gracefully (mark group as failed, continue)
- Advance to next stage only when current stage complete
- Dashboard polls and aggregates fix-progress-{N}.json files for display
**Phase 4: Completion & Aggregation**
- Collect final status from all fix-progress-{N}.json files
@@ -224,7 +220,7 @@ if (result.passRate < 100%) {
- Update fix-history.json with new session entry
- Remove active-fix-session.json
- TodoWrite completion: Mark all phases done
- Output summary to user with dashboard link
- Output summary to user
**Phase 5: Session Completion (Optional)**
- If all findings fixed successfully (no failures):
@@ -234,53 +230,12 @@ if (result.passRate < 100%) {
- Output: "Some findings failed. Review fix-summary.md before completing session."
- Do NOT auto-complete session
### Dashboard Generation
**MANDATORY**: Dashboard MUST be generated from template during Phase 1 initialization
**Template Location**: `~/.claude/templates/fix-dashboard.html`
**⚠️ POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify fix-dashboard.html after creation
**Generation Steps**:
```bash
# 1. Copy template to fix session directory
cp ~/.claude/templates/fix-dashboard.html ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
# 2. Replace SESSION_ID placeholder
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
# 3. Replace REVIEW_DIR placeholder
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/fixes/${fixSessionId}/fix-dashboard.html
# 4. Start local server and output dashboard URL
cd ${sessionDir}/fixes/${fixSessionId} && python -m http.server 8766 --bind 127.0.0.1 &
echo "🔧 Fix Dashboard: http://127.0.0.1:8766/fix-dashboard.html"
echo " (Press Ctrl+C to stop server when done)"
```
**Dashboard Features**:
- Real-time progress tracking via JSON polling (3-second interval)
- Stage timeline visualization with parallel/serial execution modes
- Active groups and agents monitoring
- Flow control steps tracking for each agent
- Fix history drawer with session summaries
- Consumes new JSON structure (fix-plan.json with metadata + fix-progress-{N}.json)
**JSON Consumption**:
- `fix-plan.json`: Reads metadata field for session info, timeline stages, groups configuration
- `fix-progress-{N}.json`: Polls all progress files to aggregate real-time status
- `active-fix-session.json`: Detects active session on load
- `fix-history.json`: Loads historical fix sessions
### Output File Structure
```
.workflow/active/WFS-{session-id}/.review/
├── fix-export-{timestamp}.json # Exported findings (input)
└── fixes/{fix-session-id}/
├── fix-dashboard.html # Interactive dashboard (generated once, auto-polls JSON)
├── fix-plan.json # Planning agent output (execution plan with metadata)
├── fix-progress-1.json # Group 1 progress (planning agent init → agent updates)
├── fix-progress-2.json # Group 2 progress (planning agent init → agent updates)
@@ -291,10 +246,8 @@ echo " (Press Ctrl+C to stop server when done)"
```
**File Producers**:
- **Orchestrator**: `fix-dashboard.html` (generated once from template during Phase 1)
- **Planning Agent**: `fix-plan.json` (with metadata), all `fix-progress-*.json` (initial state)
- **Execution Agents**: Update assigned `fix-progress-{N}.json` in real-time
- **Dashboard (Browser)**: Reads `fix-plan.json` + all `fix-progress-*.json`, aggregates in-memory every 3 seconds via JavaScript polling
### Agent Invocation Template
@@ -347,7 +300,7 @@ For each group (G1, G2, G3, ...), generate fix-progress-{N}.json following templ
- Flow control: Empty implementation_approach array
- Errors: Empty array
**CRITICAL**: Ensure complete template structure for Dashboard consumption - all fields must be present.
**CRITICAL**: Ensure complete template structure - all fields must be present.
## Analysis Requirements
@@ -419,7 +372,7 @@ Task({
description: `Fix ${group.findings.length} issues: ${group.group_name}`,
prompt: `
## Task Objective
Execute fixes for code review findings in group ${group.group_id}. Update progress file in real-time with flow control tracking for dashboard visibility.
Execute fixes for code review findings in group ${group.group_id}. Update progress file in real-time with flow control tracking.
## Assignment
- Group ID: ${group.group_id}
@@ -549,7 +502,6 @@ When all findings processed:
### Progress File Updates
- **MUST update after every significant action** (before/after each step)
- **Dashboard polls every 3 seconds** - ensure writes are atomic
- **Always maintain complete structure** - never write partial updates
- **Use ISO 8601 timestamps** - e.g., "2025-01-25T14:36:00Z"
@@ -638,9 +590,17 @@ TodoWrite({
1. **Trust AI Planning**: Planning agent's grouping and execution strategy are based on dependency analysis
2. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests
3. **Parallel Efficiency**: Default 3 concurrent agents balances speed and resource usage
4. **Monitor Dashboard**: Real-time stage timeline and agent status provide execution visibility
5. **Resume Support**: Fix sessions can resume from checkpoints after interruption
6. **Manual Review**: Always review failed fixes manually - may require architectural changes
7. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes
4. **Resume Support**: Fix sessions can resume from checkpoints after interruption
5. **Manual Review**: Always review failed fixes manually - may require architectural changes
6. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes
## Related Commands
### View Fix Progress
Use `ccw view` to open the workflow dashboard in browser:
```bash
ccw view
```

View File

@@ -51,14 +51,12 @@ Independent multi-dimensional code review orchestrator with **hybrid parallel-it
2. **Session-Integrated**: Review results tracked within workflow session for unified management
3. **Comprehensive Coverage**: Same 7 specialized dimensions as session review
4. **Intelligent Prioritization**: Automatic identification of critical issues and cross-cutting concerns
5. **Real-time Visibility**: JSON-based progress tracking with interactive HTML dashboard
6. **Unified Archive**: Review results archived with session for historical reference
5. **Unified Archive**: Review results archived with session for historical reference
### Orchestrator Boundary (CRITICAL)
- **ONLY command** for independent multi-dimensional module review
- Manages: dimension coordination, aggregation, iteration control, progress tracking
- Delegates: Code exploration and analysis to @cli-explore-agent, dimension-specific reviews via Deep Scan mode
- **⚠️ DASHBOARD CONSTRAINT**: Dashboard is generated ONCE during Phase 1 initialization. After initialization, orchestrator and agents MUST NOT read, write, or modify dashboard.html - it remains static for user interaction only.
## How It Works
@@ -66,7 +64,7 @@ Independent multi-dimensional code review orchestrator with **hybrid parallel-it
```
Phase 1: Discovery & Initialization
└─ Resolve file patterns, validate paths, initialize state, create output structure → Generate dashboard.html
└─ Resolve file patterns, validate paths, initialize state, create output structure
Phase 2: Parallel Reviews (for each dimension)
├─ Launch 7 review agents simultaneously
@@ -90,7 +88,7 @@ Phase 4: Iterative Deep-Dive (optional)
└─ Loop until no critical findings OR max iterations
Phase 5: Completion
└─ Finalize review-progress.json → Output dashboard path
└─ Finalize review-progress.json
```
### Agent Roles
@@ -188,8 +186,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];
@@ -219,37 +217,9 @@ done
**Step 4: Initialize Review State**
- State initialization: Create `review-state.json` with metadata, dimensions, max_iterations, resolved_files (merged metadata + state)
- Progress tracking: Create `review-progress.json` for dashboard polling
- Progress tracking: Create `review-progress.json` for progress tracking
**Step 5: Dashboard Generation**
**Constraints**:
- **MANDATORY**: Dashboard MUST be generated from template: `~/.claude/templates/review-cycle-dashboard.html`
- **PROHIBITED**: Direct creation or custom generation without template
- **POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify dashboard.html after creation
**Generation Commands** (3 independent steps):
```bash
# Step 1: Copy template to output location
cp ~/.claude/templates/review-cycle-dashboard.html ${sessionDir}/.review/dashboard.html
# Step 2: Replace SESSION_ID placeholder
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/.review/dashboard.html
# Step 3: Replace REVIEW_TYPE placeholder
sed -i "s|{{REVIEW_TYPE}}|module|g" ${sessionDir}/.review/dashboard.html
# Step 4: Replace REVIEW_DIR placeholder
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/.review/dashboard.html
# Output: Start local server and output dashboard URL
# Use Python HTTP server (available on most systems)
cd ${sessionDir}/.review && python -m http.server 8765 --bind 127.0.0.1 &
echo "📊 Dashboard: http://127.0.0.1:8765/dashboard.html"
echo " (Press Ctrl+C to stop server when done)"
```
**Step 6: TodoWrite Initialization**
**Step 5: TodoWrite Initialization**
- Set up progress tracking with hierarchical structure
- Mark Phase 1 completed, Phase 2 in_progress
@@ -280,7 +250,6 @@ echo " (Press Ctrl+C to stop server when done)"
- Finalize review-progress.json with completion statistics
- Update review-state.json with completion_time and phase=complete
- TodoWrite completion: Mark all tasks done
- Output: Dashboard path to user
@@ -301,12 +270,11 @@ echo " (Press Ctrl+C to stop server when done)"
├── iterations/ # Deep-dive results
│ ├── iteration-1-finding-{uuid}.json
│ └── iteration-2-finding-{uuid}.json
── reports/ # Human-readable reports
├── security-analysis.md
├── security-cli-output.txt
├── deep-dive-1-{uuid}.md
└── ...
└── dashboard.html # Interactive dashboard (primary output)
── reports/ # Human-readable reports
├── security-analysis.md
├── security-cli-output.txt
├── deep-dive-1-{uuid}.md
└── ...
```
**Session Context**:
@@ -772,23 +740,25 @@ TodoWrite({
3. **Use Glob Wisely**: `src/auth/**` is more efficient than `src/**` with lots of irrelevant files
4. **Trust Aggregation Logic**: Auto-selection based on proven heuristics
5. **Monitor Logs**: Check reports/ directory for CLI analysis insights
6. **Dashboard Polling**: Refresh every 5 seconds for real-time updates
7. **Export Results**: Use dashboard export for external tracking tools
## Related Commands
### View Review Progress
Use `ccw view` to open the review dashboard in browser:
```bash
ccw view
```
### Automated Fix Workflow
After completing a module review, use the dashboard to select findings and export them for automated fixing:
After completing a module review, use the generated findings JSON for automated fixing:
```bash
# Step 1: Complete review (this command)
/workflow:review-module-cycle src/auth/**
# Step 2: Open dashboard, select findings, and export
# Dashboard generates: fix-export-{timestamp}.json
# Step 3: Run automated fixes
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/fix-export-{timestamp}.json
# Step 2: Run automated fixes using dimension findings
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/
```
See `/workflow:review-fix` for automated fixing with smart grouping, parallel execution, and test verification.

View File

@@ -45,13 +45,11 @@ Session-based multi-dimensional code review orchestrator with **hybrid parallel-
1. **Comprehensive Coverage**: 7 specialized dimensions analyze all quality aspects simultaneously
2. **Intelligent Prioritization**: Automatic identification of critical issues and cross-cutting concerns
3. **Actionable Insights**: Deep-dive iterations provide step-by-step remediation plans
4. **Real-time Visibility**: JSON-based progress tracking with interactive HTML dashboard
### Orchestrator Boundary (CRITICAL)
- **ONLY command** for comprehensive multi-dimensional review
- Manages: dimension coordination, aggregation, iteration control, progress tracking
- Delegates: Code exploration and analysis to @cli-explore-agent, dimension-specific reviews via Deep Scan mode
- **⚠️ DASHBOARD CONSTRAINT**: Dashboard is generated ONCE during Phase 1 initialization. After initialization, orchestrator and agents MUST NOT read, write, or modify dashboard.html - it remains static for user interaction only.
## How It Works
@@ -59,7 +57,7 @@ Session-based multi-dimensional code review orchestrator with **hybrid parallel-
```
Phase 1: Discovery & Initialization
└─ Validate session, initialize state, create output structure → Generate dashboard.html
└─ Validate session, initialize state, create output structure
Phase 2: Parallel Reviews (for each dimension)
├─ Launch 7 review agents simultaneously
@@ -83,7 +81,7 @@ Phase 4: Iterative Deep-Dive (optional)
└─ Loop until no critical findings OR max iterations
Phase 5: Completion
└─ Finalize review-progress.json → Output dashboard path
└─ Finalize review-progress.json
```
### Agent Roles
@@ -199,36 +197,9 @@ git log --since="${sessionCreatedAt}" --name-only --pretty=format: | sort -u
**Step 5: Initialize Review State**
- State initialization: Create `review-state.json` with metadata, dimensions, max_iterations (merged metadata + state)
- Progress tracking: Create `review-progress.json` for dashboard polling
- Progress tracking: Create `review-progress.json` for progress tracking
**Step 6: Dashboard Generation**
**Constraints**:
- **MANDATORY**: Dashboard MUST be generated from template: `~/.claude/templates/review-cycle-dashboard.html`
- **PROHIBITED**: Direct creation or custom generation without template
- **POST-GENERATION**: Orchestrator and agents MUST NOT read/write/modify dashboard.html after creation
**Generation Commands** (3 independent steps):
```bash
# Step 1: Copy template to output location
cp ~/.claude/templates/review-cycle-dashboard.html ${sessionDir}/.review/dashboard.html
# Step 2: Replace SESSION_ID placeholder
sed -i "s|{{SESSION_ID}}|${sessionId}|g" ${sessionDir}/.review/dashboard.html
# Step 3: Replace REVIEW_TYPE placeholder
sed -i "s|{{REVIEW_TYPE}}|session|g" ${sessionDir}/.review/dashboard.html
# Step 4: Replace REVIEW_DIR placeholder
sed -i "s|{{REVIEW_DIR}}|${reviewDir}|g" ${sessionDir}/.review/dashboard.html
# Output: Start local server and output dashboard URL
cd ${sessionDir}/.review && python -m http.server 8765 --bind 127.0.0.1 &
echo "📊 Dashboard: http://127.0.0.1:8765/dashboard.html"
echo " (Press Ctrl+C to stop server when done)"
```
**Step 7: TodoWrite Initialization**
**Step 6: TodoWrite Initialization**
- Set up progress tracking with hierarchical structure
- Mark Phase 1 completed, Phase 2 in_progress
@@ -259,7 +230,6 @@ echo " (Press Ctrl+C to stop server when done)"
- Finalize review-progress.json with completion statistics
- Update review-state.json with completion_time and phase=complete
- TodoWrite completion: Mark all tasks done
- Output: Dashboard path to user
@@ -280,12 +250,11 @@ echo " (Press Ctrl+C to stop server when done)"
├── iterations/ # Deep-dive results
│ ├── iteration-1-finding-{uuid}.json
│ └── iteration-2-finding-{uuid}.json
── reports/ # Human-readable reports
├── security-analysis.md
├── security-cli-output.txt
├── deep-dive-1-{uuid}.md
└── ...
└── dashboard.html # Interactive dashboard (primary output)
── reports/ # Human-readable reports
├── security-analysis.md
├── security-cli-output.txt
├── deep-dive-1-{uuid}.md
└── ...
```
**Session Context**:
@@ -782,23 +751,25 @@ TodoWrite({
2. **Parallel Execution**: ~60 minutes for full initial review (7 dimensions)
3. **Trust Aggregation Logic**: Auto-selection based on proven heuristics
4. **Monitor Logs**: Check reports/ directory for CLI analysis insights
5. **Dashboard Polling**: Refresh every 5 seconds for real-time updates
6. **Export Results**: Use dashboard export for external tracking tools
## Related Commands
### View Review Progress
Use `ccw view` to open the review dashboard in browser:
```bash
ccw view
```
### Automated Fix Workflow
After completing a review, use the dashboard to select findings and export them for automated fixing:
After completing a review, use the generated findings JSON for automated fixing:
```bash
# Step 1: Complete review (this command)
/workflow:review-session-cycle
# Step 2: Open dashboard, select findings, and export
# Dashboard generates: fix-export-{timestamp}.json
# Step 3: Run automated fixes
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/fix-export-{timestamp}.json
# Step 2: Run automated fixes using dimension findings
/workflow:review-fix .workflow/active/WFS-{session-id}/.review/
```
See `/workflow:review-fix` for automated fixing with smart grouping, parallel execution, and test verification.

View File

@@ -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`

View File

@@ -1,352 +0,0 @@
---
name: workflow:status
description: Generate on-demand views for project overview and workflow tasks with optional task-id filtering for detailed view
argument-hint: "[optional: --project|task-id|--validate|--dashboard]"
---
# Workflow Status Command (/workflow:status)
## Overview
Generates on-demand views from project and session data. Supports multiple modes:
1. **Project Overview** (`--project`): Shows completed features and project statistics
2. **Workflow Tasks** (default): Shows current session task progress
3. **HTML Dashboard** (`--dashboard`): Generates interactive HTML task board with active and archived sessions
No synchronization needed - all views are calculated from current JSON state.
## Usage
```bash
/workflow:status # Show current workflow session overview
/workflow:status --project # Show project-level feature registry
/workflow:status impl-1 # Show specific task details
/workflow:status --validate # Validate workflow integrity
/workflow:status --dashboard # Generate HTML dashboard board
```
## Execution Process
```
Input Parsing:
└─ Decision (mode detection):
├─ --project flag → Project Overview Mode
├─ --dashboard flag → Dashboard Mode
├─ task-id argument → Task Details Mode
└─ No flags → Workflow Session Mode (default)
Project Overview Mode:
├─ Check project.json exists
├─ Read project data
├─ Parse and display overview + features
└─ Show recent archived sessions
Workflow Session Mode (default):
├─ Find active session
├─ Load session data
├─ Scan task files
└─ Display task progress
Dashboard Mode:
├─ Collect active sessions
├─ Collect archived sessions
├─ Generate HTML from template
└─ Write dashboard.html
```
## Implementation Flow
### Mode Selection
**Check for --project flag**:
- If `--project` flag present → Execute **Project Overview Mode**
- Otherwise → Execute **Workflow Session Mode** (default)
## Project Overview Mode
### Step 1: Check Project State
```bash
bash(test -f .workflow/project.json && echo "EXISTS" || echo "NOT_FOUND")
```
**If NOT_FOUND**:
```
No project state found.
Run /workflow:session:start to initialize project.
```
### Step 2: Read Project Data
```bash
bash(cat .workflow/project.json)
```
### Step 3: Parse and Display
**Data Processing**:
```javascript
const projectData = JSON.parse(Read('.workflow/project.json'));
const features = projectData.features || [];
const stats = projectData.statistics || {};
const overview = projectData.overview || null;
// Sort features by implementation date (newest first)
const sortedFeatures = features.sort((a, b) =>
new Date(b.implemented_at) - new Date(a.implemented_at)
);
```
**Output Format** (with extended overview):
```
## Project: ${projectData.project_name}
Initialized: ${projectData.initialized_at}
${overview ? `
### Overview
${overview.description}
**Technology Stack**:
${overview.technology_stack.languages.map(l => `- ${l.name}${l.primary ? ' (primary)' : ''}: ${l.file_count} files`).join('\n')}
Frameworks: ${overview.technology_stack.frameworks.join(', ')}
**Architecture**:
Style: ${overview.architecture.style}
Patterns: ${overview.architecture.patterns.join(', ')}
**Key Components** (${overview.key_components.length}):
${overview.key_components.map(c => `- ${c.name} (${c.path})\n ${c.description}`).join('\n')}
---
` : ''}
### Completed Features (${stats.total_features})
${sortedFeatures.map(f => `
- ${f.title} (${f.timeline?.implemented_at || f.implemented_at})
${f.description}
Tags: ${f.tags?.join(', ') || 'none'}
Session: ${f.traceability?.session_id || f.session_id}
Archive: ${f.traceability?.archive_path || 'unknown'}
${f.traceability?.commit_hash ? `Commit: ${f.traceability.commit_hash}` : ''}
`).join('\n')}
### Project Statistics
- Total Features: ${stats.total_features}
- Total Sessions: ${stats.total_sessions}
- Last Updated: ${stats.last_updated}
### Quick Access
- View session details: /workflow:status
- Archive query: jq '.archives[] | select(.session_id == "SESSION_ID")' .workflow/archives/manifest.json
- Documentation: .workflow/docs/${projectData.project_name}/
### Query Commands
# Find by tag
cat .workflow/project.json | jq '.features[] | select(.tags[] == "auth")'
# View archive
cat ${feature.traceability.archive_path}/IMPL_PLAN.md
# List all tags
cat .workflow/project.json | jq -r '.features[].tags[]' | sort -u
```
**Empty State**:
```
## Project: ${projectData.project_name}
Initialized: ${projectData.initialized_at}
No features completed yet.
Complete your first workflow session to add features:
1. /workflow:plan "feature description"
2. /workflow:execute
3. /workflow:session:complete
```
### Step 4: Show Recent Sessions (Optional)
```bash
# List 5 most recent archived sessions
bash(ls -1t .workflow/archives/WFS-* 2>/dev/null | head -5 | xargs -I {} basename {})
```
**Output**:
```
### Recent Sessions
- WFS-auth-system (archived)
- WFS-payment-flow (archived)
- WFS-user-dashboard (archived)
Use /workflow:session:complete to archive current session.
```
## Workflow Session Mode (Default)
### Step 1: Find Active Session
```bash
find .workflow/active/ -name "WFS-*" -type d 2>/dev/null | head -1
```
### Step 2: Load Session Data
```bash
cat .workflow/active/WFS-session/workflow-session.json
```
### Step 3: Scan Task Files
```bash
find .workflow/active/WFS-session/.task/ -name "*.json" -type f 2>/dev/null
```
### Step 4: Generate Task Status
```bash
cat .workflow/active/WFS-session/.task/impl-1.json | jq -r '.status'
```
### Step 5: Count Task Progress
```bash
find .workflow/active/WFS-session/.task/ -name "*.json" -type f | wc -l
find .workflow/active/WFS-session/.summaries/ -name "*.md" -type f 2>/dev/null | wc -l
```
### Step 6: Display Overview
```markdown
# Workflow Overview
**Session**: WFS-session-name
**Progress**: 3/8 tasks completed
## Active Tasks
- [IN PROGRESS] impl-1: Current task in progress
- [ ] impl-2: Next pending task
## Completed Tasks
- [COMPLETED] impl-0: Setup completed
```
## Dashboard Mode (HTML Board)
### Step 1: Check for --dashboard flag
```bash
# If --dashboard flag present → Execute Dashboard Mode
```
### Step 2: Collect Workflow Data
**Collect Active Sessions**:
```bash
# Find all active sessions
find .workflow/active/ -name "WFS-*" -type d 2>/dev/null
# For each active session, read metadata and tasks
for session in $(find .workflow/active/ -name "WFS-*" -type d 2>/dev/null); do
cat "$session/workflow-session.json"
find "$session/.task/" -name "*.json" -type f 2>/dev/null
done
```
**Collect Archived Sessions**:
```bash
# Find all archived sessions
find .workflow/archives/ -name "WFS-*" -type d 2>/dev/null
# Read manifest if exists
cat .workflow/archives/manifest.json 2>/dev/null
# For each archived session, read metadata
for archive in $(find .workflow/archives/ -name "WFS-*" -type d 2>/dev/null); do
cat "$archive/workflow-session.json" 2>/dev/null
# Count completed tasks
find "$archive/.task/" -name "*.json" -type f 2>/dev/null | wc -l
done
```
### Step 3: Process and Structure Data
**Build data structure for dashboard**:
```javascript
const dashboardData = {
activeSessions: [],
archivedSessions: [],
generatedAt: new Date().toISOString()
};
// Process active sessions
for each active_session in active_sessions:
const sessionData = JSON.parse(Read(active_session/workflow-session.json));
const tasks = [];
// Load all tasks for this session
for each task_file in find(active_session/.task/*.json):
const taskData = JSON.parse(Read(task_file));
tasks.push({
task_id: taskData.task_id,
title: taskData.title,
status: taskData.status,
type: taskData.type
});
dashboardData.activeSessions.push({
session_id: sessionData.session_id,
project: sessionData.project,
status: sessionData.status,
created_at: sessionData.created_at || sessionData.initialized_at,
tasks: tasks
});
// Process archived sessions
for each archived_session in archived_sessions:
const sessionData = JSON.parse(Read(archived_session/workflow-session.json));
const taskCount = bash(find archived_session/.task/*.json | wc -l);
dashboardData.archivedSessions.push({
session_id: sessionData.session_id,
project: sessionData.project,
archived_at: sessionData.completed_at || sessionData.archived_at,
taskCount: parseInt(taskCount),
archive_path: archived_session
});
```
### Step 4: Generate HTML from Template
**Load template and inject data**:
```javascript
// Read the HTML template
const template = Read("~/.claude/templates/workflow-dashboard.html");
// Prepare data for injection
const dataJson = JSON.stringify(dashboardData, null, 2);
// Replace placeholder with actual data
const htmlContent = template.replace('{{WORKFLOW_DATA}}', dataJson);
// Ensure .workflow directory exists
bash(mkdir -p .workflow);
```
### Step 5: Write HTML File
```bash
# Write the generated HTML to .workflow/dashboard.html
Write({
file_path: ".workflow/dashboard.html",
content: htmlContent
})
```
### Step 6: Display Success Message
```markdown
Dashboard generated successfully!
Location: .workflow/dashboard.html
Open in browser:
file://$(pwd)/.workflow/dashboard.html
Features:
- 📊 Active sessions overview
- 📦 Archived sessions history
- 🔍 Search and filter
- 📈 Progress tracking
- 🎨 Dark/light theme
Refresh data: Re-run /workflow:status --dashboard
```

View File

@@ -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**:

View File

@@ -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

View File

@@ -14,8 +14,8 @@ Generate implementation planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.
## Core Philosophy
- **Planning Only**: Generate planning documents (IMPL_PLAN.md, task JSONs, TODO_LIST.md) - does NOT implement code
- **Agent-Driven Document Generation**: Delegate plan generation to action-planning-agent
- **N+1 Parallel Planning**: Auto-detect multi-module projects, enable parallel planning (2+1 or 3+1 mode)
- **Progressive Loading**: Load context incrementally (Core → Selective → On-Demand) due to analysis.md file size
- **Two-Phase Flow**: Discovery (context gathering) → Output (planning document generation)
- **Memory-First**: Reuse loaded documents from conversation memory
- **Smart Selection**: Load synthesis_output OR guidance + relevant role analyses, NOT all role analyses
- **MCP-Enhanced**: Use MCP tools for advanced code analysis and research
@@ -28,22 +28,38 @@ Input Parsing:
├─ Parse flags: --session
└─ Validation: session_id REQUIRED
Phase 1: Context Preparation (Command)
Phase 1: Context Preparation & Module Detection (Command)
├─ Assemble session paths (metadata, context package, output dirs)
─ Provide metadata (session_id, execution_mode, mcp_capabilities)
─ Provide metadata (session_id, execution_mode, mcp_capabilities)
├─ Auto-detect modules from context-package + directory structure
└─ Decision:
├─ modules.length == 1 → Single Agent Mode (Phase 2A)
└─ modules.length >= 2 → Parallel Mode (Phase 2B + Phase 3)
Phase 2: Planning Document Generation (Agent)
Phase 2A: Single Agent Planning (Original Flow)
├─ Load context package (progressive loading strategy)
├─ Generate Task JSON Files (.task/IMPL-*.json)
├─ Create IMPL_PLAN.md
└─ Generate TODO_LIST.md
Phase 2B: N Parallel Planning (Multi-Module)
├─ Launch N action-planning-agents simultaneously (one per module)
├─ Each agent generates module-scoped tasks (IMPL-{prefix}{seq}.json)
├─ Task ID format: IMPL-A1, IMPL-A2... / IMPL-B1, IMPL-B2...
└─ Each module limited to ≤9 tasks
Phase 3: Integration (+1 Coordinator, Multi-Module Only)
├─ Collect all module task JSONs
├─ Resolve cross-module dependencies (CROSS::{module}::{pattern} → actual ID)
├─ Generate unified IMPL_PLAN.md (grouped by module)
└─ Generate TODO_LIST.md (hierarchical: module → tasks)
```
## Document Generation Lifecycle
### Phase 1: Context Preparation (Command Responsibility)
### Phase 1: Context Preparation & Module Detection (Command Responsibility)
**Command prepares session paths and metadata for planning document generation.**
**Command prepares session paths, metadata, and detects module structure.**
**Session Path Structure**:
```
@@ -52,8 +68,12 @@ Phase 2: Planning Document Generation (Agent)
├── .process/
│ └── context-package.json # Context package with artifact catalog
├── .task/ # Output: Task JSON files
├── IMPL_PLAN.md # Output: Implementation plan
└── TODO_LIST.md # Output: TODO list
├── IMPL-A1.json # Multi-module: prefixed by module
│ ├── IMPL-A2.json
│ ├── IMPL-B1.json
│ └── ...
├── IMPL_PLAN.md # Output: Implementation plan (grouped by module)
└── TODO_LIST.md # Output: TODO list (hierarchical)
```
**Command Preparation**:
@@ -66,9 +86,40 @@ Phase 2: Planning Document Generation (Agent)
- `session_id`
- `mcp_capabilities` (available MCP tools)
3. **Auto Module Detection** (determines single vs parallel mode):
```javascript
function autoDetectModules(contextPackage, projectRoot) {
// Priority 1: Explicit frontend/backend separation
if (exists('src/frontend') && exists('src/backend')) {
return [
{ name: 'frontend', prefix: 'A', paths: ['src/frontend'] },
{ name: 'backend', prefix: 'B', paths: ['src/backend'] }
];
}
// Priority 2: Monorepo structure
if (exists('packages/*') || exists('apps/*')) {
return detectMonorepoModules(); // Returns 2-3 main packages
}
// Priority 3: Context-package dependency clustering
const modules = clusterByDependencies(contextPackage.dependencies?.internal);
if (modules.length >= 2) return modules.slice(0, 3);
// Default: Single module (original flow)
return [{ name: 'main', prefix: '', paths: ['.'] }];
}
```
**Decision Logic**:
- `modules.length == 1` → Phase 2A (Single Agent, original flow)
- `modules.length >= 2` → Phase 2B + Phase 3 (N+1 Parallel)
**Note**: CLI tool usage is now determined semantically by action-planning-agent based on user's task description, not by flags.
### Phase 2: Planning Document Generation (Agent Responsibility)
### Phase 2A: Single Agent Planning (Original Flow)
**Condition**: `modules.length == 1` (no multi-module detected)
**Purpose**: Generate IMPL_PLAN.md, task JSONs, and TODO_LIST.md - planning documents only, NOT code implementation.
@@ -148,4 +199,93 @@ Hard Constraints:
)
```
### Phase 2B: N Parallel Planning (Multi-Module)
**Condition**: `modules.length >= 2` (multi-module detected)
**Purpose**: Launch N action-planning-agents simultaneously, one per module, for parallel task generation.
**Parallel Agent Invocation**:
```javascript
// Launch N agents in parallel (one per module)
const planningTasks = modules.map(module =>
Task(
subagent_type="action-planning-agent",
description=`Plan ${module.name} module`,
prompt=`
## SCOPE
- Module: ${module.name} (${module.type})
- Focus Paths: ${module.paths.join(', ')}
- Task ID Prefix: IMPL-${module.prefix}
- Task Limit: ≤9 tasks
- Other Modules: ${otherModules.join(', ')}
- Cross-module deps format: "CROSS::{module}::{pattern}"
## SESSION PATHS
Input:
- Context Package: .workflow/active/{session-id}/.process/context-package.json
Output:
- Task Dir: .workflow/active/{session-id}/.task/
## INSTRUCTIONS
- Generate tasks ONLY for ${module.name} module
- Use task ID format: IMPL-${module.prefix}1, IMPL-${module.prefix}2, ...
- For cross-module dependencies, use: depends_on: ["CROSS::B::api-endpoint"]
- Maximum 9 tasks per module
`
)
);
// Execute all in parallel
await Promise.all(planningTasks);
```
**Output Structure** (direct to .task/):
```
.task/
├── IMPL-A1.json # Module A (e.g., frontend)
├── IMPL-A2.json
├── IMPL-B1.json # Module B (e.g., backend)
├── IMPL-B2.json
└── IMPL-C1.json # Module C (e.g., shared)
```
**Task ID Naming**:
- Format: `IMPL-{prefix}{seq}.json`
- Prefix: A, B, C... (assigned by detection order)
- Sequence: 1, 2, 3... (per-module increment)
### Phase 3: Integration (+1 Coordinator, Multi-Module Only)
**Condition**: Only executed when `modules.length >= 2`
**Purpose**: Collect all module tasks, resolve cross-module dependencies, generate unified documents.
**Integration Logic**:
```javascript
// 1. Collect all module task JSONs
const allTasks = glob('.task/IMPL-*.json').map(loadJson);
// 2. Resolve cross-module dependencies
for (const task of allTasks) {
if (task.depends_on) {
task.depends_on = task.depends_on.map(dep => {
if (dep.startsWith('CROSS::')) {
// CROSS::B::api-endpoint → find matching IMPL-B* task
const [, targetModule, pattern] = dep.match(/CROSS::(\w+)::(.+)/);
return findTaskByModuleAndPattern(allTasks, targetModule, pattern);
}
return dep;
});
}
}
// 3. Generate unified IMPL_PLAN.md (grouped by module)
generateIMPL_PLAN(allTasks, modules);
// 4. Generate TODO_LIST.md (hierarchical structure)
generateTODO_LIST(allTasks, modules);
```
**Note**: IMPL_PLAN.md and TODO_LIST.md structure definitions are in `action-planning-agent.md`.

View File

@@ -1,664 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Dashboard - Task Board</title>
<style>
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #718096;
--border-color: #e2e8f0;
--accent-color: #4299e1;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-card: #2d3748;
--text-primary: #f7fafc;
--text-secondary: #a0aec0;
--border-color: #4a5568;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: background-color 0.3s, color 0.3s;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: var(--bg-secondary);
box-shadow: var(--shadow);
padding: 20px;
margin-bottom: 30px;
border-radius: 8px;
}
h1 {
font-size: 2rem;
margin-bottom: 10px;
color: var(--accent-color);
}
.header-controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-top: 15px;
}
.search-box {
flex: 1;
min-width: 250px;
position: relative;
}
.search-box input {
width: 100%;
padding: 10px 15px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 0.95rem;
}
.filter-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
background-color: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.btn.active {
background-color: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background-color: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--accent-color);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 5px;
}
.section {
margin-bottom: 40px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
}
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.session-card {
background-color: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 20px;
transition: all 0.3s;
}
.session-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.session-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.session-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 5px;
}
.session-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-active {
background-color: #c6f6d5;
color: #22543d;
}
.status-archived {
background-color: #e2e8f0;
color: #4a5568;
}
[data-theme="dark"] .status-active {
background-color: #22543d;
color: #c6f6d5;
}
[data-theme="dark"] .status-archived {
background-color: #4a5568;
color: #e2e8f0;
}
.session-meta {
display: flex;
gap: 15px;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 15px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
transition: width 0.3s;
}
.tasks-list {
margin-top: 15px;
}
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background-color: var(--bg-primary);
border-radius: 6px;
border-left: 3px solid var(--border-color);
transition: all 0.2s;
}
.task-item:hover {
transform: translateX(4px);
}
.task-item.completed {
border-left-color: var(--success-color);
opacity: 0.8;
}
.task-item.in_progress {
border-left-color: var(--warning-color);
}
.task-item.pending {
border-left-color: var(--text-secondary);
}
.task-checkbox {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--border-color);
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.task-item.completed .task-checkbox {
background-color: var(--success-color);
border-color: var(--success-color);
}
.task-item.completed .task-checkbox::after {
content: '✓';
color: white;
font-size: 0.8rem;
font-weight: bold;
}
.task-item.in_progress .task-checkbox {
border-color: var(--warning-color);
background-color: var(--warning-color);
}
.task-item.in_progress .task-checkbox::after {
content: '⟳';
color: white;
font-size: 0.9rem;
}
.task-title {
flex: 1;
font-size: 0.9rem;
}
.task-id {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
margin-left: 10px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.5;
}
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: var(--accent-color);
color: white;
border: none;
cursor: pointer;
font-size: 1.5rem;
box-shadow: var(--shadow-lg);
transition: all 0.3s;
z-index: 1000;
}
.theme-toggle:hover {
transform: scale(1.1);
}
@media (max-width: 768px) {
.sessions-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
h1 {
font-size: 1.5rem;
}
.header-controls {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
margin-left: 8px;
}
.badge-count {
background-color: var(--accent-color);
color: white;
}
.session-footer {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
font-size: 0.85rem;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🚀 Workflow Dashboard</h1>
<p style="color: var(--text-secondary);">Task Board - Active and Archived Sessions</p>
<div class="header-controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Search tasks or sessions..." />
</div>
<div class="filter-group">
<button class="btn active" data-filter="all">All</button>
<button class="btn" data-filter="active">Active</button>
<button class="btn" data-filter="archived">Archived</button>
</div>
</div>
</header>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalSessions">0</div>
<div class="stat-label">Total Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="activeSessions">0</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalTasks">0</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value" id="completedTasks">0</div>
<div class="stat-label">Completed Tasks</div>
</div>
</div>
<div class="section" id="activeSectionContainer">
<div class="section-header">
<h2 class="section-title">📋 Active Sessions</h2>
</div>
<div class="sessions-grid" id="activeSessions"></div>
</div>
<div class="section" id="archivedSectionContainer">
<div class="section-header">
<h2 class="section-title">📦 Archived Sessions</h2>
</div>
<div class="sessions-grid" id="archivedSessions"></div>
</div>
</div>
<button class="theme-toggle" id="themeToggle">🌙</button>
<script>
// Workflow data will be injected here
const workflowData = {{WORKFLOW_DATA}};
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
}
function updateThemeIcon(theme) {
document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
}
// Statistics calculation
function updateStatistics() {
const stats = {
totalSessions: workflowData.activeSessions.length + workflowData.archivedSessions.length,
activeSessions: workflowData.activeSessions.length,
totalTasks: 0,
completedTasks: 0
};
workflowData.activeSessions.forEach(session => {
stats.totalTasks += session.tasks.length;
stats.completedTasks += session.tasks.filter(t => t.status === 'completed').length;
});
workflowData.archivedSessions.forEach(session => {
stats.totalTasks += session.taskCount || 0;
stats.completedTasks += session.taskCount || 0;
});
document.getElementById('totalSessions').textContent = stats.totalSessions;
document.getElementById('activeSessions').textContent = stats.activeSessions;
document.getElementById('totalTasks').textContent = stats.totalTasks;
document.getElementById('completedTasks').textContent = stats.completedTasks;
}
// Render session card
function createSessionCard(session, isActive) {
const card = document.createElement('div');
card.className = 'session-card';
card.dataset.sessionType = isActive ? 'active' : 'archived';
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: (session.taskCount || 0);
const totalTasks = isActive ? session.tasks.length : (session.taskCount || 0);
const progress = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0;
let tasksHtml = '';
if (isActive && session.tasks.length > 0) {
tasksHtml = `
<div class="tasks-list">
${session.tasks.map(task => `
<div class="task-item ${task.status}">
<div class="task-checkbox"></div>
<div class="task-title">${task.title || 'Untitled Task'}</div>
<span class="task-id">${task.task_id || ''}</span>
</div>
`).join('')}
</div>
`;
}
card.innerHTML = `
<div class="session-header">
<div>
<h3 class="session-title">${session.session_id || 'Unknown Session'}</h3>
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 5px;">
${session.project || ''}
</div>
</div>
<span class="session-status ${isActive ? 'status-active' : 'status-archived'}">
${isActive ? 'Active' : 'Archived'}
</span>
</div>
<div class="session-meta">
<span>📅 ${session.created_at || session.archived_at || 'N/A'}</span>
<span>📊 ${completedTasks}/${totalTasks} tasks</span>
</div>
${totalTasks > 0 ? `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<div style="text-align: center; font-size: 0.85rem; color: var(--text-secondary);">
${Math.round(progress)}% Complete
</div>
` : ''}
${tasksHtml}
${!isActive && session.archive_path ? `
<div class="session-footer">
📁 Archive: ${session.archive_path}
</div>
` : ''}
`;
return card;
}
// Render all sessions
function renderSessions(filter = 'all') {
const activeContainer = document.getElementById('activeSessions');
const archivedContainer = document.getElementById('archivedSessions');
activeContainer.innerHTML = '';
archivedContainer.innerHTML = '';
if (filter === 'all' || filter === 'active') {
if (workflowData.activeSessions.length === 0) {
activeContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<p>No active sessions</p>
</div>
`;
} else {
workflowData.activeSessions.forEach(session => {
activeContainer.appendChild(createSessionCard(session, true));
});
}
}
if (filter === 'all' || filter === 'archived') {
if (workflowData.archivedSessions.length === 0) {
archivedContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📦</div>
<p>No archived sessions</p>
</div>
`;
} else {
workflowData.archivedSessions.forEach(session => {
archivedContainer.appendChild(createSessionCard(session, false));
});
}
}
// Show/hide sections
document.getElementById('activeSectionContainer').style.display =
(filter === 'all' || filter === 'active') ? 'block' : 'none';
document.getElementById('archivedSectionContainer').style.display =
(filter === 'all' || filter === 'archived') ? 'block' : 'none';
}
// Search functionality
function setupSearch() {
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const cards = document.querySelectorAll('.session-card');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(query) ? 'block' : 'none';
});
});
}
// Filter functionality
function setupFilters() {
const filterButtons = document.querySelectorAll('[data-filter]');
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderSessions(btn.dataset.filter);
});
});
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initTheme();
updateStatistics();
renderSessions();
setupSearch();
setupFilters();
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
});
</script>
</body>
</html>

View File

@@ -582,7 +582,7 @@ prompts/
- **Complex** (implementation, migration): 20-60min (1200000-3600000ms)
- **Heavy** (large codebase, multi-file): 60-120min (3600000-7200000ms)
**Codex Multiplier**: 1.5x of allocated time
**Codex Multiplier**: 3x of allocated time (minimum 15min / 900000ms)
**Application**: All bash() wrapped commands including Gemini, Qwen and Codex executions

View File

@@ -1,11 +0,0 @@
# MCP Tool Strategy: Exa Usage
## ⚡ Exa Triggering Mechanisms
**Auto-Trigger**:
- User mentions "exa-code" or code-related queries → `mcp__exa__get_code_context_exa`
- Need current web information → `mcp__exa__web_search_exa`
**Manual Trigger**:
- Complex API research → Exa Code Context
- Real-time information needs → Exa Web Search

View File

@@ -0,0 +1,71 @@
# Tool Strategy
## ⚡ Exa Triggering Mechanisms
**Auto-Trigger**:
- User mentions "exa-code" or code-related queries → `mcp__exa__get_code_context_exa`
- Need current web information → `mcp__exa__web_search_exa`
**Manual Trigger**:
- Complex API research → Exa Code Context
- Real-time information needs → Exa Web Search
## ⚡ Bash Text Processing (sed/awk)
**When to Use**: Edit tool fails 2+ times on same file
### sed Quick Reference
```bash
# Replace first occurrence per line
sed 's/old/new/' file.txt
# Replace all occurrences (global)
sed 's/old/new/g' file.txt
# In-place edit (modify file directly)
sed -i 's/old/new/g' file.txt
# Delete lines matching pattern
sed '/pattern/d' file.txt
# Insert line before match
sed '/pattern/i\new line' file.txt
# Insert line after match
sed '/pattern/a\new line' file.txt
# Replace on specific line number
sed '5s/old/new/' file.txt
# Multi-line replacement (escape newlines)
sed ':a;N;$!ba;s/old\npattern/new\ntext/g' file.txt
```
### awk Quick Reference
```bash
# Print specific column
awk '{print $1}' file.txt
# Print lines matching pattern
awk '/pattern/' file.txt
# Replace field value
awk '{$2="new"; print}' file.txt
# Conditional replacement
awk '/pattern/{gsub(/old/,"new")}1' file.txt
# Insert line after match
awk '/pattern/{print; print "new line"; next}1' file.txt
# Multi-field operations
awk -F',' '{print $1, $3}' file.csv
```
### Fallback Strategy
1. **Edit fails 2+ times** → Try sed for simple replacements
2. **sed fails** → Try awk for complex patterns
3. **awk fails** → Use Write to recreate file

View File

@@ -782,15 +782,11 @@ All workflows use the same file structure definition regardless of complexity. *
**Examples**:
*Analysis Commands (read-only):*
- `/cli:analyze "security"` (no session) → `.scratchpad/analyze-security-20250105-143022.md`
- `/cli:chat "build process"` (unrelated to active session) → `.scratchpad/chat-build-process-20250105-143045.md`
- `/cli:mode:plan "feature idea"` (exploratory) → `.scratchpad/plan-feature-idea-20250105-143110.md`
- `/cli:mode:code-analysis "trace auth flow"` (no session) → `.scratchpad/code-analysis-auth-flow-20250105-143130.md`
*Workflow Commands (lightweight):*
- `/workflow:lite-plan "feature idea"` (exploratory) → `.scratchpad/lite-plan-feature-idea-20250105-143110.md`
- `/workflow:lite-fix "bug description"` (bug fixing) → `.scratchpad/lite-fix-bug-20250105-143130.md`
*Implementation Commands (⚠️ modifies code):*
- `/cli:execute "implement JWT auth"` (no session) → `.scratchpad/execute-jwt-auth-20250105-143200.md`
- `/cli:codex-execute "refactor API layer"` (no session) → `.scratchpad/codex-execute-api-refactor-20250105-143230.md`
> **Note**: Direct CLI commands (`/cli:analyze`, `/cli:execute`, etc.) have been replaced by semantic invocation and workflow commands.
**Maintenance**:
- Periodically review and clean up old scratchpad files

51
.npmignore Normal file
View File

@@ -0,0 +1,51 @@
# Git
.git/
.gitignore
.gitattributes
# History and temp files
.history/
*.log
*.tmp
# Development files
.vscode/
.idea/
*.md
!README.md
!CLAUDE.md
# Workflow runtime data
.workflow/
# Test files
test/
tests/
*.test.js
*.spec.js
# Build artifacts
node_modules/
*.tgz
# OS files
.DS_Store
Thumbs.db
# Documentation (keep only essential)
CHANGELOG.md
CONTRIBUTING.md
COMMAND_REFERENCE.md
COMMAND_SPEC.md
FAQ.md
GETTING_STARTED.md
GETTING_STARTED_CN.md
# PowerShell installer (not needed for npm)
Install-Claude.ps1
install-remote.ps1
# ccw internal files
ccw/package.json
ccw/node_modules/
ccw/*.md

View File

@@ -5,7 +5,7 @@
This document defines project-specific coding standards and development principles.
### CLI Tool Context Protocols
For all CLI tool usage, command syntax, and integration guidelines:
- **MCP Tool Strategy**: @~/.claude/workflows/mcp-tool-strategy.md
- **Tool Strategy**: @~/.claude/workflows/tool-strategy.md
- **Intelligent Context Strategy**: @~/.claude/workflows/intelligent-tools-strategy.md
- **Context Search Commands**: @~/.claude/workflows/context-search-strategy.md
@@ -73,6 +73,7 @@ For all CLI tool usage, command syntax, and integration guidelines:
- Update plan documentation and progress tracking as you go
- Learn from existing implementations
- Stop after 3 failed attempts and reassess
- **Edit fallback**: When Edit tool fails 2+ times on same file, try Bash sed/awk first, then Write to recreate if still failing
## Platform-Specific Guidelines

View File

@@ -4,21 +4,13 @@ This document provides a comprehensive reference for all commands available in t
> **Version 5.9.6 Update**: Enhanced review cycle with dashboards, optimized lite-plan with parallel execution, and added lite-fix workflow for intelligent bug diagnosis.
## Unified CLI Commands (`/cli:*`)
These commands provide direct access to AI tools for quick analysis and interaction without initiating a full workflow.
## CLI Commands (`/cli:*`)
| Command | Description |
|---|---|
| `/cli:analyze` | Quick codebase analysis using CLI tools (codex/gemini/qwen). |
| `/cli:chat` | Simple CLI interaction command for direct codebase analysis. |
| `/cli:cli-init`| Initialize CLI tool configurations (Gemini and Qwen) based on workspace analysis. |
| `/cli:codex-execute` | Automated task decomposition and execution with Codex using resume mechanism. |
| `/cli:discuss-plan` | Orchestrates an iterative, multi-model discussion for planning and analysis without implementation. |
| `/cli:execute` | Auto-execution of implementation tasks with YOLO permissions and intelligent context inference. |
| `/cli:mode:bug-diagnosis` | Bug analysis and fix suggestions using CLI tools. |
| `/cli:mode:code-analysis` | Deep code analysis and debugging using CLI tools with specialized template. |
| `/cli:mode:plan` | Project planning and architecture analysis using CLI tools. |
> **Note**: For analysis, planning, and bug fixing, use workflow commands (`/workflow:lite-plan`, `/workflow:lite-fix`) or semantic invocation through natural language.
## Workflow Commands (`/workflow:*`)

View File

@@ -191,25 +191,7 @@ Commands for creating, listing, and managing workflow sessions.
## 5. CLI Commands
Direct access to AI tools for analysis and code interaction without a full workflow structure.
### **/cli:analyze**
- **Syntax**: `/cli:analyze [--agent] [--tool codex|gemini|qwen] [--enhance] <analysis target>`
- **Responsibilities**: Performs read-only codebase analysis. Can operate in standard mode (direct tool call) or agent mode (`@cli-execution-agent`) for automated context discovery.
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
- **Example**:
```bash
/cli:analyze "authentication patterns"
```
### **/cli:chat**
- **Syntax**: `/cli:chat [--agent] [--tool codex|gemini|qwen] [--enhance] <inquiry>`
- **Responsibilities**: Provides a direct Q&A interface with AI tools for codebase questions. Read-only.
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
- **Example**:
```bash
/cli:chat "how does the caching layer work?"
```
CLI tool configuration commands.
### **/cli:cli-init**
- **Syntax**: `/cli:cli-init [--tool gemini|qwen|all] [--output path] [--preview]`
@@ -220,59 +202,7 @@ Direct access to AI tools for analysis and code interaction without a full workf
/cli:cli-init
```
### **/cli:codex-execute**
- **Syntax**: `/cli:codex-execute [--verify-git] <description|task-id>`
- **Responsibilities**: Orchestrates automated task decomposition and sequential execution using Codex. It uses the `resume --last` mechanism for context continuity between subtasks.
- **Agent Calls**: None directly, but orchestrates `codex` CLI tool.
- **Example**:
```bash
/cli:codex-execute "implement user authentication system"
```
### **/cli:discuss-plan**
- **Syntax**: `/cli:discuss-plan [--topic '...'] [--task-id '...'] [--rounds N] <input>`
- **Responsibilities**: Orchestrates an iterative, multi-model (Gemini, Codex, Claude) discussion to perform deep analysis and planning without modifying code.
- **Agent Calls**: None directly, but orchestrates `gemini` and `codex` CLI tools.
- **Example**:
```bash
/cli:discuss-plan --topic "Design a new caching layer"
```
### **/cli:execute**
- **Syntax**: `/cli:execute [--agent] [--tool codex|gemini|qwen] [--enhance] <description|task-id>`
- **Responsibilities**: Executes implementation tasks with auto-approval (`YOLO` mode). **MODIFIES CODE**.
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
- **Example**:
```bash
/cli:execute "implement JWT authentication with middleware"
```
### **/cli:mode:bug-diagnosis**
- **Syntax**: `/cli:mode:bug-diagnosis [--tool ...] [--enhance] [--cd path] <bug description>`
- **Responsibilities**: Performs systematic bug analysis using the `bug-fix.md` template. Read-only.
- **Agent Calls**: `@cli-execution-agent` (default).
- **Example**:
```bash
/cli:mode:bug-diagnosis "null pointer error in login flow"
```
### **/cli:mode:code-analysis**
- **Syntax**: `/cli:mode:code-analysis [--agent] [--tool ...] [--enhance] [--cd path] <analysis target>`
- **Responsibilities**: Performs deep code analysis and execution path tracing using the `code-analysis.md` template. Read-only.
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
- **Example**:
```bash
/cli:mode:code-analysis "trace authentication execution flow"
```
### **/cli:mode:plan**
- **Syntax**: `/cli:mode:plan [--agent] [--tool ...] [--enhance] [--cd path] <topic>`
- **Responsibilities**: Performs comprehensive planning and architecture analysis using the `plan.md` template. Read-only.
- **Agent Calls**: `@cli-execution-agent` (if `--agent` is used).
- **Example**:
```bash
/cli:mode:plan "design user dashboard architecture"
```
> **Note**: For analysis, planning, and bug fixing, use workflow commands (`/workflow:lite-plan`, `/workflow:lite-fix`) or semantic invocation through natural language. Claude will automatically use appropriate CLI tools (Gemini/Qwen/Codex) with templates as needed.
---
@@ -380,13 +310,14 @@ Commands for managing individual tasks within a workflow session.
```
### **/enhance-prompt**
- **Syntax**: `/enhance-prompt <user input>`
- **Responsibilities**: A system-level skill that enhances a user's prompt by adding context from session memory and codebase analysis. It is typically triggered automatically by other commands that include the `--enhance` flag.
- **Skill Invocation**: This is a core skill, invoked when `--enhance` is used.
- **Syntax**: `/enhance-prompt <user input>` or use `-e` flag in conversation
- **Responsibilities**: A system-level skill that enhances a user's prompt by adding context from session memory and codebase analysis. It is typically triggered by the `-e` flag in natural conversation.
- **Skill Invocation**: This is a core skill, invoked when `-e` is used in conversation.
- **Agent Calls**: None.
- **Example (as part of another command)**:
```bash
/cli:execute --enhance "fix the login button"
- **Example (in natural conversation)**:
```
User: "fix the login button -e"
→ Prompt-enhancer expands and enhances the request
```
---

24
FAQ.md
View File

@@ -248,16 +248,16 @@ CCW-help
### What's the difference between `/cli:*` and `/workflow:*` commands?
**`/cli:*` commands**:
- Direct access to external AI tools
- No workflow session required
- Quick one-off tasks
- Examples: `/cli:analyze`, `/cli:chat`
- CLI tool configuration
- Example: `/cli:cli-init` (initialize Gemini/Qwen configurations)
**`/workflow:*` commands**:
- Multi-phase orchestration
- Session-based
- Complex development workflows
- Examples: `/workflow:plan`, `/workflow:execute`
- Examples: `/workflow:plan`, `/workflow:lite-plan`, `/workflow:lite-fix`
> **Note**: Most CLI commands have been replaced by **semantic invocation**. Simply describe your needs in natural language, and Claude will automatically use the appropriate tools.
### How do I use command flags?
@@ -270,9 +270,6 @@ Most commands support flags for customization:
# With CLI execution flag
/workflow:plan --cli-execute "Feature description"
# With tool selection
/cli:analyze --tool gemini "Code analysis"
# With multiple flags
/workflow:ui-design:explore-auto --prompt "Login page" --style-variants 3 --layout-variants 2
```
@@ -281,17 +278,12 @@ Most commands support flags for customization:
**Yes!** Claude understands semantic invocation:
Instead of:
```bash
/cli:analyze --tool gemini "Authentication module"
```
You can say:
Instead of using specific commands, you can say:
```
"Use Gemini to analyze the authentication module architecture"
```
Claude will automatically execute the appropriate command.
Claude will automatically select and execute the appropriate CLI tools (Gemini/Qwen/Codex) with optimized templates.
### What does the `-e` or `--enhance` flag do?
@@ -303,8 +295,6 @@ User: "Analyze authentication module -e"
Claude will expand and enhance your request for better results.
**Note**: `--enhance` in CLI commands (e.g., `/cli:analyze --enhance`) is a different feature built into CLI tools.
---
## 📦 Sessions & Tasks

View File

@@ -178,62 +178,36 @@ After planning, validate your implementation plan for consistency and completene
Quick bug analysis and fix workflow:
```bash
# Analyze the bug
/cli:mode:bug-diagnosis "Incorrect success message with wrong password"
# Lightweight bug fix workflow with intelligent diagnosis
/workflow:lite-fix "Incorrect success message with wrong password"
# Claude will analyze and then directly implement the fix based on the analysis
# Claude will analyze severity, diagnose root cause, and implement the fix
```
---
## 🔧 Workflow-Free Usage: Standalone Tools
## 🔧 Lightweight Commands
Beyond the full workflow mode, CCW provides standalone CLI tools and commands suitable for quick analysis, ad-hoc queries, and routine maintenance tasks.
Beyond the full workflow mode, CCW provides lightweight commands suitable for quick analysis and routine tasks.
### Direct CLI Tool Invocation
### Workflow Commands for Quick Tasks
CCW supports direct invocation of external AI tools (Gemini, Qwen, Codex) through a unified CLI interface without creating workflow sessions.
#### Code Analysis
Quickly analyze project code structure and architectural patterns:
Use workflow commands for integrated planning and bug fixing:
```bash
# Code analysis with Gemini
/cli:analyze --tool gemini "Analyze authentication module architecture"
# Lightweight planning workflow
/workflow:lite-plan "Design a scalable microservices architecture"
# Code quality analysis with Qwen
/cli:analyze --tool qwen "Review database model design for best practices"
# Bug fix workflow with intelligent diagnosis
/workflow:lite-fix "Analyze potential causes of memory leak"
# Initialize CLI tool configurations
/cli:cli-init
```
#### Interactive Chat
### Semantic Tool Invocation (Replaces Direct CLI Commands)
Direct interactive dialogue with AI tools:
```bash
# Chat with Gemini
/cli:chat --tool gemini "Explain React Hook use cases"
# Discuss implementation with Codex
/cli:chat --tool codex "How to optimize this query performance"
```
#### Specialized Analysis Modes
Use specific analysis modes for in-depth exploration:
```bash
# Architecture planning mode
/cli:mode:plan --tool gemini "Design a scalable microservices architecture"
# Deep code analysis
/cli:mode:code-analysis --tool qwen "Analyze utility functions in src/utils/"
# Bug analysis mode
/cli:mode:bug-index --tool gemini "Analyze potential causes of memory leak"
```
### Semantic Tool Invocation
> **Important**: Direct CLI commands (`/cli:analyze`, `/cli:chat`, `/cli:execute`, etc.) have been replaced by **semantic invocation**. Simply describe your needs in natural language, and Claude will automatically select and execute the appropriate CLI tools (Gemini/Qwen/Codex) with optimized templates.
Users can tell Claude to use specific tools through natural language, and Claude will understand the intent and automatically execute the appropriate commands.
@@ -381,13 +355,7 @@ User: "Analyze authentication module -e"
→ AI uses prompt-enhancer skill to expand the request
```
**CLI Command Enhancement** (built-in CLI feature):
```bash
# The --enhance flag here is a CLI parameter, not a skill trigger
/cli:analyze --enhance "check for security issues"
```
**Important Note**: The `-e` flag works in natural conversation, but `--enhance` in CLI commands is a separate enhancement mechanism, not the skill system.
**Important Note**: The `-e` flag works in natural conversation to trigger the prompt-enhancer skill.
---

View File

@@ -188,62 +188,36 @@
快速 Bug 分析和修复工作流:
```bash
# 分析 Bug
/cli:mode:bug-diagnosis "密码错误时仍显示成功消息"
# 轻量级 Bug 修复工作流,带智能诊断
/workflow:lite-fix "密码错误时仍显示成功消息"
# Claude 会分析后直接根据分析结果实现修复
# Claude 会分析严重程度,诊断根因,并实现修复
```
---
## 🔧 无工作流协作:独立工具使用
## 🔧 轻量级命令
除了完整的工作流模式CCW 还提供独立的 CLI 工具和命令,适合快速分析、临时查询和日常维护任务。
除了完整的工作流模式CCW 还提供轻量级命令,适合快速分析和日常任务。
### CLI 工具直接调用
### 快速任务工作流命令
CCW 支持通过统一的 CLI 接口直接调用外部 AI 工具Gemini、Qwen、Codex无需创建工作流会话。
#### 代码分析
快速分析项目代码结构和架构模式:
使用工作流命令进行集成规划和 Bug 修复:
```bash
# 使用 Gemini 进行代码分析
/cli:analyze --tool gemini "分析认证模块的架构设计"
# 轻量级规划工作流
/workflow:lite-plan "设计一个可扩展的微服务架构"
# 使用 Qwen 分析代码质量
/cli:analyze --tool qwen "检查数据库模型的设计是否合理"
# 带智能诊断的 Bug 修复工作流
/workflow:lite-fix "分析内存泄漏问题的可能原因"
# 初始化 CLI 工具配置
/cli:cli-init
```
#### 交互式对话
### 语义调用(替代直接 CLI 命令)
与 AI 工具进行直接交互式对话:
```bash
# 与 Gemini 交互
/cli:chat --tool gemini "解释一下 React Hook 的使用场景"
# 与 Codex 交互讨论实现方案
/cli:chat --tool codex "如何优化这个查询性能"
```
#### 专业模式分析
使用特定的分析模式进行深度探索:
```bash
# 架构分析模式
/cli:mode:plan --tool gemini "设计一个可扩展的微服务架构"
# 深度代码分析
/cli:mode:code-analysis --tool qwen "分析 src/utils/ 目录下的工具函数"
# Bug 分析模式
/cli:mode:bug-diagnosis --tool gemini "分析内存泄漏问题的可能原因"
```
### 工具语义调用
> **重要**: 直接 CLI 命令(`/cli:analyze`、`/cli:chat`、`/cli:execute` 等)已被**语义调用**替代。只需使用自然语言描述您的需求Claude 会自动选择并执行合适的 CLI 工具Gemini/Qwen/Codex和优化的模板。
用户可以通过自然语言告诉 Claude 使用特定工具完成任务Claude 会理解意图并自动执行相应的命令。
@@ -391,13 +365,7 @@ CCW 使用分层的 CLAUDE.md 文档系统维护项目上下文。定期更新
→ AI 使用 prompt-enhancer 技能扩展请求
```
**CLI 命令增强** (CLI 内置功能):
```bash
# 这里的 --enhance 标识符是 CLI 参数,不是技能触发器
/cli:analyze --enhance "检查安全问题"
```
**重要说明**`-e` 标识符仅在自然对话中有效,而 CLI 命令中的 `--enhance` 是独立的增强机制,与技能系统无关。
**重要说明**`-e` 标识符用于在自然对话中触发 prompt-enhancer 技能。
---

View File

@@ -2,7 +2,8 @@
<div align="center">
[![Version](https://img.shields.io/badge/version-v5.9.6-blue.svg)](https://github.com/catlog22/Claude-Code-Workflow/releases)
[![Version](https://img.shields.io/badge/version-v6.0.0-blue.svg)](https://github.com/catlog22/Claude-Code-Workflow/releases)
[![npm](https://img.shields.io/npm/v/claude-code-workflow.svg)](https://www.npmjs.com/package/claude-code-workflow)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]()
@@ -12,16 +13,15 @@
---
**Claude Code Workflow (CCW)** transforms AI development from simple prompt chaining into a powerful, context-first orchestration system. It solves execution uncertainty and error accumulation through structured planning, deterministic execution, and intelligent multi-model orchestration.
**Claude Code Workflow (CCW)** is a JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution. It transforms AI development from simple prompt chaining into a powerful orchestration system.
> **🎉 Version 5.9.6: Review Cycle Enhancement & Dashboard Automation**
> **🎉 Version 6.0.0: npm Package & Simplified Installation**
>
> **Core Improvements**:
> - **Enhanced Review Dashboard**: The `review-cycle` dashboard now supports real-time progress tracking and advanced filtering for better visibility into code reviews.
> - 🎯 **New Fix-Tracking Dashboard**: Introduced a new, independent `fix-dashboard.html` to monitor the progress of bug fixes with rich data integration.
> - 🚀 **`lite-fix` Workflow**: Added a new `lite-fix` command for intelligent, streamlined bug diagnosis and resolution.
> - 🛠️ **`lite-plan` Optimization**: Significantly optimized the `lite-plan` workflow with cost-aware parallel execution, better complexity analysis, and robust context protection.
> - 🧠 **Intelligent Test Cycles**: Improved the `test-cycle-execute` command with smart iteration strategies and a universal `@test-fix-agent` for more effective testing.
> - 📦 **npm Package**: Now available as `claude-code-workflow` on npm for simplified global installation
> - 🖥️ **CCW CLI Tool**: New `ccw` command with dashboard viewer, installation management, and workflow visualization
> - 🎯 **Simplified Install Flow**: Unified installation via npm with local-only operation (no GitHub API dependency)
> - **Enhanced Dashboard**: MCP manager, review session improvements, and UI enhancements
>
> See [CHANGELOG.md](CHANGELOG.md) for complete details.
@@ -44,9 +44,26 @@ CCW is built on a set of core principles that distinguish it from traditional AI
## ⚙️ Installation
For detailed installation instructions, refer to the [**INSTALL.md**](INSTALL.md) guide.
### **📦 npm Install (Recommended)**
### **🚀 One-Click Quick Install**
Install globally via npm:
```bash
npm install -g claude-code-workflow
```
Then install workflow files to your system:
```bash
# Interactive installation
ccw install
# Global installation (to ~/.claude)
ccw install -m Global
# Project-specific installation
ccw install -m Path -p /path/to/project
```
### **🚀 Alternative: One-Click Script Install**
**Windows (PowerShell):**
```powershell
@@ -67,6 +84,55 @@ If slash commands (e.g., `/workflow:*`) are recognized, the installation was suc
---
## 🖥️ CCW CLI Tool
The `ccw` command provides a powerful CLI for managing your Claude Code Workflow installation:
### **Commands**
| Command | Description |
|---------|-------------|
| `ccw install` | Install workflow files to Global (~/.claude) or specific Path |
| `ccw upgrade` | Upgrade existing installations to current package version |
| `ccw uninstall` | Remove workflow files from an installation |
| `ccw view` | Open the workflow dashboard in browser |
| `ccw serve` | Start dashboard server without opening browser |
| `ccw list` | List all managed installations |
### **Usage Examples**
```bash
# Install globally
ccw install -m Global
# Install to specific project
ccw install -m Path -p ./my-project
# Open dashboard
ccw view
# Start dashboard server on custom port
ccw serve -p 8080
# Upgrade all installations
ccw upgrade -a
# List installations
ccw list
```
### **Dashboard Features**
The CCW Dashboard (`ccw view`) provides:
- 📊 **Session Overview**: View all workflow sessions with status and progress
- 📋 **Task Management**: Track task execution and completion
- 🔍 **Review Sessions**: Manage code review cycles
- ⚙️ **MCP Manager**: Configure and monitor MCP servers
- 🪝 **Hook Manager**: Manage Claude Code hooks
- 📁 **Project Explorer**: Navigate project structure and artifacts
---
## 🛠️ Command Reference
CCW provides a rich set of commands for managing workflows, tasks, and interactions with AI tools. For a complete list and detailed descriptions of all available commands, please refer to the [**COMMAND_REFERENCE.md**](COMMAND_REFERENCE.md) file.

View File

@@ -16,7 +16,7 @@ flowchart TD
BugFix --> BugSeverity{了解问题根因?}
BugSeverity -->|清楚| LiteFix[/ /workflow:lite-fix<br>标准Bug修复 /]
BugSeverity -->|生产事故| HotFix[/ /workflow:lite-fix --hotfix<br>热修复模式 /]
BugSeverity -->|不清楚| BugDiag[/ /cli:mode:bug-diagnosis<br>诊断根因 /]
BugSeverity -->|不清楚| BugDiag[/ /workflow:lite-fix<br>自动诊断根因 /]
BugDiag --> LiteFix
LiteFix --> BugComplete[Bug修复完成]
@@ -142,7 +142,7 @@ flowchart TD
|------|------|------|
| 🐛 **标准Bug修复** | `/workflow:lite-fix "bug描述"` | 自适应严重性评估,完整诊断→影响评估→修复→验证 |
| 🔥 **生产热修复** | `/workflow:lite-fix --hotfix "bug描述"` | 最小化诊断,快速修复,自动生成跟进任务 |
| ❓ **根因不清楚** | `/cli:mode:bug-diagnosis``/workflow:lite-fix` | 深度诊断,再执行修复 |
| ❓ **根因不清楚** | `/workflow:lite-fix` | 自动进行深度诊断执行修复 |
| ✅ **功能开发** | 继续后续流程 | 不是Bug修复按正常开发流程 |
**Lite-Fix 工作流特性**:
@@ -171,16 +171,16 @@ flowchart TD
→ 最小化诊断 → 假设 Critical → 手术式修复 → 烟雾测试
→ 自动生成: 全面修复任务3天内+ 事后分析1周内
# 根因不清楚(诊断)
/cli:mode:bug-diagnosis --tool gemini "购物车随机丢失商品"
→ 深度诊断报告/workflow:lite-fix "修复购物车状态同步问题"
# 根因不清楚(lite-fix 自动诊断)
/workflow:lite-fix "购物车随机丢失商品"
自动深度诊断 → 识别根因 → 实现修复
```
**何时使用 lite-fix**:
- ✅ 任何有明确症状的Bug自动适应严重性
- ✅ 本地化修复1-5个文件
- ✅ 生产事故(使用 `--hotfix` 模式)
- 根因完全不明 → 先用 `/cli:mode:bug-diagnosis`
- 根因不清楚(自动进行深度诊断)
- ❌ 需要架构变更 → 用 `/workflow:plan --mode bugfix`
---
@@ -416,33 +416,24 @@ Phase 1: Gemini 分析 ──┐
→ Claude Code 自动生成codex -C src/auth --full-auto exec "实现注册"
```
**方式二:直接命令调用**
```bash
# 通过 Slash 命令精准调用
/cli:chat --tool gemini "解释这个算法"
/cli:analyze --tool qwen "分析性能瓶颈"
/cli:execute --tool codex "优化查询性能"
```
---
#### 🔗 CLI 结果作为上下文Memory
#### 🔗 语义调用与结果上下文Memory
CLI 工具的分析结果可以被保存并作为后续操作的上下文memory实现智能化的工作流程
通过自然语言描述Claude 会自动选择并执行适当的 CLI 工具Gemini/Qwen/Codex分析结果作为后续操作的上下文。
**1. 结果持久化**
**1. 语义调用示例**
```bash
# CLI 执行结果自动保存到会话目录
/cli:chat --tool gemini "分析认证模块架构"
保存到:.workflow/active/WFS-xxx/.chat/chat-[timestamp].md
# 用自然语言描述需求Claude 自动选择工具
"使用 gemini 分析认证模块架构"
Claude 自动执行 Gemini CLI 并保存结果
/cli:analyze --tool qwen "评估性能瓶颈"
保存到:.workflow/active/WFS-xxx/.chat/analyze-[timestamp].md
"让 qwen 评估性能瓶颈"
Claude 自动执行 Qwen CLI 并保存结果
/cli:execute --tool codex "实现功能"
保存到:.workflow/active/WFS-xxx/.chat/execute-[timestamp].md
"用 codex 实现这个功能"
Claude 自动执行 Codex CLI
```
**2. 结果作为规划依据**
@@ -476,8 +467,8 @@ CLI 工具的分析结果可以被保存并作为后续操作的上下文memo
```bash
# 引用历史会话的分析结果
/cli:execute --tool codex "参考 WFS-2024-001 中的架构分析,实现新的支付模块"
系统自动加载指定会话的上下文
"参考 WFS-2024-001 中的架构分析,用 codex 实现新的支付模块"
Claude 自动加载指定会话的上下文
→ 基于历史分析进行实现
```

View File

@@ -348,33 +348,24 @@ Phase 1: Gemini analysis ──┐
→ Claude Code auto-generates: codex -C src/auth --full-auto exec "Implement registration"
```
**Method 2: Direct Command Invocation**
```bash
# Precise invocation via Slash commands
/cli:chat --tool gemini "Explain this algorithm"
/cli:analyze --tool qwen "Analyze performance bottlenecks"
/cli:execute --tool codex "Optimize query performance"
```
---
#### 🔗 CLI Results as Context (Memory)
#### 🔗 Semantic Invocation & Results Context (Memory)
CLI tool analysis results can be saved and used as context (memory) for subsequent operations, enabling intelligent workflows:
Through natural language, Claude automatically selects and executes appropriate CLI tools (Gemini/Qwen/Codex), with results serving as context for subsequent operations.
**1. Result Persistence**
**1. Semantic Invocation Examples**
```bash
# CLI execution results automatically saved to session directory
/cli:chat --tool gemini "Analyze authentication module architecture"
Saved to: .workflow/active/WFS-xxx/.chat/chat-[timestamp].md
# Describe needs in natural language, Claude auto-selects tools
"Use gemini to analyze authentication module architecture"
Claude auto-executes Gemini CLI and saves results
/cli:analyze --tool qwen "Evaluate performance bottlenecks"
Saved to: .workflow/active/WFS-xxx/.chat/analyze-[timestamp].md
"Have qwen evaluate performance bottlenecks"
Claude auto-executes Qwen CLI and saves results
/cli:execute --tool codex "Implement feature"
Saved to: .workflow/active/WFS-xxx/.chat/execute-[timestamp].md
"Use codex to implement this feature"
Claude auto-executes Codex CLI
```
**2. Results as Planning Basis**
@@ -408,8 +399,8 @@ Have codex synthesize above Gemini and Qwen analyses to implement optimal soluti
```bash
# Reference historical session analysis results
/cli:execute --tool codex "Refer to architecture analysis in WFS-2024-001, implement new payment module"
System automatically loads specified session context
"Refer to architecture analysis in WFS-2024-001, use codex to implement new payment module"
Claude automatically loads specified session context
→ Implement based on historical analysis
```
@@ -630,16 +621,13 @@ Use gemini to review code quality
### Scenario D: Bug Fixing
```bash
# 1. Diagnosis
/cli:mode:bug-diagnosis --tool gemini "User login fails with token expired error"
# 1. Intelligent bug fix workflow (includes diagnosis)
/workflow:lite-fix "User login fails with token expired error"
# 2. Quick fix
/workflow:lite-plan "Fix JWT token expiration validation logic"
# 3. Test fix
# 2. Test fix (if needed)
/workflow:test-cycle-execute
# 4. Complete
# 3. Complete
```
---
@@ -654,7 +642,7 @@ Use gemini to review code quality
| ❓ Know what, don't know how | `/workflow:brainstorm:auto-parallel "Design technical solution"` |
| ✅ Know what and how | `/workflow:plan "Specific implementation description"` |
| ⚡ Simple, clear small task | `/workflow:lite-plan "Task description"` |
| 🐛 Bug fixing | `/cli:mode:bug-diagnosis` + `/workflow:lite-plan` |
| 🐛 Bug fixing | `/workflow:lite-fix "bug description"` |
### Choose by Project Phase

121
ccw/README.md Normal file
View File

@@ -0,0 +1,121 @@
# CCW - Claude Code Workflow CLI
A command-line tool for viewing workflow sessions and code review results from the Claude Code Workflow system.
## Installation
```bash
# Install globally
npm install -g ccw
# Or install from local source
cd path/to/ccw
npm install
npm link
```
## Usage
### View Dashboard
```bash
# Open workflow dashboard in browser
ccw view
# Specify project path
ccw view -p /path/to/project
# Generate dashboard without opening browser
ccw view --no-browser
# Custom output path
ccw view -o report.html
```
## Features
### Workflow Dashboard
- **Active Sessions**: View all active workflow sessions with task progress
- **Archived Sessions**: Browse completed/archived sessions
- **Task Tracking**: See individual task status (pending/in_progress/completed)
- **Progress Bars**: Visual progress indicators for each session
### Review Integration
- **Code Review Findings**: View results from `review-module-cycle`
- **Severity Distribution**: Critical/High/Medium/Low finding counts
- **Dimension Analysis**: Findings by review dimension (Security, Architecture, Quality, etc.)
- **Tabbed Interface**: Switch between Workflow and Reviews tabs
## Dashboard Data Sources
The CLI reads data from the `.workflow/` directory structure:
```
.workflow/
├── active/
│ └── WFS-{session-id}/
│ ├── workflow-session.json # Session metadata
│ ├── .task/
│ │ └── IMPL-*.json # Task definitions
│ └── .review/
│ ├── review-progress.json # Review progress
│ └── dimensions/
│ └── *.json # Dimension findings
└── archives/
└── WFS-{session-id}/ # Archived sessions
```
## Bundled Templates
The CLI includes bundled dashboard templates:
- `workflow-dashboard.html` - Workflow session and task visualization
- `review-cycle-dashboard.html` - Code review findings display
No external template installation required - templates are included in the npm package.
## Requirements
- Node.js >= 16.0.0
- npm or yarn
## Integration with Claude Code Workflow
This CLI is a standalone tool that works with the Claude Code Workflow system:
1. **Install CCW CLI** (via npm)
- `npm install -g ccw`
- Provides `ccw view` command for dashboard viewing
- Templates are bundled - no additional installation required
2. **Optional: Install Claude Code Workflow** (via `Install-Claude.ps1`)
- Provides workflow commands, agents, and automation
- CCW will automatically detect and display workflow sessions
## Options
| Option | Description |
|--------|-------------|
| `-p, --path <path>` | Path to project directory (default: current directory) |
| `--no-browser` | Generate dashboard without opening browser |
| `-o, --output <file>` | Custom output path for HTML file |
| `-V, --version` | Display version number |
| `-h, --help` | Display help information |
## Development
```bash
# Clone and install dependencies
git clone <repo-url>
cd ccw
npm install
# Link for local testing
npm link
# Test the CLI
ccw view -p /path/to/test/project
```
## License
MIT

10
ccw/bin/ccw.js Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* CCW CLI - Claude Code Workflow Dashboard
* Entry point for global CLI installation
*/
import { run } from '../src/cli.js';
run(process.argv);

1914
ccw/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
ccw/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "ccw",
"version": "1.0.0",
"description": "Claude Code Workflow CLI - Dashboard viewer for workflow sessions and reviews",
"type": "module",
"main": "src/index.js",
"bin": {
"ccw": "./bin/ccw.js"
},
"scripts": {
"test": "node --test",
"lint": "eslint src/"
},
"keywords": [
"claude",
"workflow",
"cli",
"dashboard",
"code-review"
],
"author": "Claude Code Workflow",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"commander": "^11.0.0",
"open": "^9.1.0",
"chalk": "^5.3.0",
"glob": "^10.3.0",
"inquirer": "^9.2.0",
"ora": "^7.0.0",
"figlet": "^1.7.0",
"boxen": "^7.1.0",
"gradient-string": "^2.0.2"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://github.com/claude-code-workflow/ccw"
}
}

100
ccw/src/cli.js Normal file
View File

@@ -0,0 +1,100 @@
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 { upgradeCommand } from './commands/upgrade.js';
import { listCommand } from './commands/list.js';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Load package.json with error handling
* @returns {Object} - Package info with version
*/
function loadPackageInfo() {
const pkgPath = join(__dirname, '../package.json');
try {
if (!existsSync(pkgPath)) {
console.error('Fatal Error: package.json not found.');
console.error(`Expected location: ${pkgPath}`);
process.exit(1);
}
const content = readFileSync(pkgPath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error instanceof SyntaxError) {
console.error('Fatal Error: package.json contains invalid JSON.');
console.error(`Parse error: ${error.message}`);
} else {
console.error('Fatal Error: Could not read package.json.');
console.error(`Error: ${error.message}`);
}
process.exit(1);
}
}
const pkg = loadPackageInfo();
export function run(argv) {
const program = new Command();
program
.name('ccw')
.description('Claude Code Workflow CLI - Dashboard and workflow tools')
.version(pkg.version);
// View command (server mode with live path switching)
program
.command('view')
.description('Open workflow dashboard server with live path switching')
.option('-p, --path <path>', 'Path to project directory', '.')
.option('--port <port>', 'Server port', '3456')
.option('--no-browser', 'Start server without opening browser')
.action(viewCommand);
// Serve command (alias for view)
program
.command('serve')
.description('Alias for view command')
.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')
.description('Install Claude Code Workflow to your system')
.option('-m, --mode <mode>', 'Installation mode: Global or Path')
.option('-p, --path <path>', 'Installation path (for Path mode)')
.option('-f, --force', 'Force installation without prompts')
.action(installCommand);
// Uninstall command
program
.command('uninstall')
.description('Uninstall Claude Code Workflow')
.action(uninstallCommand);
// Upgrade command
program
.command('upgrade')
.description('Upgrade Claude Code Workflow installations')
.option('-a, --all', 'Upgrade all installations without prompting')
.action(upgradeCommand);
// List command
program
.command('list')
.description('List all installed Claude Code Workflow instances')
.action(listCommand);
program.parse(argv);
}

324
ccw/src/commands/install.js Normal file
View File

@@ -0,0 +1,324 @@
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
import { join, dirname, basename } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showHeader, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { createManifest, addFileEntry, addDirectoryEntry, saveManifest, findManifest, getAllManifests } from '../core/manifest.js';
import { validatePath } from '../utils/path-resolver.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directories to install
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot() {
return join(__dirname, '..', '..');
}
// Get source installation directory (parent of ccw)
function getSourceDir() {
return join(getPackageRoot(), '..');
}
/**
* Install command handler
* @param {Object} options - Command options
*/
export async function installCommand(options) {
const version = getVersion();
// Show beautiful header
showHeader(version);
// Check for existing installations
const existingManifests = getAllManifests();
if (existingManifests.length > 0 && !options.force) {
info('Existing installations detected:');
console.log('');
existingManifests.forEach((m, i) => {
console.log(chalk.gray(` ${i + 1}. ${m.installation_mode} - ${m.installation_path}`));
console.log(chalk.gray(` Installed: ${new Date(m.installation_date).toLocaleDateString()}`));
});
console.log('');
const { proceed } = await inquirer.prompt([{
type: 'confirm',
name: 'proceed',
message: 'Continue with new installation?',
default: true
}]);
if (!proceed) {
info('Installation cancelled');
return;
}
}
// Local installation from package source
const sourceDir = getSourceDir();
// Interactive mode selection
const mode = options.mode || await selectMode();
let installPath;
if (mode === 'Global') {
installPath = homedir();
info(`Global installation to: ${installPath}`);
} else {
const inputPath = options.path || await selectPath();
// Validate the installation path
const pathValidation = validatePath(inputPath, { mustExist: true });
if (!pathValidation.valid) {
error(`Invalid installation path: ${pathValidation.error}`);
process.exit(1);
}
installPath = pathValidation.path;
info(`Path installation to: ${installPath}`);
}
// Validate source directories exist
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
if (availableDirs.length === 0) {
error('No source directories found to install.');
error(`Expected directories in: ${sourceDir}`);
process.exit(1);
}
console.log('');
info(`Found ${availableDirs.length} directories to install: ${availableDirs.join(', ')}`);
divider();
// Check for existing installation at target path
const existingManifest = findManifest(installPath, mode);
if (existingManifest) {
warning('Existing installation found at this location');
const { backup } = await inquirer.prompt([{
type: 'confirm',
name: 'backup',
message: 'Create backup before reinstalling?',
default: true
}]);
if (backup) {
await createBackup(installPath, existingManifest);
}
}
// Create manifest
const manifest = createManifest(mode, installPath);
// Perform installation
console.log('');
const spinner = createSpinner('Installing files...').start();
let totalFiles = 0;
let totalDirs = 0;
try {
for (const dir of availableDirs) {
const srcPath = join(sourceDir, dir);
const destPath = join(installPath, dir);
spinner.text = `Installing ${dir}...`;
const { files, directories } = await copyDirectory(srcPath, destPath, manifest);
totalFiles += files;
totalDirs += directories;
}
// Copy CLAUDE.md to .claude directory
const claudeMdSrc = join(sourceDir, 'CLAUDE.md');
const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md');
if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) {
spinner.text = 'Installing CLAUDE.md...';
copyFileSync(claudeMdSrc, claudeMdDest);
addFileEntry(manifest, claudeMdDest);
totalFiles++;
}
// Create version.json
const versionPath = join(installPath, '.claude', 'version.json');
if (existsSync(dirname(versionPath))) {
const versionData = {
version: version,
installedAt: new Date().toISOString(),
mode: mode,
installer: 'ccw'
};
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
addFileEntry(manifest, versionPath);
totalFiles++;
}
spinner.succeed('Installation complete!');
} catch (err) {
spinner.fail('Installation failed');
error(err.message);
process.exit(1);
}
// Save manifest
const manifestPath = saveManifest(manifest);
// Show summary
console.log('');
const summaryLines = [
chalk.green.bold('✓ Installation Successful'),
'',
chalk.white(`Mode: ${chalk.cyan(mode)}`),
chalk.white(`Path: ${chalk.cyan(installPath)}`),
chalk.white(`Version: ${chalk.cyan(version)}`),
'',
chalk.gray(`Files installed: ${totalFiles}`),
chalk.gray(`Directories created: ${totalDirs}`),
'',
chalk.gray(`Manifest: ${basename(manifestPath)}`)
];
summaryBox({
title: ' Installation Summary ',
lines: summaryLines,
borderColor: 'green'
});
// Show next steps
console.log('');
info('Next steps:');
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
console.log(chalk.gray(' 3. Run: ccw uninstall - to remove this installation'));
console.log('');
}
/**
* Interactive mode selection
* @returns {Promise<string>} - Selected mode
*/
async function selectMode() {
const { mode } = await inquirer.prompt([{
type: 'list',
name: 'mode',
message: 'Select installation mode:',
choices: [
{
name: `${chalk.cyan('Global')} - Install to home directory (recommended)`,
value: 'Global'
},
{
name: `${chalk.yellow('Path')} - Install to specific project path`,
value: 'Path'
}
]
}]);
return mode;
}
/**
* Interactive path selection
* @returns {Promise<string>} - Selected path
*/
async function selectPath() {
const { path } = await inquirer.prompt([{
type: 'input',
name: 'path',
message: 'Enter installation path:',
default: process.cwd(),
validate: (input) => {
if (!input) return 'Path is required';
if (!existsSync(input)) {
return `Path does not exist: ${input}`;
}
return true;
}
}]);
return path;
}
/**
* Create backup of existing installation
* @param {string} installPath - Installation path
* @param {Object} manifest - Existing manifest
*/
async function createBackup(installPath, manifest) {
const spinner = createSpinner('Creating backup...').start();
try {
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const backupDir = join(installPath, `.claude-backup-${timestamp}`);
mkdirSync(backupDir, { recursive: true });
// Copy existing .claude directory
const claudeDir = join(installPath, '.claude');
if (existsSync(claudeDir)) {
await copyDirectory(claudeDir, join(backupDir, '.claude'));
}
spinner.succeed(`Backup created: ${backupDir}`);
} catch (err) {
spinner.warn(`Backup failed: ${err.message}`);
}
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Object} manifest - Manifest to track files (optional)
* @returns {Object} - Count of files and directories
*/
async function copyDirectory(src, dest, manifest = null) {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
if (manifest) addDirectoryEntry(manifest, dest);
}
const entries = readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
const result = await copyDirectory(srcPath, destPath, manifest);
files += result.files;
directories += result.directories;
} else {
copyFileSync(srcPath, destPath);
files++;
if (manifest) addFileEntry(manifest, destPath);
}
}
return { files, directories };
}
/**
* Get package version
* @returns {string} - Version string
*/
function getVersion() {
try {
const pkgPath = join(getPackageRoot(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || '1.0.0';
} catch {
return '1.0.0';
}
}

37
ccw/src/commands/list.js Normal file
View File

@@ -0,0 +1,37 @@
import chalk from 'chalk';
import { showBanner, divider, info } from '../utils/ui.js';
import { getAllManifests } from '../core/manifest.js';
/**
* List command handler - shows all installations
*/
export async function listCommand() {
showBanner();
console.log(chalk.cyan.bold(' Installed Claude Code Workflow Instances\n'));
const manifests = getAllManifests();
if (manifests.length === 0) {
info('No installations found.');
console.log('');
console.log(chalk.gray(' Run: ccw install - to install Claude Code Workflow'));
console.log('');
return;
}
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white.bold(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count}`));
console.log(chalk.gray(` Dirs: ${m.directories_count}`));
console.log('');
});
divider();
console.log(chalk.gray(' Run: ccw uninstall - to remove an installation'));
console.log('');
}

67
ccw/src/commands/serve.js Normal file
View 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);
}
}

View File

@@ -0,0 +1,238 @@
import { existsSync, unlinkSync, rmdirSync, readdirSync, statSync } from 'fs';
import { join, dirname, basename } from 'path';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showBanner, createSpinner, success, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { getAllManifests, deleteManifest } from '../core/manifest.js';
/**
* Uninstall command handler
* @param {Object} options - Command options
*/
export async function uninstallCommand(options) {
showBanner();
console.log(chalk.cyan.bold(' Uninstall Claude Code Workflow\n'));
// Get all manifests
const manifests = getAllManifests();
if (manifests.length === 0) {
warning('No installations found.');
info('Nothing to uninstall.');
return;
}
// Display installations
console.log(chalk.white.bold(' Found installations:\n'));
manifests.forEach((m, i) => {
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Date: ${new Date(m.installation_date).toLocaleDateString()}`));
console.log(chalk.gray(` Version: ${m.application_version}`));
console.log(chalk.gray(` Files: ${m.files_count} | Dirs: ${m.directories_count}`));
console.log('');
});
divider();
// Select installation to uninstall
let selectedManifest;
if (manifests.length === 1) {
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Uninstall ${manifests[0].installation_mode} installation at ${manifests[0].installation_path}?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[0];
} else {
const choices = manifests.map((m, i) => ({
name: `${m.installation_mode} - ${m.installation_path}`,
value: i
}));
choices.push({ name: chalk.gray('Cancel'), value: -1 });
const { selection } = await inquirer.prompt([{
type: 'list',
name: 'selection',
message: 'Select installation to uninstall:',
choices
}]);
if (selection === -1) {
info('Uninstall cancelled');
return;
}
selectedManifest = manifests[selection];
// Confirm selection
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to uninstall ${selectedManifest.installation_mode} installation?`,
default: false
}]);
if (!confirm) {
info('Uninstall cancelled');
return;
}
}
console.log('');
// Perform uninstallation
const spinner = createSpinner('Removing files...').start();
let removedFiles = 0;
let removedDirs = 0;
let failedFiles = [];
try {
// Remove files first (in reverse order to handle nested files)
const files = [...(selectedManifest.files || [])].reverse();
for (const fileEntry of files) {
const filePath = fileEntry.path;
spinner.text = `Removing: ${basename(filePath)}`;
try {
if (existsSync(filePath)) {
unlinkSync(filePath);
removedFiles++;
}
} catch (err) {
failedFiles.push({ path: filePath, error: err.message });
}
}
// Remove directories (in reverse order to remove nested dirs first)
const directories = [...(selectedManifest.directories || [])].reverse();
// Sort by path length (deepest first)
directories.sort((a, b) => b.path.length - a.path.length);
for (const dirEntry of directories) {
const dirPath = dirEntry.path;
spinner.text = `Removing directory: ${basename(dirPath)}`;
try {
if (existsSync(dirPath)) {
// Only remove if empty
const contents = readdirSync(dirPath);
if (contents.length === 0) {
rmdirSync(dirPath);
removedDirs++;
}
}
} catch (err) {
// Ignore directory removal errors (might not be empty)
}
}
// Try to clean up parent directories if empty
const installPath = selectedManifest.installation_path;
for (const dir of ['.claude', '.codex', '.gemini', '.qwen']) {
const dirPath = join(installPath, dir);
try {
if (existsSync(dirPath)) {
await removeEmptyDirs(dirPath);
}
} catch {
// Ignore
}
}
spinner.succeed('Uninstall complete!');
} catch (err) {
spinner.fail('Uninstall failed');
error(err.message);
return;
}
// Delete manifest
deleteManifest(selectedManifest.manifest_file);
// Show summary
console.log('');
if (failedFiles.length > 0) {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.yellow.bold('⚠ Partially Completed'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
chalk.white(`Failed: ${chalk.red(failedFiles.length)}`),
'',
chalk.gray('Some files could not be removed.'),
chalk.gray('They may be in use or require elevated permissions.'),
],
borderColor: 'yellow'
});
if (process.env.DEBUG) {
console.log('');
console.log(chalk.gray('Failed files:'));
failedFiles.forEach(f => {
console.log(chalk.red(` ${f.path}: ${f.error}`));
});
}
} else {
summaryBox({
title: ' Uninstall Summary ',
lines: [
chalk.green.bold('✓ Successfully Uninstalled'),
'',
chalk.white(`Files removed: ${chalk.green(removedFiles)}`),
chalk.white(`Directories removed: ${chalk.green(removedDirs)}`),
'',
chalk.gray('Manifest removed'),
],
borderColor: 'green'
});
}
console.log('');
}
/**
* Recursively remove empty directories
* @param {string} dirPath - Directory path
*/
async function removeEmptyDirs(dirPath) {
if (!existsSync(dirPath)) return;
const stat = statSync(dirPath);
if (!stat.isDirectory()) return;
let files = readdirSync(dirPath);
// Recursively check subdirectories
for (const file of files) {
const filePath = join(dirPath, file);
if (statSync(filePath).isDirectory()) {
await removeEmptyDirs(filePath);
}
}
// Re-check after processing subdirectories
files = readdirSync(dirPath);
if (files.length === 0) {
rmdirSync(dirPath);
}
}

307
ccw/src/commands/upgrade.js Normal file
View File

@@ -0,0 +1,307 @@
import { existsSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname, basename } from 'path';
import { fileURLToPath } from 'url';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { showBanner, createSpinner, info, warning, error, summaryBox, divider } from '../utils/ui.js';
import { getAllManifests, createManifest, addFileEntry, addDirectoryEntry, saveManifest, deleteManifest } from '../core/manifest.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directories to install
const SOURCE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'];
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot() {
return join(__dirname, '..', '..');
}
// Get source installation directory (parent of ccw)
function getSourceDir() {
return join(getPackageRoot(), '..');
}
/**
* Get package version
* @returns {string} - Version string
*/
function getVersion() {
try {
const pkgPath = join(getPackageRoot(), 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || '1.0.0';
} catch {
return '1.0.0';
}
}
/**
* Upgrade command handler
* @param {Object} options - Command options
*/
export async function upgradeCommand(options) {
showBanner();
console.log(chalk.cyan.bold(' Upgrade Claude Code Workflow\n'));
const currentVersion = getVersion();
// Get all manifests
const manifests = getAllManifests();
if (manifests.length === 0) {
warning('No installations found.');
info('Run "ccw install" to install first.');
return;
}
// Display current installations
console.log(chalk.white.bold(' Current installations:\n'));
const upgradeTargets = [];
for (let i = 0; i < manifests.length; i++) {
const m = manifests[i];
const modeColor = m.installation_mode === 'Global' ? chalk.cyan : chalk.yellow;
// Read installed version
const versionFile = join(m.installation_path, '.claude', 'version.json');
let installedVersion = 'unknown';
if (existsSync(versionFile)) {
try {
const versionData = JSON.parse(readFileSync(versionFile, 'utf8'));
installedVersion = versionData.version || 'unknown';
} catch {
// Ignore parse errors
}
}
// Check if upgrade needed
const needsUpgrade = installedVersion !== currentVersion;
console.log(chalk.white(` ${i + 1}. `) + modeColor.bold(m.installation_mode));
console.log(chalk.gray(` Path: ${m.installation_path}`));
console.log(chalk.gray(` Installed: ${installedVersion}`));
if (needsUpgrade) {
console.log(chalk.green(` Package: ${currentVersion} `) + chalk.green('← Update available'));
upgradeTargets.push({ manifest: m, installedVersion, index: i });
} else {
console.log(chalk.gray(` Up to date ✓`));
}
console.log('');
}
divider();
if (upgradeTargets.length === 0) {
info('All installations are up to date.');
console.log('');
info('To upgrade ccw itself, run:');
console.log(chalk.cyan(' npm update -g ccw'));
console.log('');
return;
}
// Select which installations to upgrade
let selectedManifests = [];
if (options.all) {
selectedManifests = upgradeTargets.map(t => t.manifest);
} else if (upgradeTargets.length === 1) {
const target = upgradeTargets[0];
const { confirm } = await inquirer.prompt([{
type: 'confirm',
name: 'confirm',
message: `Upgrade ${target.manifest.installation_mode} installation (${target.installedVersion}${currentVersion})?`,
default: true
}]);
if (!confirm) {
info('Upgrade cancelled');
return;
}
selectedManifests = [target.manifest];
} else {
const choices = upgradeTargets.map((t, i) => ({
name: `${t.manifest.installation_mode} - ${t.manifest.installation_path} (${t.installedVersion}${currentVersion})`,
value: i,
checked: true
}));
const { selections } = await inquirer.prompt([{
type: 'checkbox',
name: 'selections',
message: 'Select installations to upgrade:',
choices
}]);
if (selections.length === 0) {
info('No installations selected');
return;
}
selectedManifests = selections.map(i => upgradeTargets[i].manifest);
}
// Perform upgrades
console.log('');
const results = [];
const sourceDir = getSourceDir();
for (const manifest of selectedManifests) {
const upgradeSpinner = createSpinner(`Upgrading ${manifest.installation_mode} at ${manifest.installation_path}...`).start();
try {
const result = await performUpgrade(manifest, sourceDir, currentVersion);
upgradeSpinner.succeed(`Upgraded ${manifest.installation_mode}: ${result.files} files`);
results.push({ manifest, success: true, ...result });
} catch (err) {
upgradeSpinner.fail(`Failed to upgrade ${manifest.installation_mode}`);
error(err.message);
results.push({ manifest, success: false, error: err.message });
}
}
// Show summary
console.log('');
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
const summaryLines = [
successCount === results.length
? chalk.green.bold('✓ Upgrade Successful')
: chalk.yellow.bold('⚠ Upgrade Completed with Issues'),
'',
chalk.white(`Version: ${chalk.cyan(currentVersion)}`),
''
];
if (successCount > 0) {
summaryLines.push(chalk.green(`Upgraded: ${successCount} installation(s)`));
}
if (failCount > 0) {
summaryLines.push(chalk.red(`Failed: ${failCount} installation(s)`));
}
summaryBox({
title: ' Upgrade Summary ',
lines: summaryLines,
borderColor: failCount > 0 ? 'yellow' : 'green'
});
// Show next steps
console.log('');
info('Next steps:');
console.log(chalk.gray(' 1. Restart Claude Code or your IDE'));
console.log(chalk.gray(' 2. Run: ccw view - to open the workflow dashboard'));
console.log('');
}
/**
* Perform upgrade for a single installation
* @param {Object} manifest - Installation manifest
* @param {string} sourceDir - Source directory
* @param {string} version - Version string
* @returns {Promise<Object>} - Upgrade result
*/
async function performUpgrade(manifest, sourceDir, version) {
const installPath = manifest.installation_path;
// Get available source directories
const availableDirs = SOURCE_DIRS.filter(dir => existsSync(join(sourceDir, dir)));
if (availableDirs.length === 0) {
throw new Error('No source directories found');
}
// Create new manifest
const newManifest = createManifest(manifest.installation_mode, installPath);
let totalFiles = 0;
let totalDirs = 0;
// Copy each directory
for (const dir of availableDirs) {
const srcPath = join(sourceDir, dir);
const destPath = join(installPath, dir);
const { files, directories } = await copyDirectory(srcPath, destPath, newManifest);
totalFiles += files;
totalDirs += directories;
}
// Copy CLAUDE.md to .claude directory
const claudeMdSrc = join(sourceDir, 'CLAUDE.md');
const claudeMdDest = join(installPath, '.claude', 'CLAUDE.md');
if (existsSync(claudeMdSrc) && existsSync(dirname(claudeMdDest))) {
copyFileSync(claudeMdSrc, claudeMdDest);
addFileEntry(newManifest, claudeMdDest);
totalFiles++;
}
// Update version.json
const versionPath = join(installPath, '.claude', 'version.json');
if (existsSync(dirname(versionPath))) {
const versionData = {
version: version,
installedAt: new Date().toISOString(),
upgradedAt: new Date().toISOString(),
mode: manifest.installation_mode,
installer: 'ccw'
};
writeFileSync(versionPath, JSON.stringify(versionData, null, 2), 'utf8');
addFileEntry(newManifest, versionPath);
totalFiles++;
}
// Delete old manifest and save new one
if (manifest.manifest_file) {
deleteManifest(manifest.manifest_file);
}
saveManifest(newManifest);
return { files: totalFiles, directories: totalDirs };
}
/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Object} manifest - Manifest to track files
* @returns {Object} - Count of files and directories
*/
async function copyDirectory(src, dest, manifest) {
let files = 0;
let directories = 0;
// Create destination directory
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
directories++;
addDirectoryEntry(manifest, dest);
}
const entries = readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
const result = await copyDirectory(srcPath, destPath, manifest);
files += result.files;
directories += result.directories;
} else {
copyFileSync(srcPath, destPath);
files++;
addFileEntry(manifest, destPath);
}
}
return { files, directories };
}

14
ccw/src/commands/view.js Normal file
View File

@@ -0,0 +1,14 @@
import { serveCommand } from './serve.js';
/**
* View command handler - starts dashboard server (unified with serve mode)
* @param {Object} options - Command options
*/
export async function viewCommand(options) {
// Forward to serve command with same options
await serveCommand({
path: options.path,
port: options.port || 3456,
browser: options.browser
});
}

View File

@@ -0,0 +1,29 @@
// Add after line 13 (after REVIEW_TEMPLATE constant)
// Modular dashboard JS files (in dependency order)
const MODULE_FILES = [
// Base (no dependencies)
'dashboard-js/state.js',
'dashboard-js/utils.js',
'dashboard-js/api.js',
// Components (independent)
'dashboard-js/components/theme.js',
'dashboard-js/components/sidebar.js',
'dashboard-js/components/modals.js',
'dashboard-js/components/flowchart.js',
// Components (dependent)
'dashboard-js/components/task-drawer-renderers.js',
'dashboard-js/components/task-drawer-core.js',
'dashboard-js/components/tabs-context.js',
'dashboard-js/components/tabs-other.js',
// Views
'dashboard-js/views/home.js',
'dashboard-js/views/project-overview.js',
'dashboard-js/views/review-session.js',
'dashboard-js/views/fix-session.js',
'dashboard-js/views/lite-tasks.js',
'dashboard-js/views/session-detail.js',
// Navigation & Main
'dashboard-js/components/navigation.js',
'dashboard-js/main.js'
];

View File

@@ -0,0 +1,667 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Bundled template paths
const UNIFIED_TEMPLATE = join(__dirname, '../templates/dashboard.html');
const JS_FILE = join(__dirname, '../templates/dashboard.js');
const CSS_FILE = join(__dirname, '../templates/dashboard.css');
const WORKFLOW_TEMPLATE = join(__dirname, '../templates/workflow-dashboard.html');
const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.html');
const MODULE_FILES = [
'utils.js',
'state.js',
'api.js',
'components/theme.js',
'components/modals.js',
'components/navigation.js',
'components/sidebar.js',
'components/tabs-context.js',
'components/tabs-other.js',
'components/task-drawer-core.js',
'components/task-drawer-renderers.js',
'components/flowchart.js',
'views/home.js',
'views/project-overview.js',
'views/session-detail.js',
'views/review-session.js',
'views/lite-tasks.js',
'views/fix-session.js',
'main.js'
];
/**
* Generate dashboard HTML from aggregated data
* Uses bundled templates from ccw package
* @param {Object} data - Aggregated dashboard data
* @returns {Promise<string>} - Generated HTML
*/
export async function generateDashboard(data) {
// 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 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 file
let cssContent = existsSync(CSS_FILE) ? readFileSync(CSS_FILE, 'utf8') : '';
// Read JS content
let jsContent = '';
const moduleBase = join(__dirname, '../templates/dashboard-js');
if (existsSync(moduleBase)) {
jsContent = MODULE_FILES.map(file => {
const filePath = join(moduleBase, file);
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
}).join('\n\n');
} else if (existsSync(JS_FILE)) {
jsContent = 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 JS and CSS into HTML template
html = html.replace('{{JS_CONTENT}}', jsContent);
html = html.replace('{{CSS_CONTENT}}', cssContent);
// 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
* @param {string} templatePath - Path to workflow-dashboard.html
* @returns {string} - Generated HTML
*/
function generateFromBundledTemplate(data, templatePath) {
let html = readFileSync(templatePath, 'utf8');
// Prepare workflow data for injection
const workflowData = {
activeSessions: data.activeSessions,
archivedSessions: data.archivedSessions
};
// Inject workflow data
html = html.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
// If we have review data, add a review tab
if (data.reviewData && data.reviewData.totalFindings > 0) {
html = injectReviewTab(html, data.reviewData);
}
return html;
}
/**
* Inject review tab into existing dashboard
* @param {string} html - Base dashboard HTML
* @param {Object} reviewData - Review data to display
* @returns {string} - Modified HTML with review tab
*/
function injectReviewTab(html, reviewData) {
// Add review tab button in header controls
const tabButtonHtml = `
<button class="btn" data-tab="reviews" id="reviewTabBtn">Reviews (${reviewData.totalFindings})</button>
`;
// Insert after filter-group
html = html.replace(
'</div>\n </div>\n </header>',
`</div>
<div class="filter-group" style="margin-left: auto;">
${tabButtonHtml}
</div>
</div>
</header>`
);
// Add review section before closing container
const reviewSectionHtml = generateReviewSection(reviewData);
html = html.replace(
'</div>\n\n <button class="theme-toggle"',
`</div>
${reviewSectionHtml}
</div>
<button class="theme-toggle"`
);
// Add review tab JavaScript
const reviewScript = generateReviewScript(reviewData);
html = html.replace('</script>', `\n${reviewScript}\n</script>`);
return html;
}
/**
* Generate review section HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML for review section
*/
function generateReviewSection(reviewData) {
const severityBars = Object.entries(reviewData.severityDistribution)
.map(([severity, count]) => {
const colors = {
critical: '#c53030',
high: '#f56565',
medium: '#ed8936',
low: '#48bb78'
};
const percent = reviewData.totalFindings > 0
? Math.round((count / reviewData.totalFindings) * 100)
: 0;
return `
<div class="severity-bar-item">
<span class="severity-label">${severity}</span>
<div class="severity-bar">
<div class="severity-fill" style="width: ${percent}%; background-color: ${colors[severity]}"></div>
</div>
<span class="severity-count">${count}</span>
</div>
`;
}).join('');
const dimensionCards = Object.entries(reviewData.dimensionSummary)
.map(([name, info]) => `
<div class="dimension-card">
<div class="dimension-name">${name}</div>
<div class="dimension-count">${info.count} findings</div>
</div>
`).join('');
return `
<div class="section" id="reviewSectionContainer" style="display: none;">
<div class="section-header">
<h2 class="section-title">Code Review Findings</h2>
</div>
<div class="review-stats">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${reviewData.severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${reviewData.severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${reviewData.severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${reviewData.severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="severity-distribution">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">Severity Distribution</h3>
${severityBars}
</div>
<div class="dimensions-grid" style="margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">By Dimension</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
${dimensionCards}
</div>
</div>
<style>
.review-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.severity-distribution {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.severity-bar-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.severity-label {
width: 80px;
text-transform: capitalize;
font-size: 0.9rem;
}
.severity-bar {
flex: 1;
height: 20px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.severity-fill {
height: 100%;
transition: width 0.3s;
}
.severity-count {
width: 40px;
text-align: right;
font-weight: bold;
}
.dimension-card {
background: var(--bg-card);
padding: 15px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.dimension-name {
font-weight: 600;
text-transform: capitalize;
margin-bottom: 5px;
}
.dimension-count {
color: var(--text-secondary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.review-stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</div>
`;
}
/**
* Generate JavaScript for review tab functionality
* @param {Object} reviewData - Review data
* @returns {string} - JavaScript code
*/
function generateReviewScript(reviewData) {
return `
// Review tab functionality
const reviewTabBtn = document.getElementById('reviewTabBtn');
const reviewSection = document.getElementById('reviewSectionContainer');
const activeSectionContainer = document.getElementById('activeSectionContainer');
const archivedSectionContainer = document.getElementById('archivedSectionContainer');
if (reviewTabBtn) {
reviewTabBtn.addEventListener('click', () => {
const isActive = reviewTabBtn.classList.contains('active');
// Toggle review section
if (isActive) {
// Hide reviews, show workflow
reviewTabBtn.classList.remove('active');
reviewSection.style.display = 'none';
activeSectionContainer.style.display = 'block';
archivedSectionContainer.style.display = 'block';
} else {
// Show reviews, hide workflow
reviewTabBtn.classList.add('active');
reviewSection.style.display = 'block';
activeSectionContainer.style.display = 'none';
archivedSectionContainer.style.display = 'none';
// Reset filter buttons
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
document.querySelector('[data-filter="all"]').classList.add('active');
}
});
}
`;
}
/**
* Generate inline dashboard HTML (fallback if bundled templates missing)
* @param {Object} data - Dashboard data
* @returns {string}
*/
function generateInlineDashboard(data) {
const stats = data.statistics;
const hasReviews = data.reviewData && data.reviewData.totalFindings > 0;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCW Dashboard</title>
<style>
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #718096;
--border-color: #e2e8f0;
--accent-color: #4299e1;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-card: #2d3748;
--text-primary: #f7fafc;
--text-secondary: #a0aec0;
--border-color: #4a5568;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 30px;
}
h1 { font-size: 2rem; color: var(--accent-color); margin-bottom: 10px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.stat-value { font-size: 2rem; font-weight: bold; color: var(--accent-color); }
.stat-label { color: var(--text-secondary); font-size: 0.9rem; }
.section { margin-bottom: 40px; }
.section-title { font-size: 1.5rem; margin-bottom: 20px; }
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.session-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.session-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 10px; }
.session-meta { color: var(--text-secondary); font-size: 0.9rem; }
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
margin: 15px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
}
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-primary);
border-radius: 6px;
border-left: 3px solid var(--border-color);
}
.task-item.completed { border-left-color: var(--success-color); opacity: 0.8; }
.task-item.in_progress { border-left-color: var(--warning-color); }
.task-title { flex: 1; font-size: 0.9rem; }
.task-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
.tabs { display: flex; gap: 10px; margin-top: 15px; }
.tab-btn {
padding: 10px 20px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
}
.tab-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); }
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--accent-color);
color: white;
border: none;
cursor: pointer;
font-size: 1.5rem;
box-shadow: var(--shadow);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>CCW Dashboard</h1>
<p style="color: var(--text-secondary);">Workflow Sessions and Reviews</p>
<div class="tabs">
<button class="tab-btn active" data-tab="workflow">Workflow</button>
${hasReviews ? '<button class="tab-btn" data-tab="reviews">Reviews</button>' : ''}
</div>
</header>
<div id="workflowTab">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${stats.totalSessions}</div>
<div class="stat-label">Total Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.activeSessions}</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalTasks}</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.completedTasks}</div>
<div class="stat-label">Completed Tasks</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Active Sessions</h2>
<div class="sessions-grid" id="activeSessions">
${data.activeSessions.length === 0
? '<div class="empty-state">No active sessions</div>'
: data.activeSessions.map(s => renderSessionCard(s, true)).join('')}
</div>
</div>
<div class="section">
<h2 class="section-title">Archived Sessions</h2>
<div class="sessions-grid" id="archivedSessions">
${data.archivedSessions.length === 0
? '<div class="empty-state">No archived sessions</div>'
: data.archivedSessions.map(s => renderSessionCard(s, false)).join('')}
</div>
</div>
</div>
${hasReviews ? renderReviewTab(data.reviewData) : ''}
</div>
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.querySelector('.theme-toggle').textContent = next === 'dark' ? '☀️' : '🌙';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
document.querySelector('.theme-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('workflowTab').style.display = tab === 'workflow' ? 'block' : 'none';
const reviewTab = document.getElementById('reviewsTab');
if (reviewTab) reviewTab.style.display = tab === 'reviews' ? 'block' : 'none';
});
});
</script>
</body>
</html>`;
}
/**
* Render a session card
* @param {Object} session - Session data
* @param {boolean} isActive - Whether session is active
* @returns {string} - HTML string
*/
function renderSessionCard(session, isActive) {
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: session.taskCount;
const totalTasks = isActive ? session.tasks.length : session.taskCount;
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
const tasksHtml = isActive && session.tasks.length > 0
? session.tasks.map(t => `
<div class="task-item ${t.status}">
<div class="task-title">${t.title}</div>
<span class="task-id">${t.task_id}</span>
</div>
`).join('')
: '';
return `
<div class="session-card">
<div class="session-title">${session.session_id}</div>
<div class="session-meta">
${session.project ? `<div>${session.project}</div>` : ''}
<div>${session.created_at} | ${completedTasks}/${totalTasks} tasks</div>
</div>
${totalTasks > 0 ? `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
` : ''}
${tasksHtml}
</div>
`;
}
/**
* Render review tab HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML string
*/
function renderReviewTab(reviewData) {
const { severityDistribution, dimensionSummary } = reviewData;
return `
<div id="reviewsTab" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Findings by Dimension</h2>
<div class="sessions-grid">
${Object.entries(dimensionSummary).map(([name, info]) => `
<div class="session-card">
<div class="session-title" style="text-transform: capitalize;">${name}</div>
<div class="session-meta">${info.count} findings</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,409 @@
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
* @param {Object} sessions - Scanned sessions from session-scanner
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<Object>} - Aggregated dashboard data
*/
export async function aggregateData(sessions, workflowDir) {
const data = {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
liteTasks: {
litePlan: [],
liteFix: []
},
reviewData: null,
projectOverview: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0,
litePlanCount: 0,
liteFixCount: 0
}
};
// Process active sessions
for (const session of sessions.active) {
const sessionData = await processSession(session, true);
data.activeSessions.push(sessionData);
data.statistics.totalTasks += sessionData.tasks.length;
data.statistics.completedTasks += sessionData.tasks.filter(t => t.status === 'completed').length;
}
// Process archived sessions
for (const session of sessions.archived) {
const sessionData = await processSession(session, false);
data.archivedSessions.push(sessionData);
data.statistics.totalTasks += sessionData.taskCount || 0;
data.statistics.completedTasks += sessionData.taskCount || 0;
}
// Aggregate review data if present
if (sessions.hasReviewData) {
data.reviewData = await aggregateReviewData(sessions.active);
data.statistics.reviewFindings = data.reviewData.totalFindings;
}
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);
}
// Load project overview from project.json
try {
data.projectOverview = loadProjectOverview(workflowDir);
} catch (err) {
console.error('Error loading project overview:', err.message);
}
return data;
}
/**
* Process a single session, loading tasks and review info
* @param {Object} session - Session object from scanner
* @param {boolean} isActive - Whether session is active
* @returns {Promise<Object>} - Processed session data
*/
async function processSession(session, isActive) {
const result = {
session_id: session.session_id,
project: session.project || session.session_id,
status: session.status || (isActive ? 'active' : 'archived'),
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,
hasReview: false,
reviewSummary: null,
reviewDimensions: []
};
// Load tasks for active sessions (full details)
if (isActive) {
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
for (const taskFile of taskFiles) {
try {
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
result.tasks.push({
task_id: taskData.id || basename(taskFile, '.json'),
title: taskData.title || 'Untitled Task',
status: taskData.status || 'pending',
type: taskData.meta?.type || 'task',
meta: taskData.meta || {},
context: taskData.context || {},
flow_control: taskData.flow_control || {}
});
} catch {
// Skip invalid task files
}
}
// Sort tasks by ID
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
}
result.taskCount = result.tasks.length;
// Check for review data
const reviewDir = join(session.path, '.review');
if (existsSync(reviewDir)) {
result.hasReview = true;
result.reviewSummary = loadReviewSummary(reviewDir);
// Load dimension data for review sessions
if (session.type === 'review') {
result.reviewDimensions = await loadDimensionData(reviewDir);
}
}
} else {
// For archived, also load tasks (same as active)
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
for (const taskFile of taskFiles) {
try {
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
result.tasks.push({
task_id: taskData.id || basename(taskFile, '.json'),
title: taskData.title || 'Untitled Task',
status: taskData.status || 'completed', // Archived tasks are usually completed
type: taskData.meta?.type || 'task'
});
} catch {
// Skip invalid task files
}
}
// Sort tasks by ID
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
result.taskCount = result.tasks.length;
}
// Check for review data in archived sessions too
const reviewDir = join(session.path, '.review');
if (existsSync(reviewDir)) {
result.hasReview = true;
result.reviewSummary = loadReviewSummary(reviewDir);
// Load dimension data for review sessions
if (session.type === 'review') {
result.reviewDimensions = await loadDimensionData(reviewDir);
}
}
}
return result;
}
/**
* Aggregate review data from all active sessions with reviews
* @param {Array} activeSessions - Active session objects
* @returns {Promise<Object>} - Aggregated review data
*/
async function aggregateReviewData(activeSessions) {
const reviewData = {
totalFindings: 0,
severityDistribution: { critical: 0, high: 0, medium: 0, low: 0 },
dimensionSummary: {},
sessions: []
};
for (const session of activeSessions) {
const reviewDir = join(session.path, '.review');
if (!existsSync(reviewDir)) continue;
const reviewProgress = loadReviewProgress(reviewDir);
const dimensionData = await loadDimensionData(reviewDir);
if (reviewProgress || dimensionData.length > 0) {
const sessionReview = {
session_id: session.session_id,
progress: reviewProgress,
dimensions: dimensionData,
findings: []
};
// Collect and count findings
for (const dim of dimensionData) {
if (dim.findings && Array.isArray(dim.findings)) {
for (const finding of dim.findings) {
const severity = (finding.severity || 'low').toLowerCase();
if (reviewData.severityDistribution.hasOwnProperty(severity)) {
reviewData.severityDistribution[severity]++;
}
reviewData.totalFindings++;
sessionReview.findings.push({
...finding,
dimension: dim.name
});
}
}
// Track dimension summary
if (!reviewData.dimensionSummary[dim.name]) {
reviewData.dimensionSummary[dim.name] = { count: 0, sessions: [] };
}
reviewData.dimensionSummary[dim.name].count += dim.findings?.length || 0;
reviewData.dimensionSummary[dim.name].sessions.push(session.session_id);
}
reviewData.sessions.push(sessionReview);
}
}
return reviewData;
}
/**
* Load review progress from review-progress.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewProgress(reviewDir) {
const progressFile = join(reviewDir, 'review-progress.json');
if (!existsSync(progressFile)) return null;
try {
return JSON.parse(readFileSync(progressFile, 'utf8'));
} catch {
return null;
}
}
/**
* Load review summary from review-state.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewSummary(reviewDir) {
const stateFile = join(reviewDir, 'review-state.json');
if (!existsSync(stateFile)) return null;
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
return {
phase: state.phase || 'unknown',
severityDistribution: state.severity_distribution || {},
criticalFiles: (state.critical_files || []).slice(0, 3),
status: state.status || 'in_progress'
};
} catch {
return null;
}
}
/**
* Load dimension data from .review/dimensions/
* @param {string} reviewDir - Path to .review directory
* @returns {Promise<Array>}
*/
async function loadDimensionData(reviewDir) {
const dimensionsDir = join(reviewDir, 'dimensions');
if (!existsSync(dimensionsDir)) return [];
const dimensions = [];
const dimFiles = await safeGlob('*.json', dimensionsDir);
for (const file of dimFiles) {
try {
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
// Handle array structure: [ { findings: [...], summary: {...} } ]
let findings = [];
let summary = null;
let status = 'completed';
if (Array.isArray(data) && data.length > 0) {
const dimData = data[0];
findings = dimData.findings || [];
summary = dimData.summary || null;
status = dimData.status || 'completed';
} else if (data.findings) {
findings = data.findings;
summary = data.summary || null;
status = data.status || 'completed';
}
dimensions.push({
name: basename(file, '.json'),
findings: findings,
summary: summary,
status: status
});
} catch {
// Skip invalid dimension files
}
}
return dimensions;
}
/**
* Safe glob wrapper that returns empty array on error
* @param {string} pattern - Glob pattern
* @param {string} cwd - Current working directory
* @returns {Promise<string[]>}
*/
async function safeGlob(pattern, cwd) {
try {
return await glob(pattern, { cwd, absolute: false });
} catch {
return [];
}
}
// 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.)
* @param {string} a - First task ID
* @param {string} b - Second task ID
* @returns {number}
*/
function sortTaskIds(a, b) {
const parseId = (id) => {
const match = id.match(/IMPL-(\d+)(?:\.(\d+))?/);
if (!match) return [0, 0];
return [parseInt(match[1]), parseInt(match[2] || 0)];
};
const [a1, a2] = parseId(a);
const [b1, b2] = parseId(b);
return a1 - b1 || a2 - b2;
}
/**
* Load project overview from project.json
* @param {string} workflowDir - Path to .workflow directory
* @returns {Object|null} - Project overview data or null if not found
*/
function loadProjectOverview(workflowDir) {
const projectFile = join(workflowDir, 'project.json');
if (!existsSync(projectFile)) {
console.log(`Project file not found at: ${projectFile}`);
return null;
}
try {
const fileContent = readFileSync(projectFile, 'utf8');
const projectData = JSON.parse(fileContent);
console.log(`Successfully loaded project overview: ${projectData.project_name || 'Unknown'}`);
return {
projectName: projectData.project_name || 'Unknown',
description: projectData.overview?.description || '',
initializedAt: projectData.initialized_at || null,
technologyStack: projectData.overview?.technology_stack || {
languages: [],
frameworks: [],
build_tools: [],
test_frameworks: []
},
architecture: projectData.overview?.architecture || {
style: 'Unknown',
layers: [],
patterns: []
},
keyComponents: projectData.overview?.key_components || [],
features: projectData.features || [],
developmentIndex: projectData.development_index || {
feature: [],
enhancement: [],
bugfix: [],
refactor: [],
docs: []
},
statistics: projectData.statistics || {
total_features: 0,
total_sessions: 0,
last_updated: null
},
metadata: projectData._metadata || {
initialized_by: 'unknown',
analysis_timestamp: null,
analysis_mode: 'unknown'
}
};
} catch (err) {
console.error(`Failed to parse project.json at ${projectFile}:`, err.message);
console.error('Error stack:', err.stack);
return null;
}
}

View 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;
}
}

201
ccw/src/core/manifest.js Normal file
View File

@@ -0,0 +1,201 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// Manifest directory location
const MANIFEST_DIR = join(homedir(), '.claude-manifests');
/**
* Ensure manifest directory exists
*/
function ensureManifestDir() {
if (!existsSync(MANIFEST_DIR)) {
mkdirSync(MANIFEST_DIR, { recursive: true });
}
}
/**
* Create a new installation manifest
* @param {string} mode - Installation mode (Global/Path)
* @param {string} installPath - Installation path
* @returns {Object} - New manifest object
*/
export function createManifest(mode, installPath) {
ensureManifestDir();
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const modePrefix = mode === 'Global' ? 'manifest-global' : 'manifest-path';
const manifestId = `${modePrefix}-${timestamp}`;
return {
manifest_id: manifestId,
version: '1.0',
installation_mode: mode,
installation_path: installPath,
installation_date: new Date().toISOString(),
installer_version: '1.0.0',
files: [],
directories: []
};
}
/**
* Add file entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} filePath - File path
*/
export function addFileEntry(manifest, filePath) {
manifest.files.push({
path: filePath,
type: 'File',
timestamp: new Date().toISOString()
});
}
/**
* Add directory entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} dirPath - Directory path
*/
export function addDirectoryEntry(manifest, dirPath) {
manifest.directories.push({
path: dirPath,
type: 'Directory',
timestamp: new Date().toISOString()
});
}
/**
* Save manifest to disk
* @param {Object} manifest - Manifest object
* @returns {string} - Path to saved manifest
*/
export function saveManifest(manifest) {
ensureManifestDir();
// Remove old manifests for same path and mode
removeOldManifests(manifest.installation_path, manifest.installation_mode);
const manifestPath = join(MANIFEST_DIR, `${manifest.manifest_id}.json`);
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
return manifestPath;
}
/**
* Remove old manifests for the same installation path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
*/
function removeOldManifests(installPath, mode) {
if (!existsSync(MANIFEST_DIR)) return;
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
const manifestPath = (content.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
const manifestMode = content.installation_mode || 'Global';
if (manifestPath === normalizedPath && manifestMode === mode) {
unlinkSync(filePath);
}
} catch {
// Skip invalid manifest files
}
}
} catch {
// Ignore errors
}
}
/**
* Get all installation manifests
* @returns {Array} - Array of manifest objects
*/
export function getAllManifests() {
if (!existsSync(MANIFEST_DIR)) return [];
const manifests = [];
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
// Try to read version.json for application version
let appVersion = 'unknown';
try {
const versionPath = join(content.installation_path, '.claude', 'version.json');
if (existsSync(versionPath)) {
const versionInfo = JSON.parse(readFileSync(versionPath, 'utf8'));
appVersion = versionInfo.version || 'unknown';
}
} catch {
// Ignore
}
manifests.push({
...content,
manifest_file: filePath,
application_version: appVersion,
files_count: content.files?.length || 0,
directories_count: content.directories?.length || 0
});
} catch {
// Skip invalid manifest files
}
}
// Sort by installation date (newest first)
manifests.sort((a, b) => new Date(b.installation_date) - new Date(a.installation_date));
} catch {
// Ignore errors
}
return manifests;
}
/**
* Find manifest for a specific path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
* @returns {Object|null} - Manifest or null
*/
export function findManifest(installPath, mode) {
const manifests = getAllManifests();
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
return manifests.find(m => {
const manifestPath = (m.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
return manifestPath === normalizedPath && m.installation_mode === mode;
}) || null;
}
/**
* Delete a manifest file
* @param {string} manifestFile - Path to manifest file
*/
export function deleteManifest(manifestFile) {
if (existsSync(manifestFile)) {
unlinkSync(manifestFile);
}
}
/**
* Get manifest directory path
* @returns {string}
*/
export function getManifestDir() {
return MANIFEST_DIR;
}

1348
ccw/src/core/server.js Normal file

File diff suppressed because it is too large Load Diff

385
ccw/src/core/server.js.bak Normal file
View File

@@ -0,0 +1,385 @@
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: {} },
projectOverview: null,
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 plan.json (for lite tasks)
if (dataType === 'plan' || dataType === 'all') {
const planFile = join(normalizedPath, 'plan.json');
if (existsSync(planFile)) {
try {
result.plan = JSON.parse(readFileSync(planFile, 'utf8'));
} catch (e) {
result.plan = null;
}
}
}
// 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 = {
state: null,
dimensions: [],
severityDistribution: null,
totalFindings: 0
};
if (existsSync(reviewDir)) {
// Load review-state.json
const stateFile = join(reviewDir, 'review-state.json');
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
result.review.state = state;
result.review.severityDistribution = state.severity_distribution || {};
result.review.totalFindings = state.total_findings || 0;
result.review.phase = state.phase || 'unknown';
result.review.dimensionSummaries = state.dimension_summaries || {};
result.review.crossCuttingConcerns = state.cross_cutting_concerns || [];
result.review.criticalFiles = state.critical_files || [];
} catch (e) {
// Skip unreadable state
}
}
// Load dimension findings
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 data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
// Handle array structure: [ { findings: [...] } ]
let findings = [];
let summary = null;
if (Array.isArray(data) && data.length > 0) {
const dimData = data[0];
findings = dimData.findings || [];
summary = dimData.summary || null;
} else if (data.findings) {
findings = data.findings;
summary = data.summary || null;
}
result.review.dimensions.push({
name: dimName,
findings: findings,
summary: summary,
count: findings.length
});
} 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: {} },
projectOverview: null,
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;
}

View File

@@ -0,0 +1,385 @@
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: {} },
projectOverview: null,
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 plan.json (for lite tasks)
if (dataType === 'plan' || dataType === 'all') {
const planFile = join(normalizedPath, 'plan.json');
if (existsSync(planFile)) {
try {
result.plan = JSON.parse(readFileSync(planFile, 'utf8'));
} catch (e) {
result.plan = null;
}
}
}
// 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 = {
state: null,
dimensions: [],
severityDistribution: null,
totalFindings: 0
};
if (existsSync(reviewDir)) {
// Load review-state.json
const stateFile = join(reviewDir, 'review-state.json');
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
result.review.state = state;
result.review.severityDistribution = state.severity_distribution || {};
result.review.totalFindings = state.total_findings || 0;
result.review.phase = state.phase || 'unknown';
result.review.dimensionSummaries = state.dimension_summaries || {};
result.review.crossCuttingConcerns = state.cross_cutting_concerns || [];
result.review.criticalFiles = state.critical_files || [];
} catch (e) {
// Skip unreadable state
}
}
// Load dimension findings
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 data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
// Handle array structure: [ { findings: [...] } ]
let findings = [];
let summary = null;
if (Array.isArray(data) && data.length > 0) {
const dimData = data[0];
findings = dimData.findings || [];
summary = dimData.summary || null;
} else if (data.findings) {
findings = data.findings;
summary = data.summary || null;
}
result.review.dimensions.push({
name: dimName,
findings: findings,
summary: summary,
count: findings.length
});
} 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: {} },
projectOverview: null,
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;
}

View File

@@ -0,0 +1,235 @@
import { glob } from 'glob';
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
import { join, basename } from 'path';
/**
* Scan .workflow directory for active and archived sessions
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<{active: Array, archived: Array, hasReviewData: boolean}>}
*/
export async function scanSessions(workflowDir) {
const result = {
active: [],
archived: [],
hasReviewData: false
};
if (!existsSync(workflowDir)) {
return result;
}
// Scan active sessions
const activeDir = join(workflowDir, 'active');
if (existsSync(activeDir)) {
const activeSessions = await findWfsSessions(activeDir);
for (const sessionName of activeSessions) {
const sessionPath = join(activeDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.active.push({
...sessionData,
path: sessionPath,
isActive: true
});
// Check for review data
if (existsSync(join(sessionPath, '.review'))) {
result.hasReviewData = true;
}
}
}
}
// Scan archived sessions
const archivesDir = join(workflowDir, 'archives');
if (existsSync(archivesDir)) {
const archivedSessions = await findWfsSessions(archivesDir);
for (const sessionName of archivedSessions) {
const sessionPath = join(archivesDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.archived.push({
...sessionData,
path: sessionPath,
isActive: false
});
}
}
}
// Sort by creation date (newest first)
result.active.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
result.archived.sort((a, b) => new Date(b.archived_at || b.created_at || 0) - new Date(a.archived_at || a.created_at || 0));
return result;
}
/**
* Find WFS-* directories in a given path
* @param {string} dir - Directory to search
* @returns {Promise<string[]>} - Array of session directory names
*/
async function findWfsSessions(dir) {
try {
// Use glob for cross-platform pattern matching
const sessions = await glob('WFS-*', {
cwd: dir,
onlyDirectories: true,
absolute: false
});
return sessions;
} catch {
// Fallback: manual directory listing
try {
const entries = readdirSync(dir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && e.name.startsWith('WFS-'))
.map(e => e.name);
} catch {
return [];
}
}
}
/**
* 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
* @returns {Object|null} - Session data object or null if invalid
*/
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 || sessionName,
project: data.project || data.description || '',
status: data.status || 'active',
created_at: data.created_at || data.initialized_at || data.timestamp || null,
archived_at: data.archived_at || null,
type: type,
workflow_type: data.workflow_type || null // Keep original for reference
};
} catch {
// Fall through to minimal session
}
}
// 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: sessionName,
project: '',
status: 'unknown',
created_at: timestampFromName || stats.birthtime.toISOString(),
archived_at: null,
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;
}
}
/**
* Check if session has review data
* @param {string} sessionPath - Path to session directory
* @returns {boolean}
*/
export function hasReviewData(sessionPath) {
const reviewDir = join(sessionPath, '.review');
return existsSync(reviewDir);
}
/**
* Get list of task files in session
* @param {string} sessionPath - Path to session directory
* @returns {Promise<string[]>}
*/
export async function getTaskFiles(sessionPath) {
const taskDir = join(sessionPath, '.task');
if (!existsSync(taskDir)) {
return [];
}
try {
return await glob('IMPL-*.json', { cwd: taskDir, absolute: false });
} catch {
return [];
}
}

9
ccw/src/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* CCW - Claude Code Workflow CLI
* Main exports for programmatic usage
*/
export { run } from './cli.js';
export { scanSessions } from './core/session-scanner.js';
export { aggregateData } from './core/data-aggregator.js';
export { generateDashboard } from './core/dashboard-generator.js';

View File

@@ -0,0 +1,200 @@
// ========================================
// API and Data Loading
// ========================================
// Server communication and data loading functions
// Note: Some functions are only available in server mode
// ========== Data Loading ==========
/**
* Load dashboard data from API (server mode only)
* @param {string} path - Project path to load data for
* @returns {Promise<Object|null>} Dashboard data object or null if failed
*/
async function loadDashboardData(path) {
if (!window.SERVER_MODE) {
console.warn('loadDashboardData called in static mode');
return null;
}
try {
const response = await fetch(`/api/data?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (err) {
console.error('Failed to load dashboard data:', err);
return null;
}
}
// ========== Path Management ==========
/**
* Switch to a new project path (server mode only)
* Loads dashboard data and updates UI
* @param {string} path - Project path to switch to
*/
async function switchToPath(path) {
// Show loading state
const container = document.getElementById('mainContent');
container.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await loadDashboardData(path);
if (data) {
// Update global data
workflowData = data;
projectPath = data.projectPath;
recentPaths = data.recentPaths || [];
// Update UI
document.getElementById('currentPath').textContent = projectPath;
renderDashboard();
refreshRecentPaths();
}
} catch (err) {
console.error('Failed to switch path:', err);
container.innerHTML = '<div class="error">Failed to load project data</div>';
}
}
/**
* Select a path from recent paths list
* @param {string} path - Path to select
*/
async function selectPath(path) {
localStorage.setItem('selectedPath', path);
// Server mode: load data dynamically
if (window.SERVER_MODE) {
await switchToPath(path);
return;
}
// Static mode: show command to run
const modal = document.createElement('div');
modal.className = 'path-modal-overlay';
modal.innerHTML = `
<div class="path-modal">
<div class="path-modal-header">
<span class="path-modal-icon">${icons.terminal}</span>
<h3>Run Command</h3>
</div>
<div class="path-modal-body">
<p>To view the dashboard for this project, run:</p>
<div class="path-modal-command">
<code>ccw view -p "${path}"</code>
<button class="copy-btn" id="copyCommandBtn">${icons.copy} <span>Copy</span></button>
</div>
<p class="path-modal-note" style="margin-top: 12px;">
Or use <code>ccw serve</code> for live path switching.
</p>
</div>
<div class="path-modal-footer">
<button class="path-modal-close" onclick="this.closest('.path-modal-overlay').remove()">OK</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Add copy handler
document.getElementById('copyCommandBtn').addEventListener('click', function() {
navigator.clipboard.writeText('ccw view -p "' + path + '"').then(() => {
this.innerHTML = icons.check + ' <span>Copied!</span>';
setTimeout(() => { this.innerHTML = icons.copy + ' <span>Copy</span>'; }, 2000);
});
});
}
/**
* Refresh recent paths dropdown UI
*/
function refreshRecentPaths() {
const recentContainer = document.getElementById('recentPaths');
recentContainer.innerHTML = '';
recentPaths.forEach(path => {
const item = document.createElement('div');
item.className = 'path-item' + (path === projectPath ? ' active' : '');
item.dataset.path = path;
// Path text
const pathText = document.createElement('span');
pathText.className = 'path-text';
pathText.textContent = path;
pathText.addEventListener('click', () => selectPath(path));
item.appendChild(pathText);
// Delete button (only for non-current paths)
if (path !== projectPath) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'path-delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.title = 'Remove from recent';
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await removeRecentPathFromList(path);
});
item.appendChild(deleteBtn);
}
recentContainer.appendChild(item);
});
}
/**
* Remove a path from recent paths list
*/
async function removeRecentPathFromList(path) {
try {
const response = await fetch('/api/remove-recent-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
if (response.ok) {
const data = await response.json();
if (data.success) {
recentPaths = data.paths;
refreshRecentPaths();
showRefreshToast('Path removed', 'success');
}
}
} catch (err) {
console.error('Failed to remove path:', err);
showRefreshToast('Failed to remove path', 'error');
}
}
// ========== File System Access ==========
/**
* Browse for folder using File System Access API or fallback to input dialog
*/
async function browseForFolder() {
// Try modern File System Access API first
if ('showDirectoryPicker' in window) {
try {
const dirHandle = await window.showDirectoryPicker({
mode: 'read',
startIn: 'documents'
});
// Get the directory name (we can't get full path for security reasons)
const dirName = dirHandle.name;
showPathSelectedModal(dirName, dirHandle);
return;
} catch (err) {
if (err.name === 'AbortError') {
// User cancelled
return;
}
console.warn('Directory picker failed:', err);
}
}
// Fallback: show input dialog
showPathInputModal();
}

View File

@@ -0,0 +1,112 @@
// ==========================================
// Conflict Resolution Tab
// ==========================================
async function loadAndRenderConflictTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading conflict resolution...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=conflict`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderConflictCards(data.conflictResolution);
return;
}
}
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">⚖️</div>
<div class="empty-title">No Conflict Resolution</div>
<div class="empty-text">No conflict-resolution-decisions.json found for this session.</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load conflict resolution: ${err.message}</div>`;
}
}
function renderConflictCards(conflictResolution) {
if (!conflictResolution) {
return `
<div class="tab-empty-state">
<div class="empty-icon">⚖️</div>
<div class="empty-title">No Conflict Resolution</div>
<div class="empty-text">No conflict decisions found for this session.</div>
</div>
`;
}
let cards = [];
// Header info
cards.push(`
<div class="conflict-tab-header">
<h3>⚖️ Conflict Resolution Decisions</h3>
<div class="conflict-meta-info">
<span>Session: <strong>${escapeHtml(conflictResolution.session_id || 'N/A')}</strong></span>
${conflictResolution.resolved_at ? `<span>Resolved: <strong>${formatDate(conflictResolution.resolved_at)}</strong></span>` : ''}
</div>
</div>
`);
// User Decisions as cards
if (conflictResolution.user_decisions && Object.keys(conflictResolution.user_decisions).length > 0) {
const decisions = Object.entries(conflictResolution.user_decisions);
cards.push(`<div class="conflict-section-title">🎯 User Decisions (${decisions.length})</div>`);
cards.push('<div class="conflict-cards-grid">');
for (const [key, decision] of decisions) {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
cards.push(`
<div class="conflict-card decision-card">
<div class="conflict-card-header">
<span class="conflict-card-label">${escapeHtml(label)}</span>
</div>
<div class="conflict-card-choice">
<span class="choice-label">Choice:</span>
<span class="choice-value">${escapeHtml(decision.choice || 'N/A')}</span>
</div>
${decision.description ? `
<div class="conflict-card-desc">${escapeHtml(decision.description)}</div>
` : ''}
${decision.implications && decision.implications.length > 0 ? `
<div class="conflict-card-implications">
<span class="impl-label">Implications:</span>
<ul>
${decision.implications.map(impl => `<li>${escapeHtml(impl)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
`);
}
cards.push('</div>');
}
// Resolved Conflicts as cards
if (conflictResolution.resolved_conflicts && conflictResolution.resolved_conflicts.length > 0) {
cards.push(`<div class="conflict-section-title">✅ Resolved Conflicts (${conflictResolution.resolved_conflicts.length})</div>`);
cards.push('<div class="conflict-cards-grid">');
for (const conflict of conflictResolution.resolved_conflicts) {
cards.push(`
<div class="conflict-card resolved-card">
<div class="conflict-card-header">
<span class="conflict-card-id">${escapeHtml(conflict.id || 'N/A')}</span>
<span class="conflict-category-tag">${escapeHtml(conflict.category || 'General')}</span>
</div>
<div class="conflict-card-brief">${escapeHtml(conflict.brief || '')}</div>
<div class="conflict-card-strategy">
<span class="strategy-label">Strategy:</span>
<span class="strategy-tag">${escapeHtml(conflict.strategy || 'N/A')}</span>
</div>
</div>
`);
}
cards.push('</div>');
}
return `<div class="conflict-tab-content">${cards.join('')}</div>`;
}

View File

@@ -0,0 +1,54 @@
// Exploration helpers loaded
// Helper: Render exploration field with smart type detection
function renderExpField(label, value) {
if (value === null || value === undefined) return '';
let rendered;
if (typeof value === 'string') {
rendered = `<p>${escapeHtml(value)}</p>`;
} else if (Array.isArray(value)) {
rendered = renderExpArray(value);
} else if (typeof value === 'object') {
rendered = renderExpObject(value);
} else {
rendered = `<p>${escapeHtml(String(value))}</p>`;
}
return `<div class="exp-field"><label>${escapeHtml(label)}</label>${rendered}</div>`;
}
// Helper: Render array values
function renderExpArray(arr) {
if (!arr.length) return '<p>-</p>';
if (typeof arr[0] === 'object' && arr[0] !== null) {
return `<div class="exp-array-objects">${arr.map(item => {
if (item.question) {
return `<div class="clarification-item">
<div class="clarification-question">${escapeHtml(item.question)}</div>
${item.impact ? `<div class="clarification-impact">Impact: ${escapeHtml(item.impact)}</div>` : ''}
${item.priority ? `<span class="priority-badge priority-${item.priority}">${item.priority}</span>` : ''}
</div>`;
}
return `<div class="exp-object-item">${renderExpObject(item)}</div>`;
}).join('')}</div>`;
}
return `<ul class="exp-list">${arr.map(item => `<li>${escapeHtml(String(item))}</li>`).join('')}</ul>`;
}
// Helper: Render object values recursively
function renderExpObject(obj) {
if (!obj || typeof obj !== 'object') return '';
const entries = Object.entries(obj).filter(([k]) => !k.startsWith('_'));
if (!entries.length) return '<p>-</p>';
return `<div class="exp-object">${entries.map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
if (val === null || val === undefined) return '';
if (typeof val === 'string') {
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span> <span class="exp-obj-val">${escapeHtml(val)}</span></div>`;
} else if (Array.isArray(val)) {
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span>${renderExpArray(val)}</div>`;
} else if (typeof val === 'object') {
return `<div class="exp-obj-nested"><span class="exp-obj-key">${escapeHtml(label)}</span>${renderExpObject(val)}</div>`;
}
return `<div class="exp-obj-field"><span class="exp-obj-key">${escapeHtml(label)}:</span> <span class="exp-obj-val">${escapeHtml(String(val))}</span></div>`;
}).join('')}</div>`;
}

View File

@@ -0,0 +1,640 @@
// ==========================================
// Enhanced Review Tab with Multi-Select & Preview
// ==========================================
// Review tab state
let reviewTabState = {
allFindings: [],
filteredFindings: [],
selectedFindings: new Set(),
currentFilters: {
dimension: 'all',
severities: new Set(),
search: ''
},
sortConfig: {
field: 'severity',
order: 'desc'
},
previewFinding: null,
sessionPath: null,
sessionId: null
};
// ==========================================
// Main Review Tab Render
// ==========================================
function renderReviewContent(review) {
if (!review || !review.dimensions) {
return `
<div class="tab-empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-title">No Review Data</div>
<div class="empty-text">No review findings in .review/</div>
</div>
`;
}
// Convert dimensions object to flat findings array
const findings = [];
let findingIndex = 0;
Object.entries(review.dimensions).forEach(([dim, rawFindings]) => {
let dimFindings = [];
if (Array.isArray(rawFindings)) {
dimFindings = rawFindings;
} else if (rawFindings && typeof rawFindings === 'object') {
if (Array.isArray(rawFindings.findings)) {
dimFindings = rawFindings.findings;
}
}
dimFindings.forEach(f => {
findings.push({
id: f.id || `finding-${findingIndex++}`,
title: f.title || 'Finding',
description: f.description || '',
severity: (f.severity || 'medium').toLowerCase(),
dimension: dim,
category: f.category || '',
file: f.file || '',
line: f.line || '',
code_context: f.code_context || f.snippet || '',
recommendations: f.recommendations || (f.recommendation ? [f.recommendation] : []),
root_cause: f.root_cause || '',
impact: f.impact || '',
references: f.references || [],
metadata: f.metadata || {}
});
});
});
if (findings.length === 0) {
return `
<div class="tab-empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-title">No Findings</div>
<div class="empty-text">No review findings found.</div>
</div>
`;
}
// Store findings in state
reviewTabState.allFindings = findings;
reviewTabState.filteredFindings = [...findings];
reviewTabState.selectedFindings.clear();
reviewTabState.previewFinding = null;
// Get dimensions for tabs
const dimensions = [...new Set(findings.map(f => f.dimension))];
// Count by severity
const severityCounts = {
critical: findings.filter(f => f.severity === 'critical').length,
high: findings.filter(f => f.severity === 'high').length,
medium: findings.filter(f => f.severity === 'medium').length,
low: findings.filter(f => f.severity === 'low').length
};
return `
<div class="review-enhanced-container">
<!-- Header with Stats & Controls -->
<div class="review-header-bar">
<div class="review-severity-stats">
<span class="severity-stat critical" onclick="filterReviewBySeverity('critical')" title="Filter Critical">
🔴 ${severityCounts.critical}
</span>
<span class="severity-stat high" onclick="filterReviewBySeverity('high')" title="Filter High">
🟠 ${severityCounts.high}
</span>
<span class="severity-stat medium" onclick="filterReviewBySeverity('medium')" title="Filter Medium">
🟡 ${severityCounts.medium}
</span>
<span class="severity-stat low" onclick="filterReviewBySeverity('low')" title="Filter Low">
🟢 ${severityCounts.low}
</span>
</div>
<div class="review-search-box">
<input type="text"
id="reviewSearchInput"
placeholder="Search findings..."
oninput="onReviewSearch(this.value)">
</div>
<div class="review-selection-controls">
<span class="selection-counter" id="reviewSelectionCounter">0 selected</span>
<button class="btn-mini" onclick="selectAllReviewFindings()">Select All</button>
<button class="btn-mini" onclick="selectVisibleReviewFindings()">Select Visible</button>
<button class="btn-mini" onclick="clearReviewSelection()">Clear</button>
</div>
<button class="btn-export-fix" id="exportFixBtn" onclick="exportReviewFixJson()" disabled>
🔧 Export Fix JSON
</button>
</div>
<!-- Filter Bar -->
<div class="review-filter-bar">
<div class="filter-group">
<span class="filter-label">Severity:</span>
<div class="filter-chips">
<label class="filter-chip" id="filter-critical">
<input type="checkbox" onchange="toggleReviewSeverityFilter('critical')">
<span>Critical</span>
</label>
<label class="filter-chip" id="filter-high">
<input type="checkbox" onchange="toggleReviewSeverityFilter('high')">
<span>High</span>
</label>
<label class="filter-chip" id="filter-medium">
<input type="checkbox" onchange="toggleReviewSeverityFilter('medium')">
<span>Medium</span>
</label>
<label class="filter-chip" id="filter-low">
<input type="checkbox" onchange="toggleReviewSeverityFilter('low')">
<span>Low</span>
</label>
</div>
</div>
<div class="filter-group">
<span class="filter-label">Sort:</span>
<select id="reviewSortSelect" class="sort-select" onchange="sortReviewFindings()">
<option value="severity">By Severity</option>
<option value="dimension">By Dimension</option>
<option value="file">By File</option>
</select>
<button class="btn-sort-order" id="reviewSortOrderBtn" onclick="toggleReviewSortOrder()">
<span id="reviewSortOrderIcon">↓</span>
</button>
</div>
<button class="btn-mini" onclick="resetReviewFilters()">Reset Filters</button>
</div>
<!-- Dimension Tabs -->
<div class="review-dimension-tabs">
<button class="dim-tab active" data-dimension="all" onclick="filterReviewByDimension('all')">
All (${findings.length})
</button>
${dimensions.map(dim => `
<button class="dim-tab" data-dimension="${dim}" onclick="filterReviewByDimension('${dim}')">
${escapeHtml(dim)} (${findings.filter(f => f.dimension === dim).length})
</button>
`).join('')}
</div>
<!-- Split Panel: List + Preview -->
<div class="review-split-panel">
<!-- Left: Findings List -->
<div class="review-findings-panel">
<div class="findings-list-header">
<span id="reviewFindingsCount">${findings.length} findings</span>
</div>
<div class="review-findings-list" id="reviewFindingsList">
${renderReviewFindingsList(findings)}
</div>
</div>
<!-- Right: Preview Panel -->
<div class="review-preview-panel" id="reviewPreviewPanel">
<div class="preview-empty-state">
<div class="preview-icon">👆</div>
<div class="preview-text">Click on a finding to preview details</div>
</div>
</div>
</div>
</div>
`;
}
// ==========================================
// Findings List Rendering
// ==========================================
function renderReviewFindingsList(findings) {
if (findings.length === 0) {
return `
<div class="findings-empty">
<span class="empty-icon">✨</span>
<span>No findings match your filters</span>
</div>
`;
}
return findings.map(finding => `
<div class="review-finding-item ${finding.severity} ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
data-finding-id="${finding.id}"
onclick="previewReviewFinding('${finding.id}')">
<input type="checkbox"
class="finding-checkbox"
${reviewTabState.selectedFindings.has(finding.id) ? 'checked' : ''}
onclick="toggleReviewFindingSelection('${finding.id}', event)">
<div class="finding-content">
<div class="finding-top-row">
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
</div>
<div class="finding-title">${escapeHtml(finding.title)}</div>
${finding.file ? `<div class="finding-file">📄 ${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</div>` : ''}
</div>
</div>
`).join('');
}
// ==========================================
// Preview Panel Rendering
// ==========================================
function previewReviewFinding(findingId) {
const finding = reviewTabState.allFindings.find(f => f.id === findingId);
if (!finding) return;
reviewTabState.previewFinding = finding;
// Update active state in list
document.querySelectorAll('.review-finding-item').forEach(item => {
item.classList.toggle('previewing', item.dataset.findingId === findingId);
});
const previewPanel = document.getElementById('reviewPreviewPanel');
if (!previewPanel) return;
previewPanel.innerHTML = `
<div class="preview-content">
<div class="preview-header">
<div class="preview-badges">
<span class="severity-badge ${finding.severity}">${finding.severity}</span>
<span class="dimension-badge">${escapeHtml(finding.dimension)}</span>
${finding.category ? `<span class="category-badge">${escapeHtml(finding.category)}</span>` : ''}
</div>
<button class="btn-select-finding ${reviewTabState.selectedFindings.has(finding.id) ? 'selected' : ''}"
onclick="toggleReviewFindingSelection('${finding.id}', event)">
${reviewTabState.selectedFindings.has(finding.id) ? '✓ Selected' : '+ Select for Fix'}
</button>
</div>
<h3 class="preview-title">${escapeHtml(finding.title)}</h3>
${finding.file ? `
<div class="preview-section">
<div class="preview-section-title">📄 Location</div>
<div class="preview-location">
<code>${escapeHtml(finding.file)}${finding.line ? ':' + finding.line : ''}</code>
</div>
</div>
` : ''}
<div class="preview-section">
<div class="preview-section-title">📝 Description</div>
<div class="preview-description">${escapeHtml(finding.description)}</div>
</div>
${finding.code_context ? `
<div class="preview-section">
<div class="preview-section-title">💻 Code Context</div>
<pre class="preview-code">${escapeHtml(finding.code_context)}</pre>
</div>
` : ''}
${finding.recommendations && finding.recommendations.length > 0 ? `
<div class="preview-section">
<div class="preview-section-title">✅ Recommendations</div>
<ul class="preview-recommendations">
${finding.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
</ul>
</div>
` : ''}
${finding.root_cause ? `
<div class="preview-section">
<div class="preview-section-title">🔍 Root Cause</div>
<div class="preview-root-cause">${escapeHtml(finding.root_cause)}</div>
</div>
` : ''}
${finding.impact ? `
<div class="preview-section">
<div class="preview-section-title">⚠️ Impact</div>
<div class="preview-impact">${escapeHtml(finding.impact)}</div>
</div>
` : ''}
${finding.references && finding.references.length > 0 ? `
<div class="preview-section">
<div class="preview-section-title">🔗 References</div>
<ul class="preview-references">
${finding.references.map(ref => {
const isUrl = ref.startsWith('http');
return `<li>${isUrl ? `<a href="${ref}" target="_blank">${ref}</a>` : ref}</li>`;
}).join('')}
</ul>
</div>
` : ''}
${finding.metadata && Object.keys(finding.metadata).length > 0 ? `
<div class="preview-section">
<div class="preview-section-title"> Metadata</div>
<div class="preview-metadata">
${Object.entries(finding.metadata).map(([key, value]) => `
<div class="metadata-item">
<span class="meta-key">${escapeHtml(key)}:</span>
<span class="meta-value">${escapeHtml(String(value))}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}
// ==========================================
// Selection Management
// ==========================================
function toggleReviewFindingSelection(findingId, event) {
if (event) {
event.stopPropagation();
}
if (reviewTabState.selectedFindings.has(findingId)) {
reviewTabState.selectedFindings.delete(findingId);
} else {
reviewTabState.selectedFindings.add(findingId);
}
updateReviewSelectionUI();
// Update preview panel button if this finding is being previewed
if (reviewTabState.previewFinding && reviewTabState.previewFinding.id === findingId) {
previewReviewFinding(findingId);
}
}
function selectAllReviewFindings() {
reviewTabState.allFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
updateReviewSelectionUI();
}
function selectVisibleReviewFindings() {
reviewTabState.filteredFindings.forEach(f => reviewTabState.selectedFindings.add(f.id));
updateReviewSelectionUI();
}
function selectReviewBySeverity(severity) {
reviewTabState.allFindings
.filter(f => f.severity === severity)
.forEach(f => reviewTabState.selectedFindings.add(f.id));
updateReviewSelectionUI();
}
function clearReviewSelection() {
reviewTabState.selectedFindings.clear();
updateReviewSelectionUI();
}
function updateReviewSelectionUI() {
// Update counter
const counter = document.getElementById('reviewSelectionCounter');
if (counter) {
counter.textContent = `${reviewTabState.selectedFindings.size} selected`;
}
// Update export button
const exportBtn = document.getElementById('exportFixBtn');
if (exportBtn) {
exportBtn.disabled = reviewTabState.selectedFindings.size === 0;
}
// Update checkbox states in list
document.querySelectorAll('.review-finding-item').forEach(item => {
const findingId = item.dataset.findingId;
const isSelected = reviewTabState.selectedFindings.has(findingId);
item.classList.toggle('selected', isSelected);
const checkbox = item.querySelector('.finding-checkbox');
if (checkbox) {
checkbox.checked = isSelected;
}
});
}
// ==========================================
// Filtering & Sorting
// ==========================================
function filterReviewByDimension(dimension) {
reviewTabState.currentFilters.dimension = dimension;
// Update tab active state
document.querySelectorAll('.dim-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.dimension === dimension);
});
applyReviewFilters();
}
function filterReviewBySeverity(severity) {
// Toggle the severity filter
if (reviewTabState.currentFilters.severities.has(severity)) {
reviewTabState.currentFilters.severities.delete(severity);
} else {
reviewTabState.currentFilters.severities.add(severity);
}
// Update filter chip UI
const filterChip = document.getElementById(`filter-${severity}`);
if (filterChip) {
filterChip.classList.toggle('active', reviewTabState.currentFilters.severities.has(severity));
const checkbox = filterChip.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = reviewTabState.currentFilters.severities.has(severity);
}
}
applyReviewFilters();
}
function toggleReviewSeverityFilter(severity) {
filterReviewBySeverity(severity);
}
function onReviewSearch(searchText) {
reviewTabState.currentFilters.search = searchText.toLowerCase();
applyReviewFilters();
}
function applyReviewFilters() {
reviewTabState.filteredFindings = reviewTabState.allFindings.filter(finding => {
// Dimension filter
if (reviewTabState.currentFilters.dimension !== 'all') {
if (finding.dimension !== reviewTabState.currentFilters.dimension) {
return false;
}
}
// Severity filter (multi-select)
if (reviewTabState.currentFilters.severities.size > 0) {
if (!reviewTabState.currentFilters.severities.has(finding.severity)) {
return false;
}
}
// Search filter
if (reviewTabState.currentFilters.search) {
const searchText = `${finding.title} ${finding.description} ${finding.file} ${finding.category}`.toLowerCase();
if (!searchText.includes(reviewTabState.currentFilters.search)) {
return false;
}
}
return true;
});
sortReviewFindings();
}
function sortReviewFindings() {
const sortBy = document.getElementById('reviewSortSelect')?.value || 'severity';
reviewTabState.sortConfig.field = sortBy;
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
reviewTabState.filteredFindings.sort((a, b) => {
let comparison = 0;
if (sortBy === 'severity') {
comparison = severityOrder[a.severity] - severityOrder[b.severity];
} else if (sortBy === 'dimension') {
comparison = a.dimension.localeCompare(b.dimension);
} else if (sortBy === 'file') {
comparison = (a.file || '').localeCompare(b.file || '');
}
return reviewTabState.sortConfig.order === 'asc' ? comparison : -comparison;
});
renderFilteredReviewFindings();
}
function toggleReviewSortOrder() {
reviewTabState.sortConfig.order = reviewTabState.sortConfig.order === 'asc' ? 'desc' : 'asc';
const icon = document.getElementById('reviewSortOrderIcon');
if (icon) {
icon.textContent = reviewTabState.sortConfig.order === 'asc' ? '↑' : '↓';
}
sortReviewFindings();
}
function resetReviewFilters() {
// Reset state
reviewTabState.currentFilters.dimension = 'all';
reviewTabState.currentFilters.severities.clear();
reviewTabState.currentFilters.search = '';
reviewTabState.sortConfig.field = 'severity';
reviewTabState.sortConfig.order = 'desc';
// Reset UI
document.querySelectorAll('.dim-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.dimension === 'all');
});
document.querySelectorAll('.filter-chip').forEach(chip => {
chip.classList.remove('active');
const checkbox = chip.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = false;
});
const searchInput = document.getElementById('reviewSearchInput');
if (searchInput) searchInput.value = '';
const sortSelect = document.getElementById('reviewSortSelect');
if (sortSelect) sortSelect.value = 'severity';
const sortIcon = document.getElementById('reviewSortOrderIcon');
if (sortIcon) sortIcon.textContent = '↓';
// Re-apply filters
reviewTabState.filteredFindings = [...reviewTabState.allFindings];
sortReviewFindings();
}
function renderFilteredReviewFindings() {
const listContainer = document.getElementById('reviewFindingsList');
const countEl = document.getElementById('reviewFindingsCount');
if (listContainer) {
listContainer.innerHTML = renderReviewFindingsList(reviewTabState.filteredFindings);
}
if (countEl) {
countEl.textContent = `${reviewTabState.filteredFindings.length} findings`;
}
}
// ==========================================
// Export Fix JSON
// ==========================================
function exportReviewFixJson() {
if (reviewTabState.selectedFindings.size === 0) {
showToast('Please select at least one finding to export', 'error');
return;
}
const selectedFindingsData = reviewTabState.allFindings.filter(f =>
reviewTabState.selectedFindings.has(f.id)
);
const session = sessionDataStore[currentSessionDetailKey];
const sessionId = session?.session_id || 'unknown';
const exportId = `fix-${Date.now()}`;
const exportData = {
export_id: exportId,
export_timestamp: new Date().toISOString(),
review_id: `review-${sessionId}`,
session_id: sessionId,
findings_count: selectedFindingsData.length,
findings: selectedFindingsData.map(f => ({
id: f.id,
title: f.title,
description: f.description,
severity: f.severity,
dimension: f.dimension,
category: f.category || 'uncategorized',
file: f.file,
line: f.line,
code_context: f.code_context || null,
recommendations: f.recommendations || [],
root_cause: f.root_cause || null
}))
};
// Convert to JSON and download
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const filename = `fix-export-${exportId}.json`;
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Show success notification
const severityCounts = {
critical: selectedFindingsData.filter(f => f.severity === 'critical').length,
high: selectedFindingsData.filter(f => f.severity === 'high').length,
medium: selectedFindingsData.filter(f => f.severity === 'medium').length,
low: selectedFindingsData.filter(f => f.severity === 'low').length
};
showToast(`Exported ${selectedFindingsData.length} findings for fixing (Critical: ${severityCounts.critical}, High: ${severityCounts.high}, Medium: ${severityCounts.medium}, Low: ${severityCounts.low})`, 'success');
}

View File

@@ -0,0 +1,398 @@
// ==========================================
// CAROUSEL COMPONENT
// ==========================================
// Active session carousel with detailed task info and smooth transitions
let carouselIndex = 0;
let carouselSessions = [];
let carouselInterval = null;
let carouselPaused = false;
const CAROUSEL_INTERVAL_MS = 5000; // 5 seconds
function initCarousel() {
const prevBtn = document.getElementById('carouselPrev');
const nextBtn = document.getElementById('carouselNext');
const pauseBtn = document.getElementById('carouselPause');
if (prevBtn) {
prevBtn.addEventListener('click', () => {
carouselPrev();
resetCarouselInterval();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
carouselNext();
resetCarouselInterval();
});
}
if (pauseBtn) {
pauseBtn.addEventListener('click', toggleCarouselPause);
}
}
function updateCarousel() {
// Get active sessions from workflowData
const previousSessions = carouselSessions;
const previousIndex = carouselIndex;
const previousSessionId = previousSessions[previousIndex]?.session_id;
carouselSessions = workflowData.activeSessions || [];
// Try to preserve current position
if (previousSessionId && carouselSessions.length > 0) {
// Find if the same session still exists
const newIndex = carouselSessions.findIndex(s => s.session_id === previousSessionId);
if (newIndex !== -1) {
carouselIndex = newIndex;
} else if (previousIndex < carouselSessions.length) {
// Keep same index if valid
carouselIndex = previousIndex;
} else {
// Reset to last valid index
carouselIndex = Math.max(0, carouselSessions.length - 1);
}
} else {
carouselIndex = 0;
}
renderCarouselDots();
renderCarouselSlide('none');
startCarouselInterval();
}
function renderCarouselDots() {
const dotsContainer = document.getElementById('carouselDots');
if (!dotsContainer) return;
if (carouselSessions.length === 0) {
dotsContainer.innerHTML = '';
return;
}
dotsContainer.innerHTML = carouselSessions.map((_, index) => `
<button class="carousel-dot w-2 h-2 rounded-full transition-all duration-200 ${index === carouselIndex ? 'bg-primary w-4' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60'}"
onclick="carouselGoToIndex(${index})" title="Session ${index + 1}"></button>
`).join('');
}
function updateActiveDot() {
const dots = document.querySelectorAll('.carousel-dot');
dots.forEach((dot, index) => {
if (index === carouselIndex) {
dot.classList.remove('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
dot.classList.add('bg-primary', 'w-4');
} else {
dot.classList.remove('bg-primary', 'w-4');
dot.classList.add('bg-muted-foreground/40', 'hover:bg-muted-foreground/60', 'w-2');
}
});
}
function carouselGoToIndex(index) {
if (index < 0 || index >= carouselSessions.length) return;
const direction = index > carouselIndex ? 'left' : (index < carouselIndex ? 'right' : 'none');
carouselIndex = index;
renderCarouselSlide(direction);
updateActiveDot();
resetCarouselInterval();
}
function renderCarouselSlide(direction = 'none') {
const container = document.getElementById('carouselContent');
if (!container) return;
if (carouselSessions.length === 0) {
container.innerHTML = `
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<div class="text-3xl mb-2">🎯</div>
<p class="text-sm">No active sessions</p>
</div>
</div>
`;
return;
}
const session = carouselSessions[carouselIndex];
const sessionType = session.type || 'workflow';
// Use simplified view for review sessions
if (sessionType === 'review') {
renderReviewCarouselSlide(container, session, direction);
return;
}
const tasks = session.tasks || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
const taskCount = session.taskCount || tasks.length;
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
// Get session type badge
const typeBadgeClass = getSessionTypeBadgeClass(sessionType);
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Animation class based on direction
const animClass = direction === 'left' ? 'carousel-slide-left' :
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
// Get recent task activity
const recentTasks = getRecentTaskActivity(tasks);
// Format timestamps
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
const updatedTime = session.updated_at ? formatRelativeTime(session.updated_at) : '';
// Get more tasks for display (up to 4)
const displayTasks = getRecentTaskActivity(tasks, 4);
container.innerHTML = `
<div class="carousel-slide ${animClass} h-full">
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
onclick="showSessionDetailPage('${sessionKey}')">
<!-- Two Column Layout -->
<div class="flex gap-4 h-full">
<!-- Left Column: Session Info -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Session Header -->
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">${sessionType}</span>
${inProgress > 0 ? `<span class="inline-flex items-center gap-1 text-xs text-warning"><span class="w-2 h-2 rounded-full bg-warning animate-pulse"></span>${inProgress} running</span>` : ''}
</div>
<h4 class="font-semibold text-foreground text-sm line-clamp-1 mb-2" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
<!-- Progress -->
<div class="mb-2">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">Progress</span>
<span class="text-foreground font-medium">${completed}/${taskCount}</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ${progress === 100 ? 'bg-success' : 'bg-primary'}" style="width: ${progress}%"></div>
</div>
</div>
<!-- Task Status Summary -->
<div class="flex items-center gap-3 text-xs mb-2">
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success"></span>${completed}</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-warning ${inProgress > 0 ? 'animate-pulse' : ''}"></span>${inProgress}</span>
<span class="flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-muted-foreground"></span>${pending}</span>
</div>
<!-- Footer -->
<div class="mt-auto flex items-center gap-3 text-xs text-muted-foreground">
<span>📅 ${createdTime}</span>
${updatedTime && updatedTime !== createdTime ? `<span>🔄 ${updatedTime}</span>` : ''}
</div>
</div>
<!-- Right Column: Task List -->
<div class="w-[45%] flex flex-col border-l border-border pl-3">
<div class="text-xs font-medium text-muted-foreground mb-1.5">Recent Tasks</div>
<div class="task-list flex-1 space-y-1 overflow-hidden">
${displayTasks.length > 0 ? displayTasks.map(task => `
<div class="flex items-center gap-1.5 text-xs">
<span class="shrink-0">${getTaskStatusEmoji(task.status)}</span>
<span class="truncate flex-1 ${task.status === 'in_progress' ? 'text-foreground font-medium' : 'text-muted-foreground'}">${escapeHtml(task.title || task.id || 'Task')}</span>
</div>
`).join('') : `
<div class="text-xs text-muted-foreground">No tasks yet</div>
`}
</div>
<!-- Progress percentage -->
<div class="mt-auto pt-1 text-right">
<span class="text-xl font-bold ${progress === 100 ? 'text-success' : 'text-primary'}">${progress}%</span>
</div>
</div>
</div>
</div>
</div>
`;
// Store session data for navigation
if (!sessionDataStore[sessionKey]) {
sessionDataStore[sessionKey] = session;
}
}
// Simplified carousel slide for review sessions
function renderReviewCarouselSlide(container, session, direction) {
const typeBadgeClass = getSessionTypeBadgeClass('review');
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
const animClass = direction === 'left' ? 'carousel-slide-left' :
direction === 'right' ? 'carousel-slide-right' : 'carousel-fade-in';
const createdTime = session.created_at ? formatRelativeTime(session.created_at) : '';
container.innerHTML = `
<div class="carousel-slide ${animClass} h-full">
<div class="session-card h-full p-3 cursor-pointer hover:bg-hover/30 transition-colors"
onclick="showSessionDetailPage('${sessionKey}')">
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs font-medium rounded ${typeBadgeClass}">review</span>
</div>
<h4 class="font-semibold text-foreground text-sm line-clamp-2 mb-3" title="${escapeHtml(session.session_id)}">${escapeHtml(session.session_id)}</h4>
<!-- Simple info -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="text-3xl mb-1">🔍</div>
<div class="text-xs text-muted-foreground">Click to view findings</div>
</div>
</div>
<!-- Footer -->
<div class="mt-auto text-xs text-muted-foreground">
📅 ${createdTime}
</div>
</div>
</div>
</div>
`;
// Store session data for navigation
if (!sessionDataStore[sessionKey]) {
sessionDataStore[sessionKey] = session;
}
}
function getSessionTypeBadgeClass(type) {
const classes = {
'tdd': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'review': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'test': 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
'docs': 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'workflow': 'bg-primary-light text-primary'
};
return classes[type] || classes['workflow'];
}
function getRecentTaskActivity(tasks, limit = 4) {
if (!tasks || tasks.length === 0) return [];
// Get in_progress tasks first, then most recently updated
const sorted = [...tasks].sort((a, b) => {
// in_progress first
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
// Then by updated_at
const timeA = a.updated_at || a.created_at || '';
const timeB = b.updated_at || b.created_at || '';
return timeB.localeCompare(timeA);
});
// Return top N tasks
return sorted.slice(0, limit);
}
function getTaskStatusEmoji(status) {
const emojis = {
'completed': '✅',
'in_progress': '🔄',
'pending': '⏸️',
'blocked': '🚫'
};
return emojis[status] || '📋';
}
function getTaskStatusIcon(status) {
return status === 'in_progress' ? 'animate-spin-slow' : '';
}
function formatRelativeTime(dateString) {
if (!dateString) return '';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
// Format as date for older
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch (e) {
return dateString;
}
}
function carouselNext() {
if (carouselSessions.length === 0) return;
carouselIndex = (carouselIndex + 1) % carouselSessions.length;
renderCarouselSlide('left');
updateActiveDot();
}
function carouselPrev() {
if (carouselSessions.length === 0) return;
carouselIndex = (carouselIndex - 1 + carouselSessions.length) % carouselSessions.length;
renderCarouselSlide('right');
updateActiveDot();
}
function startCarouselInterval() {
stopCarouselInterval();
if (!carouselPaused && carouselSessions.length > 1) {
carouselInterval = setInterval(carouselNext, CAROUSEL_INTERVAL_MS);
}
}
function stopCarouselInterval() {
if (carouselInterval) {
clearInterval(carouselInterval);
carouselInterval = null;
}
}
function resetCarouselInterval() {
if (!carouselPaused) {
startCarouselInterval();
}
}
function toggleCarouselPause() {
carouselPaused = !carouselPaused;
const icon = document.getElementById('carouselPauseIcon');
if (carouselPaused) {
stopCarouselInterval();
// Change to play icon
if (icon) {
icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
}
} else {
startCarouselInterval();
// Change to pause icon
if (icon) {
icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
}
}
}
// Jump to specific session in carousel
function carouselGoTo(sessionId) {
const index = carouselSessions.findIndex(s => s.session_id === sessionId);
if (index !== -1) {
carouselIndex = index;
renderCarouselSlide('none');
updateActiveDot();
resetCarouselInterval();
}
}

View File

@@ -0,0 +1,493 @@
// ==========================================
// FLOWCHART RENDERING (D3.js)
// ==========================================
function renderFlowchartForTask(sessionId, task) {
// Will render on section expand
}
function renderFlowchart(containerId, steps) {
if (!steps || steps.length === 0) return;
if (typeof d3 === 'undefined') {
document.getElementById(containerId).innerHTML = '<div class="flowchart-fallback">D3.js not loaded</div>';
return;
}
const container = document.getElementById(containerId);
const width = container.clientWidth || 500;
const nodeHeight = 50;
const nodeWidth = Math.min(width - 40, 300);
const padding = 15;
const height = steps.length * (nodeHeight + padding) + padding * 2;
// Clear existing content
container.innerHTML = '';
const svg = d3.select('#' + containerId)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('class', 'flowchart-svg');
// Arrow marker
svg.append('defs').append('marker')
.attr('id', 'arrow-' + containerId)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', 'hsl(var(--border))');
// Draw arrows
for (let i = 0; i < steps.length - 1; i++) {
const y1 = padding + i * (nodeHeight + padding) + nodeHeight;
const y2 = padding + (i + 1) * (nodeHeight + padding);
svg.append('line')
.attr('x1', width / 2)
.attr('y1', y1)
.attr('x2', width / 2)
.attr('y2', y2)
.attr('stroke', 'hsl(var(--border))')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow-' + containerId + ')');
}
// Draw nodes
const nodes = svg.selectAll('.node')
.data(steps)
.enter()
.append('g')
.attr('class', 'flowchart-node')
.attr('transform', (d, i) => `translate(${(width - nodeWidth) / 2}, ${padding + i * (nodeHeight + padding)})`);
// Node rectangles
nodes.append('rect')
.attr('width', nodeWidth)
.attr('height', nodeHeight)
.attr('rx', 6)
.attr('fill', (d, i) => i === 0 ? 'hsl(var(--primary))' : 'hsl(var(--card))')
.attr('stroke', 'hsl(var(--border))')
.attr('stroke-width', 1);
// Step number circle
nodes.append('circle')
.attr('cx', 20)
.attr('cy', nodeHeight / 2)
.attr('r', 12)
.attr('fill', (d, i) => i === 0 ? 'rgba(255,255,255,0.2)' : 'hsl(var(--muted))');
nodes.append('text')
.attr('x', 20)
.attr('y', nodeHeight / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('font-size', '11px')
.attr('fill', (d, i) => i === 0 ? 'white' : 'hsl(var(--muted-foreground))')
.text((d, i) => i + 1);
// Node text (step name)
nodes.append('text')
.attr('x', 45)
.attr('y', nodeHeight / 2)
.attr('dominant-baseline', 'central')
.attr('fill', (d, i) => i === 0 ? 'white' : 'hsl(var(--foreground))')
.attr('font-size', '12px')
.text(d => {
const text = d.step || d.action || 'Step';
return text.length > 35 ? text.substring(0, 32) + '...' : text;
});
}
function renderFullFlowchart(flowControl) {
if (!flowControl) return;
const container = document.getElementById('flowchartContainer');
if (!container) return;
const preAnalysis = Array.isArray(flowControl.pre_analysis) ? flowControl.pre_analysis : [];
const implSteps = Array.isArray(flowControl.implementation_approach) ? flowControl.implementation_approach : [];
if (preAnalysis.length === 0 && implSteps.length === 0) {
container.innerHTML = '<div class="empty-section">No flowchart data available</div>';
return;
}
const width = container.clientWidth || 500;
const nodeHeight = 90;
const nodeWidth = Math.min(width - 40, 420);
const nodeGap = 45;
const sectionGap = 30;
// Calculate total nodes and height
const totalPreNodes = preAnalysis.length;
const totalImplNodes = implSteps.length;
const hasBothSections = totalPreNodes > 0 && totalImplNodes > 0;
const height = (totalPreNodes + totalImplNodes) * (nodeHeight + nodeGap) +
(hasBothSections ? sectionGap + 60 : 0) + 60;
// Clear existing
d3.select('#flowchartContainer').selectAll('*').remove();
const svg = d3.select('#flowchartContainer')
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Add arrow markers
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'arrowhead-pre')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#f59e0b');
defs.append('marker')
.attr('id', 'arrowhead-impl')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', 'hsl(var(--primary))');
let currentY = 20;
// Render Pre-Analysis section
if (totalPreNodes > 0) {
// Section label
svg.append('text')
.attr('x', 20)
.attr('y', currentY)
.attr('fill', '#f59e0b')
.attr('font-weight', 'bold')
.attr('font-size', '13px')
.text('📋 Pre-Analysis Steps');
currentY += 25;
preAnalysis.forEach((step, idx) => {
const x = (width - nodeWidth) / 2;
// Connection line to next node
if (idx < preAnalysis.length - 1) {
svg.append('line')
.attr('x1', width / 2)
.attr('y1', currentY + nodeHeight)
.attr('x2', width / 2)
.attr('y2', currentY + nodeHeight + nodeGap - 10)
.attr('stroke', '#f59e0b')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead-pre)');
}
// Node group
const nodeG = svg.append('g')
.attr('class', 'flowchart-node')
.attr('transform', `translate(${x}, ${currentY})`);
// Node rectangle (pre-analysis style - amber/orange)
nodeG.append('rect')
.attr('width', nodeWidth)
.attr('height', nodeHeight)
.attr('rx', 10)
.attr('fill', 'hsl(var(--card))')
.attr('stroke', '#f59e0b')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,3');
// Step badge
nodeG.append('circle')
.attr('cx', 25)
.attr('cy', 25)
.attr('r', 15)
.attr('fill', '#f59e0b');
nodeG.append('text')
.attr('x', 25)
.attr('y', 30)
.attr('text-anchor', 'middle')
.attr('fill', 'white')
.attr('font-weight', 'bold')
.attr('font-size', '11px')
.text('P' + (idx + 1));
// Step name
const stepName = step.step || step.action || 'Pre-step ' + (idx + 1);
nodeG.append('text')
.attr('x', 50)
.attr('y', 28)
.attr('fill', 'hsl(var(--foreground))')
.attr('font-weight', '600')
.attr('font-size', '13px')
.text(truncateText(stepName, 40));
// Action description
if (step.action && step.action !== stepName) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 52)
.attr('fill', 'hsl(var(--muted-foreground))')
.attr('font-size', '11px')
.text(truncateText(step.action, 50));
}
// Output indicator
if (step.output_to) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 75)
.attr('fill', '#f59e0b')
.attr('font-size', '10px')
.text('→ ' + truncateText(step.output_to, 45));
}
currentY += nodeHeight + nodeGap;
});
}
// Section divider if both sections exist
if (hasBothSections) {
currentY += 10;
svg.append('line')
.attr('x1', 40)
.attr('y1', currentY)
.attr('x2', width - 40)
.attr('y2', currentY)
.attr('stroke', 'hsl(var(--border))')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '4,4');
// Connecting arrow from pre-analysis to implementation
svg.append('line')
.attr('x1', width / 2)
.attr('y1', currentY - nodeGap + 5)
.attr('x2', width / 2)
.attr('y2', currentY + sectionGap - 5)
.attr('stroke', 'hsl(var(--primary))')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead-impl)');
currentY += sectionGap;
}
// Render Implementation section
if (totalImplNodes > 0) {
// Section label
svg.append('text')
.attr('x', 20)
.attr('y', currentY)
.attr('fill', 'hsl(var(--primary))')
.attr('font-weight', 'bold')
.attr('font-size', '13px')
.text('🔧 Implementation Steps');
currentY += 25;
implSteps.forEach((step, idx) => {
const x = (width - nodeWidth) / 2;
// Connection line to next node
if (idx < implSteps.length - 1) {
svg.append('line')
.attr('x1', width / 2)
.attr('y1', currentY + nodeHeight)
.attr('x2', width / 2)
.attr('y2', currentY + nodeHeight + nodeGap - 10)
.attr('stroke', 'hsl(var(--primary))')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead-impl)');
}
// Node group
const nodeG = svg.append('g')
.attr('class', 'flowchart-node')
.attr('transform', `translate(${x}, ${currentY})`);
// Node rectangle (implementation style - blue)
nodeG.append('rect')
.attr('width', nodeWidth)
.attr('height', nodeHeight)
.attr('rx', 10)
.attr('fill', 'hsl(var(--card))')
.attr('stroke', 'hsl(var(--primary))')
.attr('stroke-width', 2);
// Step badge
nodeG.append('circle')
.attr('cx', 25)
.attr('cy', 25)
.attr('r', 15)
.attr('fill', 'hsl(var(--primary))');
nodeG.append('text')
.attr('x', 25)
.attr('y', 30)
.attr('text-anchor', 'middle')
.attr('fill', 'white')
.attr('font-weight', 'bold')
.attr('font-size', '12px')
.text(step.step || idx + 1);
// Step title
nodeG.append('text')
.attr('x', 50)
.attr('y', 28)
.attr('fill', 'hsl(var(--foreground))')
.attr('font-weight', '600')
.attr('font-size', '13px')
.text(truncateText(step.title || 'Step ' + (idx + 1), 40));
// Description
if (step.description) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 52)
.attr('fill', 'hsl(var(--muted-foreground))')
.attr('font-size', '11px')
.text(truncateText(step.description, 50));
}
// Output/depends indicator
if (step.depends_on?.length) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 75)
.attr('fill', 'var(--warning-color)')
.attr('font-size', '10px')
.text('← Depends: ' + step.depends_on.join(', '));
}
currentY += nodeHeight + nodeGap;
});
}
}
// D3.js Vertical Flowchart for Implementation Approach (legacy)
function renderImplementationFlowchart(steps) {
if (!Array.isArray(steps) || steps.length === 0) return;
const container = document.getElementById('flowchartContainer');
if (!container) return;
const width = container.clientWidth || 500;
const nodeHeight = 100;
const nodeWidth = Math.min(width - 40, 400);
const nodeGap = 50;
const height = steps.length * (nodeHeight + nodeGap) + 40;
// Clear existing
d3.select('#flowchartContainer').selectAll('*').remove();
const svg = d3.select('#flowchartContainer')
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Add arrow marker
svg.append('defs').append('marker')
.attr('id', 'arrowhead')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', 'hsl(var(--primary))');
// Draw nodes and connections
steps.forEach((step, idx) => {
const y = idx * (nodeHeight + nodeGap) + 20;
const x = (width - nodeWidth) / 2;
// Connection line to next node
if (idx < steps.length - 1) {
svg.append('line')
.attr('x1', width / 2)
.attr('y1', y + nodeHeight)
.attr('x2', width / 2)
.attr('y2', y + nodeHeight + nodeGap - 10)
.attr('stroke', 'hsl(var(--primary))')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead)');
}
// Node group
const nodeG = svg.append('g')
.attr('class', 'flowchart-node')
.attr('transform', `translate(${x}, ${y})`);
// Node rectangle with gradient
nodeG.append('rect')
.attr('width', nodeWidth)
.attr('height', nodeHeight)
.attr('rx', 10)
.attr('fill', 'hsl(var(--card))')
.attr('stroke', 'hsl(var(--primary))')
.attr('stroke-width', 2)
.attr('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))');
// Step number badge
nodeG.append('circle')
.attr('cx', 25)
.attr('cy', 25)
.attr('r', 15)
.attr('fill', 'hsl(var(--primary))');
nodeG.append('text')
.attr('x', 25)
.attr('y', 30)
.attr('text-anchor', 'middle')
.attr('fill', 'white')
.attr('font-weight', 'bold')
.attr('font-size', '12px')
.text(step.step || idx + 1);
// Step title
nodeG.append('text')
.attr('x', 50)
.attr('y', 30)
.attr('fill', 'hsl(var(--foreground))')
.attr('font-weight', '600')
.attr('font-size', '14px')
.text(truncateText(step.title || 'Step ' + (idx + 1), 35));
// Step description (if available)
if (step.description) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 55)
.attr('fill', 'hsl(var(--muted-foreground))')
.attr('font-size', '12px')
.text(truncateText(step.description, 45));
}
// Output indicator
if (step.output) {
nodeG.append('text')
.attr('x', 15)
.attr('y', 80)
.attr('fill', 'var(--success-color)')
.attr('font-size', '11px')
.text('→ ' + truncateText(step.output, 40));
}
});
}

View File

@@ -0,0 +1,273 @@
// Hook Manager Component
// Manages Claude Code hooks configuration from settings.json
// ========== Hook State ==========
let hookConfig = {
global: { hooks: {} },
project: { hooks: {} }
};
// ========== Hook Templates ==========
const HOOK_TEMPLATES = {
'ccw-notify': {
event: 'PostToolUse',
matcher: 'Write',
command: 'curl',
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook']
},
'log-tool': {
event: 'PostToolUse',
matcher: '',
command: 'bash',
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log']
},
'lint-check': {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done']
},
'git-add': {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done']
}
};
// ========== Initialization ==========
function initHookManager() {
// Initialize Hook navigation
document.querySelectorAll('.nav-item[data-view="hook-manager"]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = 'hook-manager';
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderHookManager();
});
});
}
// ========== Data Loading ==========
async function loadHookConfig() {
try {
const response = await fetch(`/api/hooks?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error('Failed to load hook config');
const data = await response.json();
hookConfig = data;
updateHookBadge();
return data;
} catch (err) {
console.error('Failed to load hook config:', err);
return null;
}
}
async function saveHook(scope, event, hookData) {
try {
const response = await fetch('/api/hooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
scope: scope,
event: event,
hookData: hookData
})
});
if (!response.ok) throw new Error('Failed to save hook');
const result = await response.json();
if (result.success) {
await loadHookConfig();
renderHookManager();
showRefreshToast(`Hook saved successfully`, 'success');
}
return result;
} catch (err) {
console.error('Failed to save hook:', err);
showRefreshToast(`Failed to save hook: ${err.message}`, 'error');
return null;
}
}
async function removeHook(scope, event, hookIndex) {
try {
const response = await fetch('/api/hooks', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
scope: scope,
event: event,
hookIndex: hookIndex
})
});
if (!response.ok) throw new Error('Failed to remove hook');
const result = await response.json();
if (result.success) {
await loadHookConfig();
renderHookManager();
showRefreshToast(`Hook removed successfully`, 'success');
}
return result;
} catch (err) {
console.error('Failed to remove hook:', err);
showRefreshToast(`Failed to remove hook: ${err.message}`, 'error');
return null;
}
}
// ========== Badge Update ==========
function updateHookBadge() {
const badge = document.getElementById('badgeHooks');
if (badge) {
let totalHooks = 0;
// Count global hooks
if (hookConfig.global?.hooks) {
for (const event of Object.keys(hookConfig.global.hooks)) {
const hooks = hookConfig.global.hooks[event];
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
}
}
// Count project hooks
if (hookConfig.project?.hooks) {
for (const event of Object.keys(hookConfig.project.hooks)) {
const hooks = hookConfig.project.hooks[event];
totalHooks += Array.isArray(hooks) ? hooks.length : 1;
}
}
badge.textContent = totalHooks;
}
}
// ========== Hook Modal Functions ==========
let editingHookData = null;
function openHookCreateModal(editData = null) {
const modal = document.getElementById('hookCreateModal');
const title = document.getElementById('hookModalTitle');
if (modal) {
modal.classList.remove('hidden');
editingHookData = editData;
// Set title based on mode
title.textContent = editData ? 'Edit Hook' : 'Create Hook';
// Clear or populate form
if (editData) {
document.getElementById('hookEvent').value = editData.event || '';
document.getElementById('hookMatcher').value = editData.matcher || '';
document.getElementById('hookCommand').value = editData.command || '';
document.getElementById('hookArgs').value = (editData.args || []).join('\n');
// Set scope radio
const scopeRadio = document.querySelector(`input[name="hookScope"][value="${editData.scope || 'project'}"]`);
if (scopeRadio) scopeRadio.checked = true;
} else {
document.getElementById('hookEvent').value = '';
document.getElementById('hookMatcher').value = '';
document.getElementById('hookCommand').value = '';
document.getElementById('hookArgs').value = '';
document.querySelector('input[name="hookScope"][value="project"]').checked = true;
}
// Focus on event select
document.getElementById('hookEvent').focus();
}
}
function closeHookCreateModal() {
const modal = document.getElementById('hookCreateModal');
if (modal) {
modal.classList.add('hidden');
editingHookData = null;
}
}
function applyHookTemplate(templateName) {
const template = HOOK_TEMPLATES[templateName];
if (!template) return;
document.getElementById('hookEvent').value = template.event;
document.getElementById('hookMatcher').value = template.matcher;
document.getElementById('hookCommand').value = template.command;
document.getElementById('hookArgs').value = template.args.join('\n');
}
async function submitHookCreate() {
const event = document.getElementById('hookEvent').value;
const matcher = document.getElementById('hookMatcher').value.trim();
const command = document.getElementById('hookCommand').value.trim();
const argsText = document.getElementById('hookArgs').value.trim();
const scope = document.querySelector('input[name="hookScope"]:checked').value;
// Validate required fields
if (!event) {
showRefreshToast('Hook event is required', 'error');
document.getElementById('hookEvent').focus();
return;
}
if (!command) {
showRefreshToast('Command is required', 'error');
document.getElementById('hookCommand').focus();
return;
}
// Parse args (one per line)
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
// Build hook data
const hookData = {
command: command
};
if (args.length > 0) {
hookData.args = args;
}
if (matcher) {
hookData.matcher = matcher;
}
// If editing, include original index for replacement
if (editingHookData && editingHookData.index !== undefined) {
hookData.replaceIndex = editingHookData.index;
}
// Submit to API
await saveHook(scope, event, hookData);
closeHookCreateModal();
}
// ========== Helpers ==========
function getHookEventDescription(event) {
const descriptions = {
'PreToolUse': 'Runs before a tool is executed',
'PostToolUse': 'Runs after a tool completes',
'Notification': 'Runs when a notification is triggered',
'Stop': 'Runs when the agent stops'
};
return descriptions[event] || event;
}
function getHookEventIcon(event) {
const icons = {
'PreToolUse': '⏳',
'PostToolUse': '✅',
'Notification': '🔔',
'Stop': '🛑'
};
return icons[event] || '🪝';
}

View File

@@ -0,0 +1,506 @@
// MCP Manager Component
// Manages MCP server configuration from .claude.json
// ========== MCP State ==========
let mcpConfig = null;
let mcpAllProjects = {};
let mcpCurrentProjectServers = {};
let mcpCreateMode = 'form'; // 'form' or 'json'
// ========== Initialization ==========
function initMcpManager() {
// Initialize MCP navigation
document.querySelectorAll('.nav-item[data-view="mcp-manager"]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = 'mcp-manager';
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderMcpManager();
});
});
}
// ========== Data Loading ==========
async function loadMcpConfig() {
try {
const response = await fetch('/api/mcp-config');
if (!response.ok) throw new Error('Failed to load MCP config');
const data = await response.json();
mcpConfig = data;
mcpAllProjects = data.projects || {};
// Get current project servers
const currentPath = projectPath.replace(/\//g, '\\');
mcpCurrentProjectServers = mcpAllProjects[currentPath]?.mcpServers || {};
// Update badge count
updateMcpBadge();
return data;
} catch (err) {
console.error('Failed to load MCP config:', err);
return null;
}
}
async function toggleMcpServer(serverName, enable) {
try {
const response = await fetch('/api/mcp-toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName,
enable: enable
})
});
if (!response.ok) throw new Error('Failed to toggle MCP server');
const result = await response.json();
if (result.success) {
// Reload config and re-render
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" ${enable ? 'enabled' : 'disabled'}`, 'success');
}
return result;
} catch (err) {
console.error('Failed to toggle MCP server:', err);
showRefreshToast(`Failed to toggle MCP server: ${err.message}`, 'error');
return null;
}
}
async function copyMcpServerToProject(serverName, serverConfig) {
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to copy MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" added to project`, 'success');
}
return result;
} catch (err) {
console.error('Failed to copy MCP server:', err);
showRefreshToast(`Failed to add MCP server: ${err.message}`, 'error');
return null;
}
}
async function removeMcpServerFromProject(serverName) {
try {
const response = await fetch('/api/mcp-remove-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: serverName
})
});
if (!response.ok) throw new Error('Failed to remove MCP server');
const result = await response.json();
if (result.success) {
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${serverName}" removed from project`, 'success');
}
return result;
} catch (err) {
console.error('Failed to remove MCP server:', err);
showRefreshToast(`Failed to remove MCP server: ${err.message}`, 'error');
return null;
}
}
// ========== Badge Update ==========
function updateMcpBadge() {
const badge = document.getElementById('badgeMcpServers');
if (badge) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
const servers = projectData?.mcpServers || {};
const disabledServers = projectData?.disabledMcpServers || [];
const totalServers = Object.keys(servers).length;
const enabledServers = totalServers - disabledServers.length;
badge.textContent = `${enabledServers}/${totalServers}`;
}
}
// ========== Helpers ==========
function getAllAvailableMcpServers() {
const allServers = {};
// Collect servers from all projects
for (const [path, config] of Object.entries(mcpAllProjects)) {
const servers = config.mcpServers || {};
for (const [name, serverConfig] of Object.entries(servers)) {
if (!allServers[name]) {
allServers[name] = {
config: serverConfig,
usedIn: []
};
}
allServers[name].usedIn.push(path);
}
}
return allServers;
}
function isServerEnabledInCurrentProject(serverName) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
if (!projectData) return false;
const disabledServers = projectData.disabledMcpServers || [];
return !disabledServers.includes(serverName);
}
function isServerInCurrentProject(serverName) {
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath];
if (!projectData) return false;
const servers = projectData.mcpServers || {};
return serverName in servers;
}
// ========== MCP Create Modal ==========
function openMcpCreateModal() {
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.remove('hidden');
// Reset to form mode
mcpCreateMode = 'form';
switchMcpCreateTab('form');
// Clear form
document.getElementById('mcpServerName').value = '';
document.getElementById('mcpServerCommand').value = '';
document.getElementById('mcpServerArgs').value = '';
document.getElementById('mcpServerEnv').value = '';
// Clear JSON input
document.getElementById('mcpServerJson').value = '';
document.getElementById('mcpJsonPreview').classList.add('hidden');
// Focus on name input
document.getElementById('mcpServerName').focus();
// Setup JSON input listener
setupMcpJsonListener();
}
}
function closeMcpCreateModal() {
const modal = document.getElementById('mcpCreateModal');
if (modal) {
modal.classList.add('hidden');
}
}
function switchMcpCreateTab(tab) {
mcpCreateMode = tab;
const formMode = document.getElementById('mcpFormMode');
const jsonMode = document.getElementById('mcpJsonMode');
const tabForm = document.getElementById('mcpTabForm');
const tabJson = document.getElementById('mcpTabJson');
if (tab === 'form') {
formMode.classList.remove('hidden');
jsonMode.classList.add('hidden');
tabForm.classList.add('active');
tabJson.classList.remove('active');
} else {
formMode.classList.add('hidden');
jsonMode.classList.remove('hidden');
tabForm.classList.remove('active');
tabJson.classList.add('active');
}
}
function setupMcpJsonListener() {
const jsonInput = document.getElementById('mcpServerJson');
if (jsonInput && !jsonInput.hasAttribute('data-listener-attached')) {
jsonInput.setAttribute('data-listener-attached', 'true');
jsonInput.addEventListener('input', () => {
updateMcpJsonPreview();
});
}
}
function parseMcpJsonConfig(jsonText) {
if (!jsonText.trim()) {
return { servers: {}, error: null };
}
try {
const parsed = JSON.parse(jsonText);
let servers = {};
// Support multiple formats:
// 1. {"servers": {...}} format (claude desktop style)
// 2. {"mcpServers": {...}} format (claude.json style)
// 3. {"serverName": {command, args}} format (direct server config)
// 4. {command, args} format (single server without name)
if (parsed.servers && typeof parsed.servers === 'object') {
servers = parsed.servers;
} else if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
servers = parsed.mcpServers;
} else if (parsed.command && typeof parsed.command === 'string') {
// Single server without name - will prompt for name
servers = { '__unnamed__': parsed };
} else {
// Check if all values are server configs (have 'command' property)
const isDirectServerConfig = Object.values(parsed).every(
v => v && typeof v === 'object' && v.command
);
if (isDirectServerConfig && Object.keys(parsed).length > 0) {
servers = parsed;
} else {
return { servers: {}, error: 'Invalid MCP server JSON format' };
}
}
// Validate each server config
for (const [name, config] of Object.entries(servers)) {
if (!config.command || typeof config.command !== 'string') {
return { servers: {}, error: `Server "${name}" missing required "command" field` };
}
if (config.args && !Array.isArray(config.args)) {
return { servers: {}, error: `Server "${name}" has invalid "args" (must be array)` };
}
if (config.env && typeof config.env !== 'object') {
return { servers: {}, error: `Server "${name}" has invalid "env" (must be object)` };
}
}
return { servers, error: null };
} catch (e) {
return { servers: {}, error: 'Invalid JSON: ' + e.message };
}
}
function updateMcpJsonPreview() {
const jsonInput = document.getElementById('mcpServerJson');
const previewContainer = document.getElementById('mcpJsonPreview');
const previewContent = document.getElementById('mcpJsonPreviewContent');
const jsonText = jsonInput.value;
const { servers, error } = parseMcpJsonConfig(jsonText);
if (!jsonText.trim()) {
previewContainer.classList.add('hidden');
return;
}
previewContainer.classList.remove('hidden');
if (error) {
previewContent.innerHTML = `<div class="text-destructive">${escapeHtml(error)}</div>`;
return;
}
const serverCount = Object.keys(servers).length;
if (serverCount === 0) {
previewContent.innerHTML = `<div class="text-muted-foreground">No servers found</div>`;
return;
}
const previewHtml = Object.entries(servers).map(([name, config]) => {
const displayName = name === '__unnamed__' ? '(will prompt for name)' : name;
const argsPreview = config.args ? config.args.slice(0, 2).join(' ') + (config.args.length > 2 ? '...' : '') : '';
return `
<div class="flex items-center gap-2 p-2 bg-background rounded">
<span class="text-success">+</span>
<span class="font-medium">${escapeHtml(displayName)}</span>
<span class="text-muted-foreground text-xs">${escapeHtml(config.command)} ${escapeHtml(argsPreview)}</span>
</div>
`;
}).join('');
previewContent.innerHTML = previewHtml;
}
async function submitMcpCreate() {
if (mcpCreateMode === 'json') {
await submitMcpCreateFromJson();
} else {
await submitMcpCreateFromForm();
}
}
async function submitMcpCreateFromForm() {
const name = document.getElementById('mcpServerName').value.trim();
const command = document.getElementById('mcpServerCommand').value.trim();
const argsText = document.getElementById('mcpServerArgs').value.trim();
const envText = document.getElementById('mcpServerEnv').value.trim();
// Validate required fields
if (!name) {
showRefreshToast('Server name is required', 'error');
document.getElementById('mcpServerName').focus();
return;
}
if (!command) {
showRefreshToast('Command is required', 'error');
document.getElementById('mcpServerCommand').focus();
return;
}
// Parse args (one per line)
const args = argsText ? argsText.split('\n').map(a => a.trim()).filter(a => a) : [];
// Parse env vars (KEY=VALUE per line)
const env = {};
if (envText) {
envText.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && trimmed.includes('=')) {
const eqIndex = trimmed.indexOf('=');
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (key) {
env[key] = value;
}
}
});
}
// Build server config
const serverConfig = {
command: command,
args: args
};
// Only add env if there are values
if (Object.keys(env).length > 0) {
serverConfig.env = env;
}
await createMcpServerWithConfig(name, serverConfig);
}
async function submitMcpCreateFromJson() {
const jsonText = document.getElementById('mcpServerJson').value.trim();
if (!jsonText) {
showRefreshToast('Please enter JSON configuration', 'error');
document.getElementById('mcpServerJson').focus();
return;
}
const { servers, error } = parseMcpJsonConfig(jsonText);
if (error) {
showRefreshToast(error, 'error');
return;
}
if (Object.keys(servers).length === 0) {
showRefreshToast('No valid servers found in JSON', 'error');
return;
}
// Handle unnamed server case
if (servers['__unnamed__']) {
const serverName = prompt('Enter a name for this MCP server:');
if (!serverName || !serverName.trim()) {
showRefreshToast('Server name is required', 'error');
return;
}
servers[serverName.trim()] = servers['__unnamed__'];
delete servers['__unnamed__'];
}
// Add all servers
let successCount = 0;
let failCount = 0;
const serverNames = Object.keys(servers);
for (const [name, config] of Object.entries(servers)) {
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: config
})
});
if (!response.ok) throw new Error('Failed to create MCP server');
const result = await response.json();
if (result.success) {
successCount++;
} else {
failCount++;
}
} catch (err) {
console.error(`Failed to create MCP server "${name}":`, err);
failCount++;
}
}
closeMcpCreateModal();
await loadMcpConfig();
renderMcpManager();
if (failCount === 0) {
showRefreshToast(`${successCount} MCP server${successCount > 1 ? 's' : ''} created successfully`, 'success');
} else if (successCount > 0) {
showRefreshToast(`${successCount} created, ${failCount} failed`, 'warning');
} else {
showRefreshToast('Failed to create MCP servers', 'error');
}
}
async function createMcpServerWithConfig(name, serverConfig) {
// Submit to API
try {
const response = await fetch('/api/mcp-copy-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectPath: projectPath,
serverName: name,
serverConfig: serverConfig
})
});
if (!response.ok) throw new Error('Failed to create MCP server');
const result = await response.json();
if (result.success) {
closeMcpCreateModal();
await loadMcpConfig();
renderMcpManager();
showRefreshToast(`MCP server "${name}" created successfully`, 'success');
} else {
showRefreshToast(result.error || 'Failed to create MCP server', 'error');
}
} catch (err) {
console.error('Failed to create MCP server:', err);
showRefreshToast(`Failed to create MCP server: ${err.message}`, 'error');
}
}

View File

@@ -0,0 +1,260 @@
// ==========================================
// MODAL DIALOGS
// ==========================================
// SVG Icons
const icons = {
folder: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>',
check: '<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"></polyline></svg>',
copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>',
terminal: '<svg viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>'
};
function showPathSelectedModal(dirName, dirHandle) {
// Try to guess full path based on current project path
const currentPath = projectPath || '';
const basePath = currentPath.substring(0, currentPath.lastIndexOf('/')) || 'D:/projects';
const suggestedPath = basePath + '/' + dirName;
const modal = document.createElement('div');
modal.className = 'path-modal-overlay';
modal.innerHTML = `
<div class="path-modal">
<div class="path-modal-header">
<span class="path-modal-icon">${icons.folder}</span>
<h3>Folder Selected</h3>
</div>
<div class="path-modal-body">
<div class="selected-folder">
<strong>${dirName}</strong>
</div>
<p class="path-modal-note">
Confirm or edit the full path:
</p>
<div class="path-input-group" style="margin-top: 12px;">
<label>Full path:</label>
<input type="text" id="fullPathInput" value="${suggestedPath}" />
<button class="path-go-btn" id="pathGoBtn">Open</button>
</div>
</div>
<div class="path-modal-footer">
<button class="path-modal-close" id="pathCancelBtn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listeners (use arrow functions to ensure proper scope)
document.getElementById('pathGoBtn').addEventListener('click', () => {
console.log('Open button clicked');
goToPath();
});
document.getElementById('pathCancelBtn').addEventListener('click', () => closePathModal());
// Focus input, select all text, and add enter key listener
setTimeout(() => {
const input = document.getElementById('fullPathInput');
input?.focus();
input?.select();
input?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') goToPath();
});
}, 100);
}
function showPathInputModal() {
const modal = document.createElement('div');
modal.className = 'path-modal-overlay';
modal.innerHTML = `
<div class="path-modal">
<div class="path-modal-header">
<span class="path-modal-icon">${icons.folder}</span>
<h3>Open Project</h3>
</div>
<div class="path-modal-body">
<div class="path-input-group" style="margin-top: 0;">
<label>Project path:</label>
<input type="text" id="fullPathInput" placeholder="D:/projects/my-project" />
<button class="path-go-btn" id="pathGoBtn">Open</button>
</div>
</div>
<div class="path-modal-footer">
<button class="path-modal-close" id="pathCancelBtn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listeners (use arrow functions to ensure proper scope)
document.getElementById('pathGoBtn').addEventListener('click', () => {
console.log('Open button clicked');
goToPath();
});
document.getElementById('pathCancelBtn').addEventListener('click', () => closePathModal());
// Focus input and add enter key listener
setTimeout(() => {
const input = document.getElementById('fullPathInput');
input?.focus();
input?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') goToPath();
});
}, 100);
}
function goToPath() {
const input = document.getElementById('fullPathInput');
const path = input?.value?.trim();
if (path) {
closePathModal();
selectPath(path);
} else {
// Show error - input is empty
input.style.borderColor = 'var(--danger-color)';
input.placeholder = 'Please enter a path';
input.focus();
}
}
function closePathModal() {
const modal = document.querySelector('.path-modal-overlay');
if (modal) {
modal.remove();
}
}
function copyCommand(btn, dirName) {
const input = document.getElementById('fullPathInput');
const path = input?.value?.trim() || `[full-path-to-${dirName}]`;
const command = `ccw view -p "${path}"`;
navigator.clipboard.writeText(command).then(() => {
btn.innerHTML = icons.check + ' <span>Copied!</span>';
setTimeout(() => { btn.innerHTML = icons.copy + ' <span>Copy</span>'; }, 2000);
});
}
function showJsonModal(jsonId, taskId) {
// Get JSON from memory store instead of DOM
const rawTask = taskJsonStore[jsonId];
if (!rawTask) return;
const jsonContent = JSON.stringify(rawTask, null, 2);
// Create modal
const overlay = document.createElement('div');
overlay.className = 'json-modal-overlay';
overlay.innerHTML = `
<div class="json-modal">
<div class="json-modal-header">
<div class="json-modal-title">
<span class="task-id-badge">${escapeHtml(taskId)}</span>
<span>Task JSON</span>
</div>
<button class="json-modal-close" onclick="closeJsonModal(this)">&times;</button>
</div>
<div class="json-modal-body">
<pre class="json-modal-content">${escapeHtml(jsonContent)}</pre>
</div>
<div class="json-modal-footer">
<button class="btn-copy-json" onclick="copyJsonToClipboard(this)">Copy JSON</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Trigger animation
requestAnimationFrame(() => overlay.classList.add('active'));
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeJsonModal(overlay.querySelector('.json-modal-close'));
});
// Close on Escape key
const escHandler = (e) => {
if (e.key === 'Escape') {
closeJsonModal(overlay.querySelector('.json-modal-close'));
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
function closeJsonModal(btn) {
const overlay = btn.closest('.json-modal-overlay');
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
}
function copyJsonToClipboard(btn) {
const content = btn.closest('.json-modal').querySelector('.json-modal-content').textContent;
navigator.clipboard.writeText(content).then(() => {
const original = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = original, 2000);
});
}
function openMarkdownModal(title, content, type = 'markdown') {
const modal = document.getElementById('markdownModal');
const titleEl = document.getElementById('markdownModalTitle');
const rawEl = document.getElementById('markdownRaw');
const previewEl = document.getElementById('markdownPreview');
// Normalize line endings
const normalizedContent = normalizeLineEndings(content);
titleEl.textContent = title;
rawEl.textContent = normalizedContent;
// Render preview based on type
if (typeof marked !== 'undefined' && type === 'markdown') {
previewEl.innerHTML = marked.parse(normalizedContent);
} else if (type === 'json') {
// For JSON, try to parse and re-stringify with formatting
try {
const parsed = typeof normalizedContent === 'string' ? JSON.parse(normalizedContent) : normalizedContent;
const formatted = JSON.stringify(parsed, null, 2);
previewEl.innerHTML = '<pre class="whitespace-pre-wrap language-json">' + escapeHtml(formatted) + '</pre>';
} catch (e) {
// If not valid JSON, show as-is
previewEl.innerHTML = '<pre class="whitespace-pre-wrap">' + escapeHtml(normalizedContent) + '</pre>';
}
} else {
// Fallback: simple text with line breaks
previewEl.innerHTML = '<pre class="whitespace-pre-wrap">' + escapeHtml(normalizedContent) + '</pre>';
}
// Show modal and default to preview tab
modal.classList.remove('hidden');
switchMarkdownTab('preview');
}
function closeMarkdownModal() {
const modal = document.getElementById('markdownModal');
modal.classList.add('hidden');
}
function switchMarkdownTab(tab) {
const rawEl = document.getElementById('markdownRaw');
const previewEl = document.getElementById('markdownPreview');
const rawTabBtn = document.getElementById('mdTabRaw');
const previewTabBtn = document.getElementById('mdTabPreview');
if (tab === 'raw') {
rawEl.classList.remove('hidden');
previewEl.classList.add('hidden');
rawTabBtn.classList.add('active', 'bg-background', 'text-foreground');
rawTabBtn.classList.remove('text-muted-foreground');
previewTabBtn.classList.remove('active', 'bg-background', 'text-foreground');
previewTabBtn.classList.add('text-muted-foreground');
} else {
rawEl.classList.add('hidden');
previewEl.classList.remove('hidden');
previewTabBtn.classList.add('active', 'bg-background', 'text-foreground');
previewTabBtn.classList.remove('text-muted-foreground');
rawTabBtn.classList.remove('active', 'bg-background', 'text-foreground');
rawTabBtn.classList.add('text-muted-foreground');
}
}

View File

@@ -0,0 +1,239 @@
// Navigation and Routing
// Manages navigation events, active state, content title updates, search, and path selector
// Path Selector
function initPathSelector() {
const btn = document.getElementById('pathButton');
const menu = document.getElementById('pathMenu');
const recentContainer = document.getElementById('recentPaths');
// Render recent paths
if (recentPaths && recentPaths.length > 0) {
recentPaths.forEach(path => {
const item = document.createElement('div');
item.className = 'path-item' + (path === projectPath ? ' active' : '');
item.dataset.path = path;
// Path text
const pathText = document.createElement('span');
pathText.className = 'path-text';
pathText.textContent = path;
pathText.addEventListener('click', () => selectPath(path));
item.appendChild(pathText);
// Delete button (only for non-current paths)
if (path !== projectPath) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'path-delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.title = 'Remove from recent';
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await removeRecentPathFromList(path);
});
item.appendChild(deleteBtn);
}
recentContainer.appendChild(item);
});
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => {
menu.classList.add('hidden');
});
document.getElementById('browsePath').addEventListener('click', async () => {
await browseForFolder();
});
}
// Navigation
function initNavigation() {
document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentFilter = item.dataset.filter;
currentLiteType = null;
currentView = 'sessions';
currentSessionDetailKey = null;
updateContentTitle();
showStatsAndSearch();
renderSessions();
});
});
// Lite Tasks Navigation
document.querySelectorAll('.nav-item[data-lite]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentLiteType = item.dataset.lite;
currentFilter = null;
currentView = 'liteTasks';
currentSessionDetailKey = null;
updateContentTitle();
showStatsAndSearch();
renderLiteTasks();
});
});
// View Navigation (Project Overview, MCP Manager, etc.)
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = item.dataset.view;
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
// Route to appropriate view
if (currentView === 'mcp-manager') {
renderMcpManager();
} else if (currentView === 'project-overview') {
renderProjectOverview();
}
});
});
}
function setActiveNavItem(item) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
}
function updateContentTitle() {
const titleEl = document.getElementById('contentTitle');
if (currentView === 'project-overview') {
titleEl.textContent = 'Project Overview';
} else if (currentView === 'mcp-manager') {
titleEl.textContent = 'MCP Server Management';
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': 'Lite Plan Sessions', 'lite-fix': 'Lite Fix Sessions' };
titleEl.textContent = names[currentLiteType] || 'Lite Tasks';
} else if (currentView === 'sessionDetail') {
titleEl.textContent = 'Session Detail';
} else if (currentView === 'liteTaskDetail') {
titleEl.textContent = 'Lite Task Detail';
} else {
const names = { 'all': 'All Sessions', 'active': 'Active Sessions', 'archived': 'Archived Sessions' };
titleEl.textContent = names[currentFilter] || 'Sessions';
}
}
// Search
function initSearch() {
const input = document.getElementById('searchInput');
input.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
document.querySelectorAll('.session-card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(query) ? '' : 'none';
});
});
}
// Refresh Workspace
function initRefreshButton() {
const btn = document.getElementById('refreshWorkspace');
if (btn) {
btn.addEventListener('click', refreshWorkspace);
}
}
async function refreshWorkspace() {
const btn = document.getElementById('refreshWorkspace');
// Add spinning animation
btn.classList.add('refreshing');
btn.disabled = true;
try {
if (window.SERVER_MODE) {
// Reload data from server
const data = await loadDashboardData(projectPath);
if (data) {
// Update stores
sessionDataStore = {};
liteTaskDataStore = {};
// Populate stores
[...(data.activeSessions || []), ...(data.archivedSessions || [])].forEach(s => {
sessionDataStore[s.session_id] = s;
});
[...(data.liteTasks?.litePlan || []), ...(data.liteTasks?.liteFix || [])].forEach(s => {
liteTaskDataStore[s.session_id] = s;
});
// Update global data
window.workflowData = data;
// Update sidebar counts
updateSidebarCounts(data);
// Re-render current view
if (currentView === 'sessions') {
renderSessions();
} else if (currentView === 'liteTasks') {
renderLiteTasks();
} else if (currentView === 'sessionDetail' && currentSessionDetailKey) {
showSessionDetailPage(currentSessionDetailKey);
} else if (currentView === 'liteTaskDetail' && currentSessionDetailKey) {
showLiteTaskDetailPage(currentSessionDetailKey);
} else if (currentView === 'project-overview') {
renderProjectOverview();
}
showRefreshToast('Workspace refreshed', 'success');
}
} else {
// Non-server mode: just reload page
window.location.reload();
}
} catch (error) {
console.error('Refresh failed:', error);
showRefreshToast('Refresh failed: ' + error.message, 'error');
} finally {
btn.classList.remove('refreshing');
btn.disabled = false;
}
}
function updateSidebarCounts(data) {
// Update session counts
const activeCount = document.querySelector('.nav-item[data-filter="active"] .nav-count');
const archivedCount = document.querySelector('.nav-item[data-filter="archived"] .nav-count');
const allCount = document.querySelector('.nav-item[data-filter="all"] .nav-count');
if (activeCount) activeCount.textContent = data.activeSessions?.length || 0;
if (archivedCount) archivedCount.textContent = data.archivedSessions?.length || 0;
if (allCount) allCount.textContent = (data.activeSessions?.length || 0) + (data.archivedSessions?.length || 0);
// Update lite task counts
const litePlanCount = document.querySelector('.nav-item[data-lite="lite-plan"] .nav-count');
const liteFixCount = document.querySelector('.nav-item[data-lite="lite-fix"] .nav-count');
if (litePlanCount) litePlanCount.textContent = data.liteTasks?.litePlan?.length || 0;
if (liteFixCount) liteFixCount.textContent = data.liteTasks?.liteFix?.length || 0;
}
function showRefreshToast(message, type) {
// Remove existing toast
const existing = document.querySelector('.status-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `status-toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, 2000);
}

View File

@@ -0,0 +1,194 @@
// ==========================================
// NOTIFICATIONS COMPONENT
// ==========================================
// Real-time silent refresh (no notification bubbles)
let wsConnection = null;
let autoRefreshInterval = null;
let lastDataHash = null;
const AUTO_REFRESH_INTERVAL_MS = 30000; // 30 seconds
// ========== WebSocket Connection ==========
function initWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
wsConnection = new WebSocket(wsUrl);
wsConnection.onopen = () => {
console.log('[WS] Connected');
};
wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleNotification(data);
} catch (e) {
console.error('[WS] Failed to parse message:', e);
}
};
wsConnection.onclose = () => {
console.log('[WS] Disconnected, reconnecting in 5s...');
setTimeout(initWebSocket, 5000);
};
wsConnection.onerror = (error) => {
console.error('[WS] Error:', error);
};
} catch (e) {
console.log('[WS] WebSocket not available, using polling');
}
}
// ========== Notification Handler ==========
function handleNotification(data) {
const { type, payload } = data;
// Silent refresh - no notification bubbles
switch (type) {
case 'session_updated':
case 'summary_written':
case 'task_completed':
case 'new_session':
// Just refresh data silently
refreshIfNeeded();
// Optionally highlight in carousel if it's the current session
if (payload.sessionId && typeof carouselGoTo === 'function') {
carouselGoTo(payload.sessionId);
}
break;
default:
console.log('[WS] Unknown notification type:', type);
}
}
// ========== Auto Refresh ==========
function initAutoRefresh() {
// Calculate initial hash
lastDataHash = calculateDataHash();
// Start polling interval
autoRefreshInterval = setInterval(checkForChanges, AUTO_REFRESH_INTERVAL_MS);
}
function calculateDataHash() {
if (!workflowData) return null;
// Simple hash based on key data points
const hashData = {
activeSessions: (workflowData.activeSessions || []).length,
archivedSessions: (workflowData.archivedSessions || []).length,
totalTasks: workflowData.statistics?.totalTasks || 0,
completedTasks: workflowData.statistics?.completedTasks || 0,
generatedAt: workflowData.generatedAt
};
return JSON.stringify(hashData);
}
async function checkForChanges() {
if (!window.SERVER_MODE) return;
try {
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) return;
const newData = await response.json();
const newHash = JSON.stringify({
activeSessions: (newData.activeSessions || []).length,
archivedSessions: (newData.archivedSessions || []).length,
totalTasks: newData.statistics?.totalTasks || 0,
completedTasks: newData.statistics?.completedTasks || 0,
generatedAt: newData.generatedAt
});
if (newHash !== lastDataHash) {
lastDataHash = newHash;
// Silent refresh - no notification
await refreshWorkspaceData(newData);
}
} catch (e) {
console.error('[AutoRefresh] Check failed:', e);
}
}
async function refreshIfNeeded() {
if (!window.SERVER_MODE) return;
try {
const response = await fetch(`/api/data?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) return;
const newData = await response.json();
await refreshWorkspaceData(newData);
} catch (e) {
console.error('[Refresh] Failed:', e);
}
}
async function refreshWorkspaceData(newData) {
// Update global data
window.workflowData = newData;
// Clear and repopulate stores
Object.keys(sessionDataStore).forEach(k => delete sessionDataStore[k]);
Object.keys(liteTaskDataStore).forEach(k => delete liteTaskDataStore[k]);
[...(newData.activeSessions || []), ...(newData.archivedSessions || [])].forEach(s => {
const key = `session-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
sessionDataStore[key] = s;
});
[...(newData.liteTasks?.litePlan || []), ...(newData.liteTasks?.liteFix || [])].forEach(s => {
const key = `lite-${s.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
liteTaskDataStore[key] = s;
});
// Update UI silently
updateStats();
updateBadges();
updateCarousel();
// Re-render current view if needed
if (currentView === 'sessions') {
renderSessions();
} else if (currentView === 'liteTasks') {
renderLiteTasks();
}
lastDataHash = calculateDataHash();
}
// ========== Cleanup ==========
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
function closeWebSocket() {
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
// ========== Navigation Helper ==========
function goToSession(sessionId) {
// Find session in carousel and navigate
const sessionKey = `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Jump to session in carousel if visible
if (typeof carouselGoTo === 'function') {
carouselGoTo(sessionId);
}
// Navigate to session detail
if (sessionDataStore[sessionKey]) {
showSessionDetailPage(sessionKey);
}
}

View File

@@ -0,0 +1,31 @@
// ==========================================
// SIDEBAR MANAGEMENT
// ==========================================
function initSidebar() {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('sidebarToggle');
const menuToggle = document.getElementById('menuToggle');
const overlay = document.getElementById('sidebarOverlay');
// Restore collapsed state
if (localStorage.getItem('sidebarCollapsed') === 'true') {
sidebar.classList.add('collapsed');
}
toggle.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
});
// Mobile menu
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
overlay.classList.toggle('open');
});
overlay.addEventListener('click', () => {
sidebar.classList.remove('open');
overlay.classList.remove('open');
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
// ==========================================
// Tab Content Renderers - Other Tabs
// ==========================================
// Functions for rendering Summary, IMPL Plan, Review, and Lite Context tabs
// ==========================================
// Summary Tab Rendering
// ==========================================
function renderSummaryContent(summaries) {
if (!summaries || summaries.length === 0) {
return `
<div class="tab-empty-state">
<div class="empty-icon">📝</div>
<div class="empty-title">No Summaries</div>
<div class="empty-text">No summaries found in .summaries/</div>
</div>
`;
}
// Store summaries in global variable for modal access
window._currentSummaries = summaries;
return `
<div class="summary-tab-content space-y-4">
${summaries.map((s, idx) => {
const normalizedContent = normalizeLineEndings(s.content || '');
// Extract first 3 lines for preview
const previewLines = normalizedContent.split('\n').slice(0, 3).join('\n');
const hasMore = normalizedContent.split('\n').length > 3;
return `
<div class="summary-item-card">
<div class="summary-item-header">
<h4 class="summary-item-title">📄 ${escapeHtml(s.name || 'Summary')}</h4>
<button class="btn-view-modal" onclick="openMarkdownModal('${escapeHtml(s.name || 'Summary')}', window._currentSummaries[${idx}].content, 'markdown');">
👁️ View
</button>
</div>
<div class="summary-item-preview">
<pre class="summary-preview-text">${escapeHtml(previewLines)}${hasMore ? '\n...' : ''}</pre>
</div>
</div>
`;
}).join('')}
</div>
`;
}
// ==========================================
// IMPL Plan Tab Rendering
// ==========================================
function renderImplPlanContent(implPlan) {
if (!implPlan) {
return `
<div class="tab-empty-state">
<div class="empty-icon">📐</div>
<div class="empty-title">No IMPL Plan</div>
<div class="empty-text">No IMPL_PLAN.md found for this session.</div>
</div>
`;
}
// Normalize and store in global variable for modal access
const normalizedContent = normalizeLineEndings(implPlan);
window._currentImplPlan = normalizedContent;
// Extract first 5 lines for preview
const previewLines = normalizedContent.split('\n').slice(0, 5).join('\n');
const hasMore = normalizedContent.split('\n').length > 5;
return `
<div class="impl-plan-tab-content">
<div class="impl-plan-card">
<div class="impl-plan-header">
<h3 class="impl-plan-title">📐 Implementation Plan</h3>
<button class="btn-view-modal" onclick="openMarkdownModal('IMPL_PLAN.md', window._currentImplPlan, 'markdown')">
👁️ View
</button>
</div>
<div class="impl-plan-preview">
<pre class="impl-plan-preview-text">${escapeHtml(previewLines)}${hasMore ? '\n...' : ''}</pre>
</div>
</div>
</div>
`;
}
// ==========================================
// Review Tab Rendering
// ==========================================
// NOTE: Enhanced review tab with multi-select, filtering, and preview panel
// is now in _review_tab.js - renderReviewContent() function defined there
// ==========================================
// Lite Context Tab Rendering
// ==========================================
function renderLiteContextContent(context, explorations, session) {
const plan = session.plan || {};
let sections = [];
// Render explorations if available (from exploration-*.json files)
if (explorations && explorations.manifest) {
sections.push(renderExplorationContext(explorations));
}
// If we have context from context-package.json
if (context) {
sections.push(`
<div class="context-package-section">
<div class="collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">Context Package</span>
</div>
<div class="collapsible-content collapsed">
<pre class="json-content">${escapeHtml(JSON.stringify(context, null, 2))}</pre>
</div>
</div>
</div>
`);
}
// Fallback: show context from plan
if (plan.focus_paths?.length || plan.summary) {
sections.push(`
<div class="plan-context-section">
${plan.summary ? `
<div class="context-section">
<h4>Summary</h4>
<p>${escapeHtml(plan.summary)}</p>
</div>
` : ''}
${plan.focus_paths?.length ? `
<div class="context-section">
<h4>Focus Paths</h4>
<div class="path-tags">
${plan.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}
</div>
</div>
` : ''}
</div>
`);
}
// If we have any sections, wrap them
if (sections.length > 0) {
return `<div class="context-tab-content">${sections.join('')}</div>`;
}
return `
<div class="tab-empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">No Context Data</div>
<div class="empty-text">No context-package.json or exploration files found for this session.</div>
</div>
`;
}
// ==========================================
// Exploration Context Rendering
// ==========================================
function renderExplorationContext(explorations) {
if (!explorations || !explorations.manifest) {
return '';
}
const manifest = explorations.manifest;
const data = explorations.data || {};
let sections = [];
// Header with manifest info
sections.push(`
<div class="exploration-header">
<h4>${escapeHtml(manifest.task_description || 'Exploration Context')}</h4>
<div class="exploration-meta">
<span class="meta-item">Complexity: <strong>${escapeHtml(manifest.complexity || 'N/A')}</strong></span>
<span class="meta-item">Explorations: <strong>${manifest.exploration_count || 0}</strong></span>
</div>
</div>
`);
// Render each exploration angle as collapsible section
const explorationOrder = ['architecture', 'dependencies', 'patterns', 'integration-points'];
const explorationTitles = {
'architecture': '🏗️ Architecture',
'dependencies': '📦 Dependencies',
'patterns': '🔄 Patterns',
'integration-points': '🔌 Integration Points'
};
for (const angle of explorationOrder) {
const expData = data[angle];
if (!expData) {
continue;
}
const angleContent = renderExplorationAngle(angle, expData);
sections.push(`
<div class="exploration-section collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">${explorationTitles[angle] || angle}</span>
</div>
<div class="collapsible-content collapsed">
${angleContent}
</div>
</div>
`);
}
return `<div class="exploration-context">${sections.join('')}</div>`;
}
function renderExplorationAngle(angle, data) {
let content = [];
// Project structure - handle string or object
if (data.project_structure) {
content.push(renderExpField('Project Structure', data.project_structure));
}
// Relevant files
if (data.relevant_files && data.relevant_files.length) {
content.push(`
<div class="exp-field">
<label>Relevant Files (${data.relevant_files.length})</label>
<div class="relevant-files-list">
${data.relevant_files.slice(0, 10).map(f => `
<div class="file-item-exp">
<div class="file-path"><code>${escapeHtml(f.path || '')}</code></div>
<div class="file-relevance">Relevance: ${f.relevance ? (f.relevance * 100).toFixed(0) : 0}%</div>
${f.rationale ? `<div class="file-rationale">${escapeHtml((f.rationale || "").substring(0, 200))}...</div>` : ''}
</div>
`).join('')}
${data.relevant_files.length > 10 ? `<div class="more-files">... and ${data.relevant_files.length - 10} more files</div>` : ''}
</div>
</div>
`);
}
// Patterns - handle string or object
if (data.patterns) {
content.push(renderExpField('Patterns', data.patterns));
}
// Dependencies - handle string or object
if (data.dependencies) {
content.push(renderExpField('Dependencies', data.dependencies));
}
// Integration points - handle string or object
if (data.integration_points) {
content.push(renderExpField('Integration Points', data.integration_points));
}
// Constraints - handle string or object
if (data.constraints) {
content.push(renderExpField('Constraints', data.constraints));
}
// Clarification needs - handle array or object
if (data.clarification_needs) {
content.push(renderExpField('Clarification Needs', data.clarification_needs));
}
return content.join('') || '<p>No data available</p>';
}

View File

@@ -0,0 +1,477 @@
// ==========================================
// TASK DRAWER CORE
// ==========================================
// Core drawer functionality and main rendering functions
let currentDrawerTasks = [];
function openTaskDrawer(taskId) {
const task = currentDrawerTasks.find(t => (t.task_id || t.id) === taskId);
if (!task) {
console.error('Task not found:', taskId);
return;
}
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
document.getElementById('drawerContent').innerHTML = renderTaskDrawerContent(task);
document.getElementById('taskDetailDrawer').classList.add('open');
document.getElementById('drawerOverlay').classList.add('active');
// Initialize flowchart after DOM is updated
setTimeout(() => {
renderFullFlowchart(task.flow_control);
}, 100);
}
function openTaskDrawerForLite(sessionId, taskId) {
const session = liteTaskDataStore[currentSessionDetailKey];
if (!session) return;
const task = session.tasks?.find(t => t.id === taskId);
if (!task) return;
// Set current drawer tasks and session context
currentDrawerTasks = session.tasks || [];
window._currentDrawerSession = session;
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
// Use dedicated lite task drawer renderer
document.getElementById('drawerContent').innerHTML = renderLiteTaskDrawerContent(task, session);
document.getElementById('taskDetailDrawer').classList.add('open');
document.getElementById('drawerOverlay').classList.add('active');
}
function closeTaskDrawer() {
document.getElementById('taskDetailDrawer').classList.remove('open');
document.getElementById('drawerOverlay').classList.remove('active');
}
function switchDrawerTab(tabName) {
// Update tab buttons
document.querySelectorAll('.drawer-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
// Update tab panels
document.querySelectorAll('.drawer-panel').forEach(panel => {
panel.classList.toggle('active', panel.dataset.tab === tabName);
});
// Render flowchart if switching to flowchart tab
if (tabName === 'flowchart') {
const taskId = document.getElementById('drawerTaskTitle').textContent;
const task = currentDrawerTasks.find(t => t.title === taskId || t.task_id === taskId);
if (task?.flow_control) {
setTimeout(() => renderFullFlowchart(task.flow_control), 50);
}
}
}
function renderTaskDrawerContent(task) {
const fc = task.flow_control || {};
return `
<!-- Task Header -->
<div class="drawer-task-header">
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
<span class="task-status-badge ${task.status || 'pending'}">${task.status || 'pending'}</span>
</div>
<!-- Tab Navigation -->
<div class="drawer-tabs">
<button class="drawer-tab active" data-tab="overview" onclick="switchDrawerTab('overview')">Overview</button>
<button class="drawer-tab" data-tab="flowchart" onclick="switchDrawerTab('flowchart')">Flowchart</button>
<button class="drawer-tab" data-tab="files" onclick="switchDrawerTab('files')">Files</button>
<button class="drawer-tab" data-tab="raw" onclick="switchDrawerTab('raw')">Raw JSON</button>
</div>
<!-- Tab Content -->
<div class="drawer-tab-content">
<!-- Overview Tab (default) -->
<div class="drawer-panel active" data-tab="overview">
${renderPreAnalysisSteps(fc.pre_analysis)}
${renderImplementationStepsList(fc.implementation_approach)}
</div>
<!-- Flowchart Tab -->
<div class="drawer-panel" data-tab="flowchart">
<div id="flowchartContainer" class="flowchart-container"></div>
</div>
<!-- Files Tab -->
<div class="drawer-panel" data-tab="files">
${renderTargetFiles(fc.target_files)}
${fc.test_commands ? renderTestCommands(fc.test_commands) : ''}
</div>
<!-- Raw JSON Tab -->
<div class="drawer-panel" data-tab="raw">
<pre class="json-view">${escapeHtml(JSON.stringify(task, null, 2))}</pre>
</div>
</div>
`;
}
function renderLiteTaskDrawerContent(task, session) {
const rawTask = task._raw || task;
return `
<!-- Task Header -->
<div class="drawer-task-header">
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
${rawTask.action ? `<span class="action-badge">${escapeHtml(rawTask.action)}</span>` : ''}
</div>
<!-- Tab Navigation -->
<div class="drawer-tabs">
<button class="drawer-tab active" data-tab="overview" onclick="switchDrawerTab('overview')">Overview</button>
<button class="drawer-tab" data-tab="implementation" onclick="switchDrawerTab('implementation')">Implementation</button>
<button class="drawer-tab" data-tab="files" onclick="switchDrawerTab('files')">Files</button>
<button class="drawer-tab" data-tab="raw" onclick="switchDrawerTab('raw')">Raw JSON</button>
</div>
<!-- Tab Content -->
<div class="drawer-tab-content">
<!-- Overview Tab (default) -->
<div class="drawer-panel active" data-tab="overview">
${renderLiteTaskOverview(rawTask)}
</div>
<!-- Implementation Tab -->
<div class="drawer-panel" data-tab="implementation">
${renderLiteTaskImplementation(rawTask)}
</div>
<!-- Files Tab -->
<div class="drawer-panel" data-tab="files">
${renderLiteTaskFiles(rawTask)}
</div>
<!-- Raw JSON Tab -->
<div class="drawer-panel" data-tab="raw">
<pre class="json-view">${escapeHtml(JSON.stringify(rawTask, null, 2))}</pre>
</div>
</div>
`;
}
// Render plan.json task details in drawer (for lite tasks)
function renderPlanTaskDetails(task, session) {
if (!task) return '';
// Get corresponding plan task if available
const planTask = session?.plan?.tasks?.find(pt => pt.id === task.id);
if (!planTask) {
// Fallback: task itself might have plan-like structure
return renderTaskImplementationDetails(task);
}
return renderTaskImplementationDetails(planTask);
}
function renderTaskImplementationDetails(task) {
const sections = [];
// Description
if (task.description) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Description</h4>
<p class="task-description">${escapeHtml(task.description)}</p>
</div>
`);
}
// Modification Points
if (task.modification_points?.length) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Modification Points</h4>
<div class="modification-points-list">
${task.modification_points.map(mp => `
<div class="mod-point-item">
<div class="mod-point-file">
<span class="file-icon">📄</span>
<code>${escapeHtml(mp.file || mp.path || '')}</code>
</div>
${mp.target ? `<div class="mod-point-target">Target: <code>${escapeHtml(mp.target)}</code></div>` : ''}
${mp.change ? `<div class="mod-point-change">${escapeHtml(mp.change)}</div>` : ''}
</div>
`).join('')}
</div>
</div>
`);
}
// Implementation Steps
if (task.implementation?.length) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Implementation Steps</h4>
<ol class="implementation-steps-list">
${task.implementation.map(step => `
<li class="impl-step-item">${escapeHtml(typeof step === 'string' ? step : step.step || JSON.stringify(step))}</li>
`).join('')}
</ol>
</div>
`);
}
// Reference
if (task.reference) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Reference</h4>
${task.reference.pattern ? `<div class="ref-pattern"><strong>Pattern:</strong> ${escapeHtml(task.reference.pattern)}</div>` : ''}
${task.reference.files?.length ? `
<div class="ref-files">
<strong>Files:</strong>
<ul>
${task.reference.files.map(f => `<li><code>${escapeHtml(f)}</code></li>`).join('')}
</ul>
</div>
` : ''}
${task.reference.examples ? `<div class="ref-examples"><strong>Examples:</strong> ${escapeHtml(task.reference.examples)}</div>` : ''}
</div>
`);
}
// Acceptance Criteria
if (task.acceptance?.length) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Acceptance Criteria</h4>
<ul class="acceptance-list">
${task.acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}
</ul>
</div>
`);
}
// Dependencies
if (task.depends_on?.length) {
sections.push(`
<div class="drawer-section">
<h4 class="drawer-section-title">Dependencies</h4>
<div class="dependencies-list">
${task.depends_on.map(dep => `<span class="dep-badge">${escapeHtml(dep)}</span>`).join(' ')}
</div>
</div>
`);
}
return sections.join('');
}
// Render lite task overview
function renderLiteTaskOverview(task) {
let sections = [];
// Description Card
if (task.description) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📝</span>
<h4 class="lite-card-title">Description</h4>
</div>
<div class="lite-card-body">
<p class="lite-description">${escapeHtml(task.description)}</p>
</div>
</div>
`);
}
// Scope Card
if (task.scope) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📂</span>
<h4 class="lite-card-title">Scope</h4>
</div>
<div class="lite-card-body">
<div class="lite-scope-box">
<code>${escapeHtml(task.scope)}</code>
</div>
</div>
</div>
`);
}
// Acceptance Criteria Card
if (task.acceptance && task.acceptance.length > 0) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">✅</span>
<h4 class="lite-card-title">Acceptance Criteria</h4>
<span class="lite-count-badge">${task.acceptance.length}</span>
</div>
<div class="lite-card-body">
<ul class="lite-checklist">
${task.acceptance.map(a => `
<li class="lite-check-item">
<span class="lite-check-icon">○</span>
<span class="lite-check-text">${escapeHtml(a)}</span>
</li>
`).join('')}
</ul>
</div>
</div>
`);
}
// Reference Card
if (task.reference) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📚</span>
<h4 class="lite-card-title">Reference</h4>
</div>
<div class="lite-card-body">
${task.reference.pattern ? `
<div class="lite-ref-section">
<span class="lite-ref-label">Pattern:</span>
<span class="lite-ref-value">${escapeHtml(task.reference.pattern)}</span>
</div>
` : ''}
${task.reference.files && task.reference.files.length > 0 ? `
<div class="lite-ref-section">
<span class="lite-ref-label">Files:</span>
<div class="lite-ref-files">
${task.reference.files.map(f => `<code class="lite-file-tag">${escapeHtml(f)}</code>`).join('')}
</div>
</div>
` : ''}
${task.reference.examples ? `
<div class="lite-ref-section">
<span class="lite-ref-label">Examples:</span>
<span class="lite-ref-value">${escapeHtml(task.reference.examples)}</span>
</div>
` : ''}
</div>
</div>
`);
}
// Dependencies Card
if (task.depends_on && task.depends_on.length > 0) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">🔗</span>
<h4 class="lite-card-title">Dependencies</h4>
</div>
<div class="lite-card-body">
<div class="lite-deps-tags">
${task.depends_on.map(dep => `<span class="lite-dep-tag">${escapeHtml(dep)}</span>`).join('')}
</div>
</div>
</div>
`);
}
return sections.length > 0 ? sections.join('') : '<div class="empty-section">No overview data</div>';
}
// Render lite task implementation steps
function renderLiteTaskImplementation(task) {
let sections = [];
// Implementation Steps Card
if (task.implementation && task.implementation.length > 0) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📋</span>
<h4 class="lite-card-title">Implementation Steps</h4>
<span class="lite-count-badge">${task.implementation.length}</span>
</div>
<div class="lite-card-body">
<div class="lite-impl-steps">
${task.implementation.map((step, idx) => `
<div class="lite-impl-step">
<div class="lite-step-num">${idx + 1}</div>
<div class="lite-step-content">
<p class="lite-step-text">${escapeHtml(typeof step === 'string' ? step : step.step || JSON.stringify(step))}</p>
</div>
</div>
`).join('')}
</div>
</div>
</div>
`);
}
// Modification Points Card
if (task.modification_points && task.modification_points.length > 0) {
sections.push(`
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">🔧</span>
<h4 class="lite-card-title">Modification Points</h4>
<span class="lite-count-badge">${task.modification_points.length}</span>
</div>
<div class="lite-card-body">
<div class="lite-mod-points">
${task.modification_points.map(mp => `
<div class="lite-mod-card">
<div class="lite-mod-header">
<code class="lite-mod-file">${escapeHtml(mp.file || '')}</code>
</div>
${mp.target ? `
<div class="lite-mod-target">
<span class="lite-mod-label">Target:</span>
<span class="lite-mod-value">${escapeHtml(mp.target)}</span>
</div>
` : ''}
${mp.change ? `
<div class="lite-mod-change">${escapeHtml(mp.change)}</div>
` : ''}
</div>
`).join('')}
</div>
</div>
</div>
`);
}
return sections.length > 0 ? sections.join('') : '<div class="empty-section">No implementation data</div>';
}
// Render lite task files
function renderLiteTaskFiles(task) {
const files = [];
// Collect from modification_points
if (task.modification_points) {
task.modification_points.forEach(mp => {
if (mp.file && !files.includes(mp.file)) files.push(mp.file);
});
}
// Collect from scope
if (task.scope && !files.includes(task.scope)) {
files.push(task.scope);
}
if (files.length === 0) {
return '<div class="empty-section">No files specified</div>';
}
return `
<div class="drawer-section">
<h4 class="drawer-section-title">Target Files</h4>
<ul class="target-files-list">
${files.map(f => `
<li class="file-item">
<span class="file-icon">📄</span>
<code>${escapeHtml(f)}</code>
</li>
`).join('')}
</ul>
</div>
`;
}

View File

@@ -0,0 +1,447 @@
// ==========================================
// TASK DRAWER RENDERERS
// ==========================================
// Detailed content renderers and helper functions for task drawer
function renderPreAnalysisSteps(preAnalysis) {
if (!Array.isArray(preAnalysis) || preAnalysis.length === 0) {
return '<div class="empty-section">No pre-analysis steps</div>';
}
return `
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">🔍</span>
<h4 class="lite-card-title">Pre-Analysis Steps</h4>
<span class="lite-count-badge">${preAnalysis.length}</span>
</div>
<div class="lite-card-body">
<div class="lite-impl-steps">
${preAnalysis.map((item, idx) => `
<div class="lite-impl-step">
<div class="lite-step-num">${idx + 1}</div>
<div class="lite-step-content">
<p class="lite-step-text">${escapeHtml(item.step || item.action || 'Step ' + (idx + 1))}</p>
${item.action && item.action !== item.step ? `
<div class="lite-step-meta">
<span class="lite-step-label">Action:</span>
<span class="lite-step-value">${escapeHtml(item.action)}</span>
</div>
` : ''}
${item.commands?.length ? `
<div class="lite-step-commands">
${item.commands.map(c => `<code class="lite-cmd-tag">${escapeHtml(typeof c === 'string' ? c : JSON.stringify(c))}</code>`).join('')}
</div>
` : ''}
${item.output_to ? `
<div class="lite-step-meta">
<span class="lite-step-label">Output:</span>
<code class="lite-file-tag">${escapeHtml(item.output_to)}</code>
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
function renderImplementationStepsList(steps) {
if (!Array.isArray(steps) || steps.length === 0) {
return '<div class="empty-section">No implementation steps</div>';
}
return `
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📋</span>
<h4 class="lite-card-title">Implementation Approach</h4>
<span class="lite-count-badge">${steps.length}</span>
</div>
<div class="lite-card-body">
<div class="session-impl-steps">
${steps.map((step, idx) => {
const hasMods = step.modification_points?.length;
const hasFlow = step.logic_flow?.length;
return `
<div class="session-impl-step">
<div class="session-step-header">
<div class="lite-step-num">${step.step || idx + 1}</div>
<div class="session-step-title">${escapeHtml(step.title || 'Untitled Step')}</div>
</div>
${step.description ? `<div class="session-step-desc">${escapeHtml(step.description)}</div>` : ''}
${hasMods ? `
<div class="session-step-section">
<div class="session-section-label">
<span class="session-section-icon">🔧</span>
<span>Modifications</span>
<span class="lite-count-badge">${step.modification_points.length}</span>
</div>
<div class="session-mods-list">
${step.modification_points.map(mp => `
<div class="session-mod-item">
${typeof mp === 'string' ? `<code class="lite-file-tag">${escapeHtml(mp)}</code>` : `
<code class="lite-file-tag">${escapeHtml(mp.file || mp.path || '')}</code>
${mp.changes ? `<span class="session-mod-change">${escapeHtml(mp.changes)}</span>` : ''}
`}
</div>
`).join('')}
</div>
</div>
` : ''}
${hasFlow ? `
<div class="session-step-section">
<div class="session-section-label">
<span class="session-section-icon">⚡</span>
<span>Logic Flow</span>
<span class="lite-count-badge">${step.logic_flow.length}</span>
</div>
<div class="session-flow-list">
${step.logic_flow.map((lf, lfIdx) => `
<div class="session-flow-item">
<span class="session-flow-num">${lfIdx + 1}</span>
<span class="session-flow-text">${escapeHtml(typeof lf === 'string' ? lf : lf.action || JSON.stringify(lf))}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
${step.depends_on?.length ? `
<div class="session-step-deps">
<span class="session-deps-label">Dependencies:</span>
<div class="lite-deps-tags">
${step.depends_on.map(d => `<span class="lite-dep-tag">${escapeHtml(d)}</span>`).join('')}
</div>
</div>
` : ''}
</div>
`}).join('')}
</div>
</div>
</div>
`;
}
function renderTargetFiles(files) {
if (!Array.isArray(files) || files.length === 0) {
return '<div class="empty-section">No target files</div>';
}
// Get current project path for building full paths
const projectPath = window.currentProjectPath || '';
return `
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">📁</span>
<h4 class="lite-card-title">Target Files</h4>
<span class="lite-count-badge">${files.length}</span>
</div>
<div class="lite-card-body">
<div class="session-files-list">
${files.map(f => {
const filePath = typeof f === 'string' ? f : (f.path || JSON.stringify(f));
// Build full path for vscode link
const fullPath = filePath.startsWith('/') || filePath.includes(':')
? filePath
: (projectPath ? `${projectPath}/${filePath}` : filePath);
const vscodeUri = `vscode://file/${fullPath.replace(/\\/g, '/')}`;
return `
<a href="${vscodeUri}" class="session-file-item" title="Open in VS Code: ${escapeHtml(fullPath)}">
<span class="session-file-icon">📄</span>
<code class="session-file-path">${escapeHtml(filePath)}</code>
<span class="session-file-action">↗</span>
</a>
`;
}).join('')}
</div>
</div>
</div>
`;
}
function renderTestCommands(testCommands) {
if (!testCommands || typeof testCommands !== 'object') return '';
const entries = Object.entries(testCommands);
if (entries.length === 0) return '';
return `
<div class="lite-card">
<div class="lite-card-header">
<span class="lite-card-icon">🧪</span>
<h4 class="lite-card-title">Test Commands</h4>
<span class="lite-count-badge">${entries.length}</span>
</div>
<div class="lite-card-body">
<div class="session-test-commands">
${entries.map(([key, val]) => `
<div class="session-test-item">
<span class="session-test-label">${escapeHtml(key)}</span>
<code class="session-test-cmd">${escapeHtml(typeof val === 'string' ? val : JSON.stringify(val))}</code>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
function renderTaskDetail(sessionId, task) {
// Get raw task data for JSON view
const rawTask = task._raw || task;
const taskJsonId = `task-json-${sessionId}-${task.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
// Store JSON in memory instead of inline script tag
taskJsonStore[taskJsonId] = rawTask;
return `
<div class="task-detail" id="task-${sessionId}-${task.id}">
<div class="task-detail-header">
<span class="task-id-badge">${escapeHtml(task.id)}</span>
<span class="task-title">${escapeHtml(task.title || 'Untitled')}</span>
<span class="task-status-badge ${task.status}">${task.status}</span>
<div class="task-header-actions">
<button class="btn-view-json" onclick="showJsonModal('${taskJsonId}', '${escapeHtml(task.id)}')">{ } JSON</button>
</div>
</div>
<!-- Collapsible: Meta -->
<div class="collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">meta</span>
<span class="section-preview">${escapeHtml((task.meta?.type || task.meta?.action || '') + (task.meta?.scope ? ' | ' + task.meta.scope : ''))}</span>
</div>
<div class="collapsible-content collapsed">
${renderDynamicFields(task.meta || rawTask, ['type', 'action', 'agent', 'scope', 'module', 'execution_group'])}
</div>
</div>
<!-- Collapsible: Context -->
<div class="collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">context</span>
<span class="section-preview">${escapeHtml(getContextPreview(task.context, rawTask))}</span>
</div>
<div class="collapsible-content collapsed">
${renderContextFields(task.context, rawTask)}
</div>
</div>
<!-- Collapsible: Flow Control (with Flowchart) -->
<div class="collapsible-section">
<div class="collapsible-header">
<span class="collapse-icon">▶</span>
<span class="section-label">flow_control</span>
<span class="section-preview">${escapeHtml(getFlowControlPreview(task.flow_control, rawTask))}</span>
</div>
<div class="collapsible-content collapsed">
<div class="flowchart-container" id="flowchart-${sessionId}-${task.id}"></div>
${renderFlowControlDetails(task.flow_control, rawTask)}
</div>
</div>
</div>
`;
}
function getContextPreview(context, rawTask) {
const items = [];
if (context?.requirements?.length) items.push(`${context.requirements.length} reqs`);
if (context?.acceptance?.length) items.push(`${context.acceptance.length} acceptance`);
if (context?.focus_paths?.length) items.push(`${context.focus_paths.length} paths`);
if (rawTask?.modification_points?.length) items.push(`${rawTask.modification_points.length} mods`);
return items.join(' | ') || 'No context';
}
function getFlowControlPreview(flowControl, rawTask) {
const steps = flowControl?.implementation_approach?.length || rawTask?.implementation?.length || 0;
return steps > 0 ? `${steps} steps` : 'No steps';
}
function renderDynamicFields(obj, priorityKeys = []) {
if (!obj || typeof obj !== 'object') return '<div class="field-value json-value-null">null</div>';
const entries = Object.entries(obj).filter(([k, v]) => v !== null && v !== undefined && k !== '_raw');
if (entries.length === 0) return '<div class="field-value json-value-null">Empty</div>';
// Sort: priority keys first, then alphabetically
entries.sort(([a], [b]) => {
const aIdx = priorityKeys.indexOf(a);
const bIdx = priorityKeys.indexOf(b);
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
if (aIdx !== -1) return -1;
if (bIdx !== -1) return 1;
return a.localeCompare(b);
});
return `<div class="field-group">${entries.map(([key, value]) => renderFieldRow(key, value)).join('')}</div>`;
}
function renderFieldRow(key, value) {
return `
<div class="field-row">
<span class="field-label">${escapeHtml(key)}:</span>
<div class="field-value">${renderFieldValue(key, value)}</div>
</div>
`;
}
function renderFieldValue(key, value) {
if (value === null || value === undefined) {
return '<span class="json-value-null">null</span>';
}
if (typeof value === 'boolean') {
return `<span class="json-value-boolean">${value}</span>`;
}
if (typeof value === 'number') {
return `<span class="json-value-number">${value}</span>`;
}
if (typeof value === 'string') {
// Check if it's a path
if (key.includes('path') || key.includes('file') || value.includes('/') || value.includes('\\')) {
return `<span class="array-item path-item">${escapeHtml(value)}</span>`;
}
return `<span class="json-value-string">${escapeHtml(value)}</span>`;
}
if (Array.isArray(value)) {
if (value.length === 0) return '<span class="json-value-null">[]</span>';
// Check if array contains objects or strings
if (typeof value[0] === 'object') {
return `<div class="nested-array">${value.map((item, i) => `
<div class="array-object">
<div class="array-object-header">[${i + 1}]</div>
${renderDynamicFields(item)}
</div>
`).join('')}</div>`;
}
// Array of strings/primitives
const isPathArray = key.includes('path') || key.includes('file');
return `<div class="array-value">${value.map(v =>
`<span class="array-item ${isPathArray ? 'path-item' : ''}">${escapeHtml(String(v))}</span>`
).join('')}</div>`;
}
if (typeof value === 'object') {
return renderDynamicFields(value);
}
return escapeHtml(String(value));
}
function renderContextFields(context, rawTask) {
const sections = [];
// Requirements / Description
const requirements = context?.requirements || [];
const description = rawTask?.description;
if (requirements.length > 0 || description) {
sections.push(`
<div class="context-field">
<label>requirements:</label>
${description ? `<p style="margin-bottom: 8px;">${escapeHtml(description)}</p>` : ''}
${requirements.length > 0 ? `<ul>${requirements.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>` : ''}
</div>
`);
}
// Focus paths / Modification points
const focusPaths = context?.focus_paths || [];
const modPoints = rawTask?.modification_points || [];
if (focusPaths.length > 0 || modPoints.length > 0) {
sections.push(`
<div class="context-field">
<label>${modPoints.length > 0 ? 'modification_points:' : 'focus_paths:'}</label>
${modPoints.length > 0 ? `
<div class="mod-points">
${modPoints.map(m => `
<div class="mod-point">
<span class="array-item path-item">${escapeHtml(m.file || m)}</span>
${m.target ? `<span class="mod-target">→ ${escapeHtml(m.target)}</span>` : ''}
${m.change ? `<p class="mod-change">${escapeHtml(m.change)}</p>` : ''}
</div>
`).join('')}
</div>
` : `
<div class="path-tags">${focusPaths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}</div>
`}
</div>
`);
}
// Acceptance criteria
const acceptance = context?.acceptance || rawTask?.acceptance || [];
if (acceptance.length > 0) {
sections.push(`
<div class="context-field">
<label>acceptance:</label>
<ul>${acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}</ul>
</div>
`);
}
// Dependencies
const depends = context?.depends_on || rawTask?.depends_on || [];
if (depends.length > 0) {
sections.push(`
<div class="context-field">
<label>depends_on:</label>
<div class="path-tags">${depends.map(d => `<span class="array-item depends-badge">${escapeHtml(d)}</span>`).join('')}</div>
</div>
`);
}
// Reference
const reference = rawTask?.reference;
if (reference) {
sections.push(`
<div class="context-field">
<label>reference:</label>
${renderDynamicFields(reference)}
</div>
`);
}
return sections.length > 0
? `<div class="context-fields">${sections.join('')}</div>`
: '<div class="field-value json-value-null">No context data</div>';
}
function renderFlowControlDetails(flowControl, rawTask) {
const sections = [];
// Pre-analysis
const preAnalysis = flowControl?.pre_analysis || rawTask?.pre_analysis || [];
if (preAnalysis.length > 0) {
sections.push(`
<div class="context-field" style="margin-top: 16px;">
<label>pre_analysis:</label>
<ul>${preAnalysis.map(p => `<li>${escapeHtml(p)}</li>`).join('')}</ul>
</div>
`);
}
// Target files
const targetFiles = flowControl?.target_files || rawTask?.target_files || [];
if (targetFiles.length > 0) {
sections.push(`
<div class="context-field">
<label>target_files:</label>
<div class="path-tags">${targetFiles.map(f => `<span class="path-tag">${escapeHtml(f)}</span>`).join('')}</div>
</div>
`);
}
return sections.join('');
}

View File

@@ -0,0 +1,21 @@
// ==========================================
// THEME MANAGEMENT
// ==========================================
function initTheme() {
const saved = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
document.getElementById('themeToggle').addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon(next);
});
}
function updateThemeIcon(theme) {
document.getElementById('themeToggle').textContent = theme === 'light' ? '🌙' : '☀️';
}

View File

@@ -0,0 +1,57 @@
// Application Entry Point
// Initializes all components and sets up global event handlers
document.addEventListener('DOMContentLoaded', async () => {
// Initialize components with error handling to prevent cascading failures
try { initTheme(); } catch (e) { console.error('Theme init failed:', e); }
try { initSidebar(); } catch (e) { console.error('Sidebar init failed:', e); }
try { initPathSelector(); } catch (e) { console.error('Path selector init failed:', e); }
try { initNavigation(); } catch (e) { console.error('Navigation init failed:', e); }
try { initSearch(); } catch (e) { console.error('Search init failed:', e); }
try { initRefreshButton(); } catch (e) { console.error('Refresh button init failed:', e); }
try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); }
try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); }
try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); }
// Initialize real-time features (WebSocket + auto-refresh)
try { initWebSocket(); } catch (e) { console.log('WebSocket not available:', e.message); }
try { initAutoRefresh(); } catch (e) { console.error('Auto-refresh init failed:', e); }
// Server mode: load data from API
try {
if (window.SERVER_MODE) {
await switchToPath(window.INITIAL_PATH || projectPath);
} else {
renderDashboard();
}
} catch (e) {
console.error('Dashboard render failed:', e);
}
// Global Escape key handler for modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeMarkdownModal();
// Close JSON modal if exists
const jsonModal = document.querySelector('.json-modal-overlay');
if (jsonModal) {
const closeBtn = jsonModal.querySelector('.json-modal-close');
if (closeBtn) closeJsonModal(closeBtn);
}
// Close path modal if exists
closePathModal();
// Close MCP create modal if exists
if (typeof closeMcpCreateModal === 'function') {
closeMcpCreateModal();
}
// Close Hook create modal if exists
if (typeof closeHookCreateModal === 'function') {
closeHookCreateModal();
}
}
});
});

View File

@@ -0,0 +1,37 @@
// ========================================
// State Management
// ========================================
// Global state variables and template placeholders
// This module must be loaded first as other modules depend on these variables
// ========== Data Placeholders ==========
// These placeholders are replaced by the dashboard generator at build time
let workflowData = {{WORKFLOW_DATA}};
let projectPath = '{{PROJECT_PATH}}';
let recentPaths = {{RECENT_PATHS}};
// ========== Application State ==========
// Current filter for session list view ('all', 'active', 'archived')
let currentFilter = 'all';
// Current lite task type ('lite-plan', 'lite-fix', or null)
let currentLiteType = null;
// Current view mode ('sessions', 'liteTasks', 'project-overview', 'sessionDetail', 'liteTaskDetail')
let currentView = 'sessions';
// Current session detail key (null when not in detail view)
let currentSessionDetailKey = null;
// ========== Data Stores ==========
// Store session data for modal/detail access
// Key: session key, Value: session data object
const sessionDataStore = {};
// Store lite task session data for detail page access
// Key: session key, Value: lite session data object
const liteTaskDataStore = {};
// Store task JSON data in a global map instead of inline script tags
// Key: unique task ID, Value: raw task JSON data
const taskJsonStore = {};

View File

@@ -0,0 +1,153 @@
// ========================================
// Utility Functions
// ========================================
// General-purpose helper functions used across the application
// ========== HTML/Text Processing ==========
/**
* Escape HTML special characters to prevent XSS attacks
* @param {string} str - String to escape
* @returns {string} Escaped string safe for HTML insertion
*/
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Truncate text to specified maximum length
* @param {string} text - Text to truncate
* @param {number} maxLen - Maximum length (including ellipsis)
* @returns {string} Truncated text with '...' if needed
*/
function truncateText(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.substring(0, maxLen - 3) + '...' : text;
}
/**
* Normalize line endings in content
* Handles both literal \r\n escape sequences and actual newlines
* @param {string} content - Content to normalize
* @returns {string} Content with normalized line endings (LF only)
*/
function normalizeLineEndings(content) {
if (!content) return '';
let normalized = content;
// If content has literal \r\n or \n as text (escaped), convert to actual newlines
if (normalized.includes('\\r\\n')) {
normalized = normalized.replace(/\\r\\n/g, '\n');
} else if (normalized.includes('\\n')) {
normalized = normalized.replace(/\\n/g, '\n');
}
// Normalize CRLF to LF for consistent rendering
normalized = normalized.replace(/\r\n/g, '\n');
return normalized;
}
// ========== Date/Time Formatting ==========
/**
* Format ISO date string to human-readable format
* @param {string} dateStr - ISO date string
* @returns {string} Formatted date string (YYYY/MM/DD HH:mm) or '-' if invalid
*/
function formatDate(dateStr) {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) return '-';
// Format: YYYY/MM/DD HH:mm
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
} catch (e) {
return '-';
}
}
// ========== UI Helpers ==========
/**
* Get color for relevance score visualization
* @param {number} score - Relevance score (0-1)
* @returns {string} CSS color value
*/
function getRelevanceColor(score) {
if (score >= 0.95) return '#10b981';
if (score >= 0.90) return '#3b82f6';
if (score >= 0.80) return '#f59e0b';
return '#6b7280';
}
/**
* Get CSS class for role badge styling
* @param {string} role - Role identifier
* @returns {string} CSS class name
*/
function getRoleBadgeClass(role) {
const roleMap = {
'core-hook': 'primary',
'api-client': 'success',
'api-router': 'info',
'service-layer': 'warning',
'pydantic-schemas': 'secondary',
'orm-model': 'secondary',
'typescript-types': 'info'
};
return roleMap[role] || 'secondary';
}
/**
* Toggle collapsible section visibility
* @param {HTMLElement} header - Section header element
*/
function toggleSection(header) {
const content = header.nextElementSibling;
const icon = header.querySelector('.collapse-icon');
const isCollapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed');
header.classList.toggle('expanded');
icon.textContent = isCollapsed ? '▼' : '▶';
// Render flowchart if expanding flow_control section
if (isCollapsed && header.querySelector('.section-label')?.textContent === 'flow_control') {
const taskId = content.closest('[data-task-id]')?.dataset.taskId;
if (taskId) {
const task = taskJsonStore[taskId];
if (task?.flow_control) {
setTimeout(() => renderFullFlowchart(task.flow_control), 100);
}
}
}
}
/**
* Initialize collapsible sections within a container
* @param {HTMLElement} container - Container element to search within
*/
function initCollapsibleSections(container) {
setTimeout(() => {
const headers = container.querySelectorAll('.collapsible-header');
headers.forEach(header => {
if (!header._clickBound) {
header._clickBound = true;
header.addEventListener('click', function(e) {
e.stopPropagation();
toggleSection(this);
});
}
});
}, 100);
}

View File

@@ -0,0 +1,180 @@
// ============================================
// FIX SESSION VIEW
// ============================================
// Fix session detail page rendering
function renderFixSessionDetailPage(session) {
const isActive = session._isActive !== false;
const tasks = session.tasks || [];
// Calculate fix statistics
const totalTasks = tasks.length;
const fixedCount = tasks.filter(t => t.status === 'completed' && t.result === 'fixed').length;
const failedCount = tasks.filter(t => t.status === 'completed' && t.result === 'failed').length;
const pendingCount = tasks.filter(t => t.status === 'pending').length;
const inProgressCount = tasks.filter(t => t.status === 'in_progress').length;
const percentComplete = totalTasks > 0 ? ((fixedCount + failedCount) / totalTasks * 100) : 0;
return `
<div class="session-detail-page session-type-fix">
<!-- Header -->
<div class="detail-header">
<button class="btn-back" onclick="goBackToSessions()">
<span class="back-icon">←</span>
<span>Back to Sessions</span>
</button>
<div class="detail-title-row">
<h2 class="detail-session-id">🔧 ${escapeHtml(session.session_id)}</h2>
<div class="detail-badges">
<span class="session-type-badge test-fix">Fix</span>
<span class="session-status ${isActive ? 'active' : 'archived'}">
${isActive ? 'ACTIVE' : 'ARCHIVED'}
</span>
</div>
</div>
</div>
<!-- Fix Progress Section -->
<div class="fix-progress-section">
<div class="fix-progress-header">
<h3>🔧 Fix Progress</h3>
<span class="phase-badge ${session.phase || 'execution'}">${session.phase || 'Execution'}</span>
</div>
<!-- Progress Bar -->
<div class="fix-progress-bar">
<div class="fix-progress-bar-fill" style="width: ${percentComplete}%"></div>
</div>
<div class="progress-text">
<strong>${fixedCount + failedCount}/${totalTasks}</strong> completed (${percentComplete.toFixed(1)}%)
</div>
<!-- Summary Cards -->
<div class="fix-summary-grid">
<div class="summary-card">
<div class="summary-icon">📊</div>
<div class="summary-value">${totalTasks}</div>
<div class="summary-label">Total Tasks</div>
</div>
<div class="summary-card fixed">
<div class="summary-icon">✅</div>
<div class="summary-value">${fixedCount}</div>
<div class="summary-label">Fixed</div>
</div>
<div class="summary-card failed">
<div class="summary-icon">❌</div>
<div class="summary-value">${failedCount}</div>
<div class="summary-label">Failed</div>
</div>
<div class="summary-card pending">
<div class="summary-icon">⏳</div>
<div class="summary-value">${pendingCount}</div>
<div class="summary-label">Pending</div>
</div>
</div>
<!-- Stage Timeline (if available) -->
${session.stages && session.stages.length > 0 ? `
<div class="stage-timeline">
${session.stages.map((stage, idx) => `
<div class="stage-item ${stage.status || 'pending'}">
<div class="stage-number">Stage ${idx + 1}</div>
<div class="stage-mode">${stage.execution_mode === 'parallel' ? '⚡ Parallel' : '➡️ Serial'}</div>
<div class="stage-groups">${stage.groups?.length || 0} groups</div>
</div>
`).join('')}
</div>
` : ''}
</div>
<!-- Fix Tasks Grid -->
<div class="fix-tasks-section">
<div class="tasks-header">
<h3>📋 Fix Tasks</h3>
<div class="task-filters">
<button class="filter-btn active" data-status="all" onclick="filterFixTasks('all')">All</button>
<button class="filter-btn" data-status="pending" onclick="filterFixTasks('pending')">Pending</button>
<button class="filter-btn" data-status="in_progress" onclick="filterFixTasks('in_progress')">In Progress</button>
<button class="filter-btn" data-status="fixed" onclick="filterFixTasks('fixed')">Fixed</button>
<button class="filter-btn" data-status="failed" onclick="filterFixTasks('failed')">Failed</button>
</div>
</div>
<div class="fix-tasks-grid" id="fixTasksGrid">
${renderFixTasksGrid(tasks)}
</div>
</div>
<!-- Session Info -->
<div class="detail-info-bar">
<div class="info-item">
<span class="info-label">Created:</span>
<span class="info-value">${formatDate(session.created_at)}</span>
</div>
${session.archived_at ? `
<div class="info-item">
<span class="info-label">Archived:</span>
<span class="info-value">${formatDate(session.archived_at)}</span>
</div>
` : ''}
<div class="info-item">
<span class="info-label">Project:</span>
<span class="info-value">${escapeHtml(session.project || '-')}</span>
</div>
</div>
</div>
`;
}
function renderFixTasksGrid(tasks) {
if (!tasks || tasks.length === 0) {
return `
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-text">No fix tasks found</div>
</div>
`;
}
return tasks.map(task => {
const statusClass = task.status === 'completed' ? (task.result || 'completed') : task.status;
const statusText = task.status === 'completed' ? (task.result || 'completed') : task.status;
return `
<div class="fix-task-card status-${statusClass}" data-status="${statusClass}">
<div class="task-card-header">
<span class="task-id-badge">${escapeHtml(task.task_id || task.id || 'N/A')}</span>
<span class="task-status-badge ${statusClass}">${statusText}</span>
</div>
<div class="task-card-title">${escapeHtml(task.title || 'Untitled Task')}</div>
${task.finding_title ? `<div class="task-finding">${escapeHtml(task.finding_title)}</div>` : ''}
${task.file ? `<div class="task-file">📄 ${escapeHtml(task.file)}${task.line ? ':' + task.line : ''}</div>` : ''}
<div class="task-card-meta">
${task.dimension ? `<span class="task-dimension">${escapeHtml(task.dimension)}</span>` : ''}
${task.attempts && task.attempts > 1 ? `<span class="task-attempts">🔄 ${task.attempts} attempts</span>` : ''}
${task.commit_hash ? `<span class="task-commit">💾 ${task.commit_hash.substring(0, 7)}</span>` : ''}
</div>
</div>
`;
}).join('');
}
function initFixSessionPage(session) {
// Initialize event handlers for fix session page
// Filter handlers are inline onclick
}
function filterFixTasks(status) {
// Update filter buttons
document.querySelectorAll('.task-filters .filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.status === status);
});
// Filter task cards
document.querySelectorAll('.fix-task-card').forEach(card => {
if (status === 'all' || card.dataset.status === status) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}

View File

@@ -0,0 +1,193 @@
// ==========================================
// HOME VIEW - Dashboard Homepage
// ==========================================
function renderDashboard() {
// Show stats grid and search (may be hidden by MCP view)
showStatsAndSearch();
updateStats();
updateBadges();
updateCarousel();
renderSessions();
document.getElementById('generatedAt').textContent = workflowData.generatedAt || new Date().toISOString();
}
function showStatsAndSearch() {
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = '';
if (searchInput) searchInput.parentElement.style.display = '';
}
function hideStatsAndCarousel() {
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
}
function updateStats() {
const stats = workflowData.statistics || {};
document.getElementById('statTotalSessions').textContent = stats.totalSessions || 0;
document.getElementById('statActiveSessions').textContent = stats.activeSessions || 0;
document.getElementById('statTotalTasks').textContent = stats.totalTasks || 0;
document.getElementById('statCompletedTasks').textContent = stats.completedTasks || 0;
}
function updateBadges() {
const active = workflowData.activeSessions || [];
const archived = workflowData.archivedSessions || [];
document.getElementById('badgeAll').textContent = active.length + archived.length;
document.getElementById('badgeActive').textContent = active.length;
document.getElementById('badgeArchived').textContent = archived.length;
// Lite Tasks badges
const liteTasks = workflowData.liteTasks || {};
document.getElementById('badgeLitePlan').textContent = liteTasks.litePlan?.length || 0;
document.getElementById('badgeLiteFix').textContent = liteTasks.liteFix?.length || 0;
// MCP badge - load async if needed
if (typeof loadMcpConfig === 'function') {
loadMcpConfig().then(() => {
if (typeof updateMcpBadge === 'function') {
updateMcpBadge();
}
}).catch(e => console.error('MCP badge update failed:', e));
}
}
function renderSessions() {
const container = document.getElementById('mainContent');
let sessions = [];
if (currentFilter === 'all' || currentFilter === 'active') {
sessions = sessions.concat((workflowData.activeSessions || []).map(s => ({ ...s, _isActive: true })));
}
if (currentFilter === 'all' || currentFilter === 'archived') {
sessions = sessions.concat((workflowData.archivedSessions || []).map(s => ({ ...s, _isActive: false })));
}
if (sessions.length === 0) {
container.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1;">
<div class="empty-icon">📭</div>
<div class="empty-title">No Sessions Found</div>
<div class="empty-text">No workflow sessions match your current filter.</div>
</div>
`;
return;
}
container.innerHTML = `<div class="sessions-grid">${sessions.map(session => renderSessionCard(session)).join('')}</div>`;
}
function renderSessionCard(session) {
const tasks = session.tasks || [];
const taskCount = session.taskCount || tasks.length;
const completed = tasks.filter(t => t.status === 'completed').length;
const progress = taskCount > 0 ? Math.round((completed / taskCount) * 100) : 0;
// Use _isActive flag set during rendering, default to true
const isActive = session._isActive !== false;
const date = session.created_at;
// Get session type badge
const sessionType = session.type || 'workflow';
const typeBadge = sessionType !== 'workflow' ? `<span class="session-type-badge ${sessionType}">${sessionType}</span>` : '';
// Store session data for modal
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
sessionDataStore[sessionKey] = session;
// Special rendering for review sessions
if (sessionType === 'review') {
return renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date);
}
return `
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
<div class="session-header">
<div class="session-title">${escapeHtml(session.session_id || 'Unknown')}</div>
<div class="session-badges">
${typeBadge}
<span class="session-status ${isActive ? 'active' : 'archived'}">
${isActive ? 'ACTIVE' : 'ARCHIVED'}
</span>
</div>
</div>
<div class="session-body">
<div class="session-meta">
<span class="session-meta-item">📅 ${formatDate(date)}</span>
<span class="session-meta-item">📋 ${taskCount} tasks</span>
</div>
${taskCount > 0 ? `
<div class="progress-container">
<span class="progress-label">Progress</span>
<div class="progress-bar-wrapper">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span class="progress-text">${completed}/${taskCount} (${progress}%)</span>
</div>
</div>
` : ''}
</div>
</div>
`;
}
// Special card rendering for review sessions
function renderReviewSessionCard(session, sessionKey, typeBadge, isActive, date) {
// Calculate findings stats from reviewDimensions
const dimensions = session.reviewDimensions || [];
let totalFindings = 0;
let criticalCount = 0;
let highCount = 0;
let mediumCount = 0;
let lowCount = 0;
dimensions.forEach(dim => {
const findings = dim.findings || [];
totalFindings += findings.length;
criticalCount += findings.filter(f => f.severity === 'critical').length;
highCount += findings.filter(f => f.severity === 'high').length;
mediumCount += findings.filter(f => f.severity === 'medium').length;
lowCount += findings.filter(f => f.severity === 'low').length;
});
return `
<div class="session-card" onclick="showSessionDetailPage('${sessionKey}')">
<div class="session-header">
<div class="session-title">${escapeHtml(session.session_id || 'Unknown')}</div>
<div class="session-badges">
${typeBadge}
<span class="session-status ${isActive ? 'active' : 'archived'}">
${isActive ? 'ACTIVE' : 'ARCHIVED'}
</span>
</div>
</div>
<div class="session-body">
<div class="session-meta">
<span class="session-meta-item">📅 ${formatDate(date)}</span>
<span class="session-meta-item">🔍 ${totalFindings} findings</span>
</div>
${totalFindings > 0 ? `
<div class="review-findings-summary">
<div class="findings-severity-row">
${criticalCount > 0 ? `<span class="finding-count critical">🔴 ${criticalCount}</span>` : ''}
${highCount > 0 ? `<span class="finding-count high">🟠 ${highCount}</span>` : ''}
${mediumCount > 0 ? `<span class="finding-count medium">🟡 ${mediumCount}</span>` : ''}
${lowCount > 0 ? `<span class="finding-count low">🟢 ${lowCount}</span>` : ''}
</div>
<div class="dimensions-info">
${dimensions.length} dimensions
</div>
</div>
` : ''}
</div>
</div>
`;
}

View File

@@ -0,0 +1,387 @@
// Hook Manager View
// Renders the Claude Code hooks management interface
async function renderHookManager() {
const container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for Hook view
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load hook config if not already loaded
if (!hookConfig.global.hooks && !hookConfig.project.hooks) {
await loadHookConfig();
}
const globalHooks = hookConfig.global?.hooks || {};
const projectHooks = hookConfig.project?.hooks || {};
// Count hooks
const globalHookCount = countHooks(globalHooks);
const projectHookCount = countHooks(projectHooks);
container.innerHTML = `
<div class="hook-manager">
<!-- Project Hooks -->
<div class="hook-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Project Hooks</h3>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-light text-primary">.claude/settings.json</span>
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openHookCreateModal()">
<span>+</span> New Hook
</button>
</div>
<span class="text-sm text-muted-foreground">${projectHookCount} hooks configured</span>
</div>
${projectHookCount === 0 ? `
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
<div class="text-3xl mb-3">🪝</div>
<p class="text-muted-foreground">No hooks configured for this project</p>
<p class="text-sm text-muted-foreground mt-1">Create a hook to automate actions on tool usage</p>
</div>
` : `
<div class="hook-grid grid gap-3">
${renderHooksByEvent(projectHooks, 'project')}
</div>
`}
</div>
<!-- Global Hooks -->
<div class="hook-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Global Hooks</h3>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-muted text-muted-foreground">~/.claude/settings.json</span>
</div>
<span class="text-sm text-muted-foreground">${globalHookCount} hooks configured</span>
</div>
${globalHookCount === 0 ? `
<div class="hook-empty-state bg-card border border-border rounded-lg p-6 text-center">
<p class="text-muted-foreground">No global hooks configured</p>
<p class="text-sm text-muted-foreground mt-1">Global hooks apply to all Claude Code sessions</p>
</div>
` : `
<div class="hook-grid grid gap-3">
${renderHooksByEvent(globalHooks, 'global')}
</div>
`}
</div>
<!-- Quick Install Templates -->
<div class="hook-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Quick Install Templates</h3>
<span class="text-sm text-muted-foreground">One-click hook installation</span>
</div>
<div class="hook-templates-grid grid grid-cols-1 md:grid-cols-2 gap-4">
${renderQuickInstallCard('ccw-notify', 'CCW Dashboard Notify', 'Notify CCW dashboard when files are written', 'PostToolUse', 'Write')}
${renderQuickInstallCard('log-tool', 'Tool Usage Logger', 'Log all tool executions to a file', 'PostToolUse', 'All')}
${renderQuickInstallCard('lint-check', 'Auto Lint Check', 'Run ESLint on JavaScript/TypeScript files after write', 'PostToolUse', 'Write')}
${renderQuickInstallCard('git-add', 'Auto Git Stage', 'Automatically stage written files to git', 'PostToolUse', 'Write')}
</div>
</div>
<!-- Hook Environment Variables Reference -->
<div class="hook-section mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Environment Variables Reference</h3>
</div>
<div class="bg-card border border-border rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_FILE_PATHS</code>
<span class="text-muted-foreground">Space-separated file paths affected</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_NAME</code>
<span class="text-muted-foreground">Name of the tool being executed</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_TOOL_INPUT</code>
<span class="text-muted-foreground">JSON input passed to the tool</span>
</div>
</div>
<div class="space-y-2">
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_SESSION_ID</code>
<span class="text-muted-foreground">Current Claude session ID</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_PROJECT_DIR</code>
<span class="text-muted-foreground">Current project directory path</span>
</div>
<div class="flex items-start gap-2">
<code class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">$CLAUDE_WORKING_DIR</code>
<span class="text-muted-foreground">Current working directory</span>
</div>
</div>
</div>
</div>
</div>
</div>
`;
// Attach event listeners
attachHookEventListeners();
}
function countHooks(hooks) {
let count = 0;
for (const event of Object.keys(hooks)) {
const hookList = hooks[event];
count += Array.isArray(hookList) ? hookList.length : 1;
}
return count;
}
function renderHooksByEvent(hooks, scope) {
const events = Object.keys(hooks);
if (events.length === 0) return '';
return events.map(event => {
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
return hookList.map((hook, index) => {
const matcher = hook.matcher || 'All tools';
const command = hook.command || 'N/A';
const args = hook.args || [];
return `
<div class="hook-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${getHookEventIcon(event)}</span>
<div>
<h4 class="font-semibold text-foreground">${event}</h4>
<p class="text-xs text-muted-foreground">${getHookEventDescription(event)}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded transition-colors"
data-scope="${scope}"
data-event="${event}"
data-index="${index}"
data-action="edit"
title="Edit hook">
✏️
</button>
<button class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
data-scope="${scope}"
data-event="${event}"
data-index="${index}"
data-action="delete"
title="Delete hook">
🗑️
</button>
</div>
</div>
<div class="hook-details text-sm space-y-2">
<div class="flex items-center gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">matcher</span>
<span class="text-muted-foreground">${escapeHtml(matcher)}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">command</span>
<span class="font-mono text-xs text-foreground">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="font-mono text-xs text-muted-foreground truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
</div>
</div>
`;
}).join('');
}).join('');
}
function renderQuickInstallCard(templateId, title, description, event, matcher) {
const isInstalled = isHookTemplateInstalled(templateId);
return `
<div class="hook-template-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isInstalled ? 'border-success bg-success-light/30' : ''}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${isInstalled ? '✅' : '🪝'}</span>
<div>
<h4 class="font-semibold text-foreground">${escapeHtml(title)}</h4>
<p class="text-xs text-muted-foreground">${escapeHtml(description)}</p>
</div>
</div>
</div>
<div class="hook-template-meta text-xs text-muted-foreground mb-3 flex items-center gap-3">
<span class="flex items-center gap-1">
<span class="font-mono bg-muted px-1 py-0.5 rounded">${event}</span>
</span>
<span class="flex items-center gap-1">
Matches: <span class="font-medium">${matcher}</span>
</span>
</div>
<div class="flex items-center gap-2">
${isInstalled ? `
<button class="flex-1 px-3 py-1.5 text-sm bg-destructive/10 text-destructive rounded hover:bg-destructive/20 transition-colors"
data-template="${templateId}"
data-action="uninstall">
Uninstall
</button>
` : `
<button class="flex-1 px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-template="${templateId}"
data-action="install-project">
Install (Project)
</button>
<button class="px-3 py-1.5 text-sm bg-muted text-foreground rounded hover:bg-hover transition-colors"
data-template="${templateId}"
data-action="install-global">
Global
</button>
`}
</div>
</div>
`;
}
function isHookTemplateInstalled(templateId) {
const template = HOOK_TEMPLATES[templateId];
if (!template) return false;
// Check project hooks
const projectHooks = hookConfig.project?.hooks?.[template.event];
if (projectHooks) {
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
if (hookList.some(h => h.command === template.command)) return true;
}
// Check global hooks
const globalHooks = hookConfig.global?.hooks?.[template.event];
if (globalHooks) {
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
if (hookList.some(h => h.command === template.command)) return true;
}
return false;
}
async function installHookTemplate(templateId, scope) {
const template = HOOK_TEMPLATES[templateId];
if (!template) {
showRefreshToast('Template not found', 'error');
return;
}
const hookData = {
command: template.command,
args: template.args
};
if (template.matcher) {
hookData.matcher = template.matcher;
}
await saveHook(scope, template.event, hookData);
}
async function uninstallHookTemplate(templateId) {
const template = HOOK_TEMPLATES[templateId];
if (!template) return;
// Find and remove from project hooks
const projectHooks = hookConfig.project?.hooks?.[template.event];
if (projectHooks) {
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
const index = hookList.findIndex(h => h.command === template.command);
if (index !== -1) {
await removeHook('project', template.event, index);
return;
}
}
// Find and remove from global hooks
const globalHooks = hookConfig.global?.hooks?.[template.event];
if (globalHooks) {
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
const index = hookList.findIndex(h => h.command === template.command);
if (index !== -1) {
await removeHook('global', template.event, index);
return;
}
}
}
function attachHookEventListeners() {
// Edit buttons
document.querySelectorAll('.hook-card button[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const scope = e.target.dataset.scope;
const event = e.target.dataset.event;
const index = parseInt(e.target.dataset.index);
const hooks = scope === 'global' ? hookConfig.global.hooks : hookConfig.project.hooks;
const hookList = Array.isArray(hooks[event]) ? hooks[event] : [hooks[event]];
const hook = hookList[index];
if (hook) {
openHookCreateModal({
scope: scope,
event: event,
index: index,
matcher: hook.matcher || '',
command: hook.command,
args: hook.args || []
});
}
});
});
// Delete buttons
document.querySelectorAll('.hook-card button[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const scope = e.target.dataset.scope;
const event = e.target.dataset.event;
const index = parseInt(e.target.dataset.index);
if (confirm(`Remove this ${event} hook?`)) {
await removeHook(scope, event, index);
}
});
});
// Install project buttons
document.querySelectorAll('button[data-action="install-project"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await installHookTemplate(templateId, 'project');
});
});
// Install global buttons
document.querySelectorAll('button[data-action="install-global"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await installHookTemplate(templateId, 'global');
});
});
// Uninstall buttons
document.querySelectorAll('button[data-action="uninstall"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const templateId = e.target.dataset.template;
await uninstallHookTemplate(templateId);
});
});
}

View File

@@ -0,0 +1,390 @@
// ============================================
// LITE TASKS VIEW
// ============================================
// Lite-plan and lite-fix task list and detail rendering
function renderLiteTasks() {
const container = document.getElementById('mainContent');
const liteTasks = workflowData.liteTasks || {};
const sessions = currentLiteType === 'lite-plan'
? liteTasks.litePlan || []
: liteTasks.liteFix || [];
if (sessions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">⚡</div>
<div class="empty-title">No ${currentLiteType} Sessions</div>
<div class="empty-text">No sessions found in .workflow/.${currentLiteType}/</div>
</div>
`;
return;
}
container.innerHTML = `<div class="sessions-grid">${sessions.map(session => renderLiteTaskCard(session)).join('')}</div>`;
// Initialize collapsible sections
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
// Render flowcharts for expanded tasks
sessions.forEach(session => {
session.tasks?.forEach(task => {
if (task.flow_control?.implementation_approach) {
renderFlowchartForTask(session.id, task);
}
});
});
}
function renderLiteTaskCard(session) {
const tasks = session.tasks || [];
// Store session data for detail page
const sessionKey = `lite-${session.type}-${session.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
liteTaskDataStore[sessionKey] = session;
return `
<div class="session-card lite-task-card" onclick="showLiteTaskDetailPage('${sessionKey}')" style="cursor: pointer;">
<div class="session-header">
<div class="session-title">${escapeHtml(session.id)}</div>
<span class="session-status ${session.type}">
${session.type === 'lite-plan' ? '📝 PLAN' : '🔧 FIX'}
</span>
</div>
<div class="session-body">
<div class="session-meta">
<span class="session-meta-item">📅 ${formatDate(session.createdAt)}</span>
<span class="session-meta-item">📋 ${tasks.length} tasks</span>
</div>
</div>
</div>
`;
}
// Lite Task Detail Page
function showLiteTaskDetailPage(sessionKey) {
const session = liteTaskDataStore[sessionKey];
if (!session) return;
currentView = 'liteTaskDetail';
currentSessionDetailKey = sessionKey;
// Hide stats grid and carousel on detail pages
hideStatsAndCarousel();
// Also store in sessionDataStore for tab switching compatibility
sessionDataStore[sessionKey] = {
...session,
session_id: session.id,
created_at: session.createdAt,
path: session.path,
type: session.type
};
const container = document.getElementById('mainContent');
const tasks = session.tasks || [];
const plan = session.plan || {};
const progress = session.progress || { total: 0, completed: 0, percentage: 0 };
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
container.innerHTML = `
<div class="session-detail-page lite-task-detail-page">
<!-- Header -->
<div class="detail-header">
<button class="btn-back" onclick="goBackToLiteTasks()">
<span class="back-icon">←</span>
<span>Back to ${session.type === 'lite-plan' ? 'Lite Plan' : 'Lite Fix'}</span>
</button>
<div class="detail-title-row">
<h2 class="detail-session-id">${session.type === 'lite-plan' ? '📝' : '🔧'} ${escapeHtml(session.id)}</h2>
<div class="detail-badges">
<span class="session-type-badge ${session.type}">${session.type}</span>
</div>
</div>
</div>
<!-- Session Info Bar -->
<div class="detail-info-bar">
<div class="info-item">
<span class="info-label">Created:</span>
<span class="info-value">${formatDate(session.createdAt)}</span>
</div>
<div class="info-item">
<span class="info-label">Tasks:</span>
<span class="info-value">${tasks.length} tasks</span>
</div>
</div>
<!-- Tab Navigation -->
<div class="detail-tabs">
<button class="detail-tab active" data-tab="tasks" onclick="switchLiteDetailTab('tasks')">
<span class="tab-icon">📋</span>
<span class="tab-text">Tasks</span>
<span class="tab-count">${tasks.length}</span>
</button>
<button class="detail-tab" data-tab="plan" onclick="switchLiteDetailTab('plan')">
<span class="tab-icon">📐</span>
<span class="tab-text">Plan</span>
</button>
<button class="detail-tab" data-tab="context" onclick="switchLiteDetailTab('context')">
<span class="tab-icon">📦</span>
<span class="tab-text">Context</span>
</button>
<button class="detail-tab" data-tab="summary" onclick="switchLiteDetailTab('summary')">
<span class="tab-icon">📝</span>
<span class="tab-text">Summary</span>
</button>
</div>
<!-- Tab Content -->
<div class="detail-tab-content" id="liteDetailTabContent">
${renderLiteTasksTab(session, tasks, completed, inProgress, pending)}
</div>
</div>
`;
// Initialize collapsible sections
setTimeout(() => {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
}, 50);
}
function goBackToLiteTasks() {
currentView = 'liteTasks';
currentSessionDetailKey = null;
updateContentTitle();
showStatsAndSearch();
renderLiteTasks();
}
function switchLiteDetailTab(tabName) {
// Update active tab
document.querySelectorAll('.detail-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
const session = liteTaskDataStore[currentSessionDetailKey];
if (!session) return;
const contentArea = document.getElementById('liteDetailTabContent');
const tasks = session.tasks || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
switch (tabName) {
case 'tasks':
contentArea.innerHTML = renderLiteTasksTab(session, tasks, completed, inProgress, pending);
// Re-initialize collapsible sections
setTimeout(() => {
document.querySelectorAll('.collapsible-header').forEach(header => {
header.addEventListener('click', () => toggleSection(header));
});
}, 50);
break;
case 'plan':
contentArea.innerHTML = renderLitePlanTab(session);
break;
case 'context':
loadAndRenderLiteContextTab(session, contentArea);
break;
case 'summary':
loadAndRenderLiteSummaryTab(session, contentArea);
break;
}
}
function renderLiteTasksTab(session, tasks, completed, inProgress, pending) {
// Populate drawer tasks for click-to-open functionality
currentDrawerTasks = tasks;
if (tasks.length === 0) {
return `
<div class="tab-empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">No Tasks</div>
<div class="empty-text">This session has no tasks defined.</div>
</div>
`;
}
return `
<div class="tasks-tab-content">
<div class="tasks-list" id="liteTasksListContent">
${tasks.map(task => renderLiteTaskDetailItem(session.id, task)).join('')}
</div>
</div>
`;
}
function renderLiteTaskDetailItem(sessionId, task) {
const rawTask = task._raw || task;
const taskJsonId = `task-json-${sessionId}-${task.id}`.replace(/[^a-zA-Z0-9-]/g, '-');
taskJsonStore[taskJsonId] = rawTask;
// Get preview info for lite tasks
const action = rawTask.action || '';
const scope = rawTask.scope || '';
const modCount = rawTask.modification_points?.length || 0;
const implCount = rawTask.implementation?.length || 0;
const acceptCount = rawTask.acceptance?.length || 0;
return `
<div class="detail-task-item-full lite-task-item" onclick="openTaskDrawerForLite('${sessionId}', '${escapeHtml(task.id)}')" style="cursor: pointer;" title="Click to view details">
<div class="task-item-header-lite">
<span class="task-id-badge">${escapeHtml(task.id)}</span>
<span class="task-title">${escapeHtml(task.title || 'Untitled')}</span>
<button class="btn-view-json" onclick="event.stopPropagation(); showJsonModal('${taskJsonId}', '${escapeHtml(task.id)}')">{ } JSON</button>
</div>
<div class="task-item-meta-lite">
${action ? `<span class="meta-badge action">${escapeHtml(action)}</span>` : ''}
${scope ? `<span class="meta-badge scope">${escapeHtml(scope)}</span>` : ''}
${modCount > 0 ? `<span class="meta-badge mods">${modCount} mods</span>` : ''}
${implCount > 0 ? `<span class="meta-badge impl">${implCount} steps</span>` : ''}
${acceptCount > 0 ? `<span class="meta-badge accept">${acceptCount} acceptance</span>` : ''}
</div>
</div>
`;
}
function getMetaPreviewForLite(task, rawTask) {
const meta = task.meta || {};
const parts = [];
if (meta.type || rawTask.action) parts.push(meta.type || rawTask.action);
if (meta.scope || rawTask.scope) parts.push(meta.scope || rawTask.scope);
return parts.join(' | ') || 'No meta';
}
function openTaskDrawerForLite(sessionId, taskId) {
const session = liteTaskDataStore[currentSessionDetailKey];
if (!session) return;
const task = session.tasks?.find(t => t.id === taskId);
if (!task) return;
// Set current drawer tasks and session context
currentDrawerTasks = session.tasks || [];
window._currentDrawerSession = session;
document.getElementById('drawerTaskTitle').textContent = task.title || taskId;
// Use dedicated lite task drawer renderer
document.getElementById('drawerContent').innerHTML = renderLiteTaskDrawerContent(task, session);
document.getElementById('taskDetailDrawer').classList.add('open');
document.getElementById('drawerOverlay').classList.add('active');
}
function renderLitePlanTab(session) {
const plan = session.plan;
if (!plan) {
return `
<div class="tab-empty-state">
<div class="empty-icon">📐</div>
<div class="empty-title">No Plan Data</div>
<div class="empty-text">No plan.json found for this session.</div>
</div>
`;
}
return `
<div class="plan-tab-content">
<!-- Summary -->
${plan.summary ? `
<div class="plan-section">
<h4 class="plan-section-title">📋 Summary</h4>
<p class="plan-summary-text">${escapeHtml(plan.summary)}</p>
</div>
` : ''}
<!-- Approach -->
${plan.approach ? `
<div class="plan-section">
<h4 class="plan-section-title">🎯 Approach</h4>
<p class="plan-approach-text">${escapeHtml(plan.approach)}</p>
</div>
` : ''}
<!-- Focus Paths -->
${plan.focus_paths?.length ? `
<div class="plan-section">
<h4 class="plan-section-title">📁 Focus Paths</h4>
<div class="path-tags">
${plan.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}
</div>
</div>
` : ''}
<!-- Metadata -->
<div class="plan-section">
<h4 class="plan-section-title"> Metadata</h4>
<div class="plan-meta-grid">
${plan.estimated_time ? `<div class="meta-item"><span class="meta-label">Estimated Time:</span> ${escapeHtml(plan.estimated_time)}</div>` : ''}
${plan.complexity ? `<div class="meta-item"><span class="meta-label">Complexity:</span> ${escapeHtml(plan.complexity)}</div>` : ''}
${plan.recommended_execution ? `<div class="meta-item"><span class="meta-label">Execution:</span> ${escapeHtml(plan.recommended_execution)}</div>` : ''}
</div>
</div>
<!-- Raw JSON -->
<div class="plan-section">
<h4 class="plan-section-title">{ } Raw JSON</h4>
<pre class="json-content">${escapeHtml(JSON.stringify(plan, null, 2))}</pre>
</div>
</div>
`;
}
async function loadAndRenderLiteContextTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading context data...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderLiteContextContent(data.context, data.explorations, session);
// Re-initialize collapsible sections for explorations (scoped to contentArea)
initCollapsibleSections(contentArea);
return;
}
}
// Fallback: show plan context if available
contentArea.innerHTML = renderLiteContextContent(null, null, session);
initCollapsibleSections(contentArea);
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
}
}
async function loadAndRenderLiteSummaryTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading summaries...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderSummaryContent(data.summaries);
return;
}
}
// Fallback
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">📝</div>
<div class="empty-title">No Summaries</div>
<div class="empty-text">No summaries found in .summaries/</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load summaries: ${err.message}</div>`;
}
}

View File

@@ -0,0 +1,271 @@
// MCP Manager View
// Renders the MCP server management interface
async function renderMcpManager() {
const container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for MCP view
const statsGrid = document.getElementById('statsGrid');
const searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load MCP config if not already loaded
if (!mcpConfig) {
await loadMcpConfig();
}
const currentPath = projectPath.replace(/\//g, '\\');
const projectData = mcpAllProjects[currentPath] || {};
const projectServers = projectData.mcpServers || {};
const disabledServers = projectData.disabledMcpServers || [];
// Get all available servers from all projects
const allAvailableServers = getAllAvailableMcpServers();
// Separate current project servers and available servers
const currentProjectServerNames = Object.keys(projectServers);
const otherAvailableServers = Object.entries(allAvailableServers)
.filter(([name]) => !currentProjectServerNames.includes(name));
container.innerHTML = `
<div class="mcp-manager">
<!-- Current Project MCP Servers -->
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Current Project MCP Servers</h3>
<button class="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="openMcpCreateModal()">
<span>+</span> New Server
</button>
</div>
<span class="text-sm text-muted-foreground">${currentProjectServerNames.length} servers configured</span>
</div>
${currentProjectServerNames.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<div class="text-3xl mb-3">🔌</div>
<p class="text-muted-foreground">No MCP servers configured for this project</p>
<p class="text-sm text-muted-foreground mt-1">Add servers from the available list below</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${currentProjectServerNames.map(serverName => {
const serverConfig = projectServers[serverName];
const isEnabled = !disabledServers.includes(serverName);
return renderMcpServerCard(serverName, serverConfig, isEnabled, true);
}).join('')}
</div>
`}
</div>
<!-- Available MCP Servers from Other Projects -->
<div class="mcp-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">Available from Other Projects</h3>
<span class="text-sm text-muted-foreground">${otherAvailableServers.length} servers available</span>
</div>
${otherAvailableServers.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<p class="text-muted-foreground">No additional MCP servers found in other projects</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${otherAvailableServers.map(([serverName, serverInfo]) => {
return renderAvailableServerCard(serverName, serverInfo);
}).join('')}
</div>
`}
</div>
<!-- All Projects MCP Overview Table -->
<div class="mcp-section mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">All Projects MCP Overview</h3>
<span class="text-sm text-muted-foreground">${Object.keys(mcpAllProjects).length} projects</span>
</div>
<div class="mcp-projects-table bg-card border border-border rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-muted/50">
<tr>
<th class="text-left px-4 py-3 text-sm font-semibold text-foreground border-b border-border">Project</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-foreground border-b border-border">MCP Servers</th>
<th class="text-center px-4 py-3 text-sm font-semibold text-foreground border-b border-border w-24">Status</th>
</tr>
</thead>
<tbody>
${Object.entries(mcpAllProjects).map(([path, config]) => {
const servers = config.mcpServers || {};
const projectDisabled = config.disabledMcpServers || [];
const serverNames = Object.keys(servers);
const isCurrentProject = path === currentPath;
const enabledCount = serverNames.filter(s => !projectDisabled.includes(s)).length;
return `
<tr class="border-b border-border last:border-b-0 ${isCurrentProject ? 'bg-primary/5' : 'hover:bg-hover/50'}">
<td class="px-4 py-3">
<div class="flex items-center gap-2 min-w-0">
<span class="text-base shrink-0">${isCurrentProject ? '📍' : '📁'}</span>
<div class="min-w-0">
<div class="font-medium text-foreground truncate text-sm" title="${escapeHtml(path)}">
${escapeHtml(path.split('\\').pop() || path)}
${isCurrentProject ? '<span class="ml-2 text-xs text-primary font-medium">(Current)</span>' : ''}
</div>
<div class="text-xs text-muted-foreground truncate">${escapeHtml(path)}</div>
</div>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1.5">
${serverNames.length === 0
? '<span class="text-xs text-muted-foreground italic">No MCP servers</span>'
: serverNames.map(serverName => {
const isEnabled = !projectDisabled.includes(serverName);
return `
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${isEnabled ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">
<span class="w-1.5 h-1.5 rounded-full ${isEnabled ? 'bg-success' : 'bg-muted-foreground'}"></span>
${escapeHtml(serverName)}
</span>
`;
}).join('')
}
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full ${serverNames.length > 0 ? 'bg-success-light text-success' : 'bg-hover text-muted-foreground'}">
${enabledCount}/${serverNames.length}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
</div>
</div>
`;
// Attach event listeners for toggle switches
attachMcpEventListeners();
}
function renderMcpServerCard(serverName, serverConfig, isEnabled, isInCurrentProject) {
const command = serverConfig.command || 'N/A';
const args = serverConfig.args || [];
const hasEnv = serverConfig.env && Object.keys(serverConfig.env).length > 0;
return `
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all ${isEnabled ? '' : 'opacity-60'}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">${isEnabled ? '🟢' : '🔴'}</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
</div>
<label class="mcp-toggle relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
${isEnabled ? 'checked' : ''}
data-server-name="${escapeHtml(serverName)}"
data-action="toggle">
<div class="w-9 h-5 bg-hover peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-success"></div>
</label>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${args.length > 0 ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">args</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(args.slice(0, 3).join(' '))}${args.length > 3 ? '...' : ''}</span>
</div>
` : ''}
${hasEnv ? `
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">env</span>
<span class="text-xs">${Object.keys(serverConfig.env).length} variables</span>
</div>
` : ''}
</div>
${isInCurrentProject ? `
<div class="mt-3 pt-3 border-t border-border">
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove">
Remove from project
</button>
</div>
` : ''}
</div>
`;
}
function renderAvailableServerCard(serverName, serverInfo) {
const serverConfig = serverInfo.config;
const usedIn = serverInfo.usedIn || [];
const command = serverConfig.command || 'N/A';
return `
<div class="mcp-server-card mcp-server-available bg-card border border-border border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-xl">⚪</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
</div>
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-action="add">
Add
</button>
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">cmd</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
<div class="flex items-center gap-2 text-muted-foreground">
<span class="text-xs">Used in ${usedIn.length} project${usedIn.length !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
`;
}
function attachMcpEventListeners() {
// Toggle switches
document.querySelectorAll('.mcp-server-card input[data-action="toggle"]').forEach(input => {
input.addEventListener('change', async (e) => {
const serverName = e.target.dataset.serverName;
const enable = e.target.checked;
await toggleMcpServer(serverName, enable);
});
});
// Add buttons
document.querySelectorAll('.mcp-server-card button[data-action="add"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = e.target.dataset.serverName;
const serverConfig = JSON.parse(e.target.dataset.serverConfig);
await copyMcpServerToProject(serverName, serverConfig);
});
});
// Remove buttons
document.querySelectorAll('.mcp-server-card button[data-action="remove"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = e.target.dataset.serverName;
if (confirm(`Remove MCP server "${serverName}" from this project?`)) {
await removeMcpServerFromProject(serverName);
}
});
});
}

View File

@@ -0,0 +1,246 @@
// ==========================================
// PROJECT OVERVIEW VIEW
// ==========================================
function renderProjectOverview() {
// Show stats grid and search (may be hidden by MCP view)
if (typeof showStatsAndSearch === 'function') showStatsAndSearch();
const container = document.getElementById('mainContent');
const project = workflowData.projectOverview;
if (!project) {
container.innerHTML = `
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="text-6xl mb-4">📋</div>
<h3 class="text-xl font-semibold text-foreground mb-2">No Project Overview</h3>
<p class="text-muted-foreground mb-4">
Run <code class="px-2 py-1 bg-muted rounded text-sm font-mono">/workflow:init</code> to initialize project analysis
</p>
</div>
`;
return;
}
container.innerHTML = `
<!-- Project Header -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-2xl font-bold text-foreground mb-2">${escapeHtml(project.projectName)}</h2>
<p class="text-muted-foreground">${escapeHtml(project.description || 'No description available')}</p>
</div>
<div class="text-sm text-muted-foreground text-right">
<div>Initialized: ${formatDate(project.initializedAt)}</div>
<div class="mt-1">Mode: <span class="font-mono text-xs px-2 py-0.5 bg-muted rounded">${escapeHtml(project.metadata?.analysis_mode || 'unknown')}</span></div>
</div>
</div>
</div>
<!-- Technology Stack -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span>💻</span> Technology Stack
</h3>
<!-- Languages -->
<div class="mb-5">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Languages</h4>
<div class="flex flex-wrap gap-3">
${project.technologyStack.languages.map(lang => `
<div class="flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg ${lang.primary ? 'ring-2 ring-primary' : ''}">
<span class="font-semibold text-foreground">${escapeHtml(lang.name)}</span>
<span class="text-xs text-muted-foreground">${lang.file_count} files</span>
${lang.primary ? '<span class="text-xs px-1.5 py-0.5 bg-primary text-primary-foreground rounded">Primary</span>' : ''}
</div>
`).join('') || '<span class="text-muted-foreground text-sm">No languages detected</span>'}
</div>
</div>
<!-- Frameworks -->
<div class="mb-5">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Frameworks</h4>
<div class="flex flex-wrap gap-2">
${project.technologyStack.frameworks.map(fw => `
<span class="px-3 py-1.5 bg-success-light text-success rounded-lg text-sm font-medium">${escapeHtml(fw)}</span>
`).join('') || '<span class="text-muted-foreground text-sm">No frameworks detected</span>'}
</div>
</div>
<!-- Build Tools -->
<div class="mb-5">
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Build Tools</h4>
<div class="flex flex-wrap gap-2">
${project.technologyStack.build_tools.map(tool => `
<span class="px-3 py-1.5 bg-warning-light text-warning rounded-lg text-sm font-medium">${escapeHtml(tool)}</span>
`).join('') || '<span class="text-muted-foreground text-sm">No build tools detected</span>'}
</div>
</div>
<!-- Test Frameworks -->
<div>
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">Test Frameworks</h4>
<div class="flex flex-wrap gap-2">
${project.technologyStack.test_frameworks.map(fw => `
<span class="px-3 py-1.5 bg-accent text-accent-foreground rounded-lg text-sm font-medium">${escapeHtml(fw)}</span>
`).join('') || '<span class="text-muted-foreground text-sm">No test frameworks detected</span>'}
</div>
</div>
</div>
<!-- Architecture -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span>🏗️</span> Architecture
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<!-- Style -->
<div>
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Style</h4>
<div class="px-3 py-2 bg-background border border-border rounded-lg">
<span class="text-foreground font-medium">${escapeHtml(project.architecture.style)}</span>
</div>
</div>
<!-- Layers -->
<div>
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Layers</h4>
<div class="flex flex-wrap gap-2">
${project.architecture.layers.map(layer => `
<span class="px-2 py-1 bg-muted text-foreground rounded text-sm">${escapeHtml(layer)}</span>
`).join('') || '<span class="text-muted-foreground text-sm">None</span>'}
</div>
</div>
<!-- Patterns -->
<div>
<h4 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">Patterns</h4>
<div class="flex flex-wrap gap-2">
${project.architecture.patterns.map(pattern => `
<span class="px-2 py-1 bg-muted text-foreground rounded text-sm">${escapeHtml(pattern)}</span>
`).join('') || '<span class="text-muted-foreground text-sm">None</span>'}
</div>
</div>
</div>
</div>
<!-- Key Components -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span>⚙️</span> Key Components
</h3>
${project.keyComponents.length > 0 ? `
<div class="space-y-3">
${project.keyComponents.map(comp => {
const importanceColors = {
high: 'border-l-4 border-l-destructive bg-destructive/5',
medium: 'border-l-4 border-l-warning bg-warning/5',
low: 'border-l-4 border-l-muted-foreground bg-muted'
};
const importanceBadges = {
high: '<span class="px-2 py-0.5 text-xs font-semibold bg-destructive text-destructive-foreground rounded">High</span>',
medium: '<span class="px-2 py-0.5 text-xs font-semibold bg-warning text-foreground rounded">Medium</span>',
low: '<span class="px-2 py-0.5 text-xs font-semibold bg-muted text-muted-foreground rounded">Low</span>'
};
return `
<div class="p-4 ${importanceColors[comp.importance] || importanceColors.low} rounded-lg">
<div class="flex items-start justify-between mb-2">
<h4 class="font-semibold text-foreground">${escapeHtml(comp.name)}</h4>
${importanceBadges[comp.importance] || ''}
</div>
<p class="text-sm text-muted-foreground mb-2">${escapeHtml(comp.description)}</p>
<code class="text-xs font-mono text-primary">${escapeHtml(comp.path)}</code>
</div>
`;
}).join('')}
</div>
` : '<p class="text-muted-foreground text-sm">No key components identified</p>'}
</div>
<!-- Development Index -->
<div class="bg-card border border-border rounded-lg p-6 mb-6">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span>📝</span> Development History
</h3>
${renderDevelopmentIndex(project.developmentIndex)}
</div>
<!-- Statistics -->
<div class="bg-card border border-border rounded-lg p-6">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span>📊</span> Statistics
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="text-center p-4 bg-background rounded-lg">
<div class="text-3xl font-bold text-primary mb-1">${project.statistics.total_features || 0}</div>
<div class="text-sm text-muted-foreground">Total Features</div>
</div>
<div class="text-center p-4 bg-background rounded-lg">
<div class="text-3xl font-bold text-success mb-1">${project.statistics.total_sessions || 0}</div>
<div class="text-sm text-muted-foreground">Total Sessions</div>
</div>
<div class="text-center p-4 bg-background rounded-lg">
<div class="text-sm text-muted-foreground mb-1">Last Updated</div>
<div class="text-sm font-medium text-foreground">${formatDate(project.statistics.last_updated)}</div>
</div>
</div>
</div>
`;
}
function renderDevelopmentIndex(devIndex) {
if (!devIndex) return '<p class="text-muted-foreground text-sm">No development history available</p>';
const categories = [
{ key: 'feature', label: 'Features', icon: '✨', badgeClass: 'bg-primary-light text-primary' },
{ key: 'enhancement', label: 'Enhancements', icon: '⚡', badgeClass: 'bg-success-light text-success' },
{ key: 'bugfix', label: 'Bug Fixes', icon: '🐛', badgeClass: 'bg-destructive/10 text-destructive' },
{ key: 'refactor', label: 'Refactorings', icon: '🔧', badgeClass: 'bg-warning-light text-warning' },
{ key: 'docs', label: 'Documentation', icon: '📚', badgeClass: 'bg-muted text-muted-foreground' }
];
const totalEntries = categories.reduce((sum, cat) => sum + (devIndex[cat.key]?.length || 0), 0);
if (totalEntries === 0) {
return '<p class="text-muted-foreground text-sm">No development history entries</p>';
}
return `
<div class="space-y-4">
${categories.map(cat => {
const entries = devIndex[cat.key] || [];
if (entries.length === 0) return '';
return `
<div>
<h4 class="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<span>${cat.icon}</span>
<span>${cat.label}</span>
<span class="text-xs px-2 py-0.5 ${cat.badgeClass} rounded-full">${entries.length}</span>
</h4>
<div class="space-y-2">
${entries.slice(0, 5).map(entry => `
<div class="p-3 bg-background border border-border rounded-lg hover:shadow-sm transition-shadow">
<div class="flex items-start justify-between mb-1">
<h5 class="font-medium text-foreground text-sm">${escapeHtml(entry.title)}</h5>
<span class="text-xs text-muted-foreground">${formatDate(entry.date)}</span>
</div>
${entry.description ? `<p class="text-sm text-muted-foreground mb-1">${escapeHtml(entry.description)}</p>` : ''}
<div class="flex items-center gap-2 text-xs">
${entry.sub_feature ? `<span class="px-2 py-0.5 bg-muted rounded">${escapeHtml(entry.sub_feature)}</span>` : ''}
${entry.status ? `<span class="px-2 py-0.5 ${entry.status === 'completed' ? 'bg-success-light text-success' : 'bg-warning-light text-warning'} rounded">${escapeHtml(entry.status)}</span>` : ''}
</div>
</div>
`).join('')}
${entries.length > 5 ? `<div class="text-sm text-muted-foreground text-center py-2">... and ${entries.length - 5} more</div>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,770 @@
// ============================================
// SESSION DETAIL VIEW
// ============================================
// Standard workflow session detail page rendering
function showSessionDetailPage(sessionKey) {
const session = sessionDataStore[sessionKey];
if (!session) return;
currentView = 'sessionDetail';
currentSessionDetailKey = sessionKey;
updateContentTitle();
// Hide stats grid and carousel on detail pages
hideStatsAndCarousel();
const container = document.getElementById('mainContent');
const sessionType = session.type || 'workflow';
// Render specialized pages for review and test-fix sessions
if (sessionType === 'review' || sessionType === 'review-cycle') {
container.innerHTML = renderReviewSessionDetailPage(session);
initReviewSessionPage(session);
return;
}
if (sessionType === 'test-fix' || sessionType === 'fix') {
container.innerHTML = renderFixSessionDetailPage(session);
initFixSessionPage(session);
return;
}
// Default workflow session detail page
const tasks = session.tasks || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
const isActive = session._isActive !== false;
container.innerHTML = `
<div class="session-detail-page">
<!-- Header -->
<div class="detail-header">
<button class="btn-back" onclick="goBackToSessions()">
<span class="back-icon">←</span>
<span>Back to Sessions</span>
</button>
<div class="detail-title-row">
<h2 class="detail-session-id">${escapeHtml(session.session_id)}</h2>
<div class="detail-badges">
<span class="session-type-badge ${session.type || 'workflow'}">${session.type || 'workflow'}</span>
<span class="session-status ${isActive ? 'active' : 'archived'}">
${isActive ? 'ACTIVE' : 'ARCHIVED'}
</span>
</div>
</div>
</div>
<!-- Session Info Bar -->
<div class="detail-info-bar">
<div class="info-item">
<span class="info-label">Created:</span>
<span class="info-value">${formatDate(session.created_at)}</span>
</div>
${session.archived_at ? `
<div class="info-item">
<span class="info-label">Archived:</span>
<span class="info-value">${formatDate(session.archived_at)}</span>
</div>
` : ''}
<div class="info-item">
<span class="info-label">Project:</span>
<span class="info-value">${escapeHtml(session.project || '-')}</span>
</div>
<div class="info-item">
<span class="info-label">Tasks:</span>
<span class="info-value">${completed}/${tasks.length} completed</span>
</div>
</div>
<!-- Tab Navigation -->
<div class="detail-tabs">
<button class="detail-tab active" data-tab="tasks" onclick="switchDetailTab('tasks')">
<span class="tab-icon">📋</span>
<span class="tab-text">Tasks</span>
<span class="tab-count">${tasks.length}</span>
</button>
<button class="detail-tab" data-tab="context" onclick="switchDetailTab('context')">
<span class="tab-icon">📦</span>
<span class="tab-text">Context</span>
</button>
<button class="detail-tab" data-tab="summary" onclick="switchDetailTab('summary')">
<span class="tab-icon">📝</span>
<span class="tab-text">Summary</span>
</button>
<button class="detail-tab" data-tab="impl-plan" onclick="switchDetailTab('impl-plan')">
<span class="tab-icon">📐</span>
<span class="tab-text">IMPL Plan</span>
</button>
<button class="detail-tab" data-tab="conflict" onclick="switchDetailTab('conflict')"> <span class="tab-icon">⚖️</span> <span class="tab-text">Conflict</span> </button>
${session.hasReview ? `
<button class="detail-tab" data-tab="review" onclick="switchDetailTab('review')">
<span class="tab-icon">🔍</span>
<span class="tab-text">Review</span>
</button>
` : ''}
</div>
<!-- Tab Content -->
<div class="detail-tab-content" id="detailTabContent">
${renderTasksTab(session, tasks, completed, inProgress, pending)}
</div>
</div>
`;
}
function goBackToSessions() {
currentView = 'sessions';
currentSessionDetailKey = null;
updateContentTitle();
showStatsAndSearch();
renderSessions();
}
function switchDetailTab(tabName) {
// Update active tab
document.querySelectorAll('.detail-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
const session = sessionDataStore[currentSessionDetailKey];
if (!session) return;
const contentArea = document.getElementById('detailTabContent');
const tasks = session.tasks || [];
const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const pending = tasks.filter(t => t.status === 'pending').length;
switch (tabName) {
case 'tasks':
contentArea.innerHTML = renderTasksTab(session, tasks, completed, inProgress, pending);
break;
case 'context':
loadAndRenderContextTab(session, contentArea);
break;
case 'summary':
loadAndRenderSummaryTab(session, contentArea);
break;
case 'impl-plan':
loadAndRenderImplPlanTab(session, contentArea);
break;
case 'review':
loadAndRenderReviewTab(session, contentArea);
break;
case 'conflict': loadAndRenderConflictTab(session, contentArea); break;
}
}
function renderTasksTab(session, tasks, completed, inProgress, pending) {
// Populate drawer tasks for click-to-open functionality
currentDrawerTasks = tasks;
// Auto-load full task details in server mode
if (window.SERVER_MODE && session.path) {
// Schedule auto-load after DOM render
setTimeout(() => loadFullTaskDetails(), 50);
}
// Show task list with loading state or basic list
const showLoading = window.SERVER_MODE && session.path;
return `
<div class="tasks-tab-content">
<!-- Combined Stats & Actions Bar -->
<div class="task-toolbar">
<div class="task-stats-bar">
<span class="task-stat completed">✓ ${completed} completed</span>
<span class="task-stat in-progress">⟳ ${inProgress} in progress</span>
<span class="task-stat pending">○ ${pending} pending</span>
</div>
<div class="toolbar-divider"></div>
<div class="task-bulk-actions">
<span class="bulk-label">Quick Actions:</span>
<button class="bulk-action-btn" onclick="bulkSetAllStatus('pending')" title="Set all tasks to pending">
<span class="bulk-icon">○</span> All Pending
</button>
<button class="bulk-action-btn" onclick="bulkSetAllStatus('in_progress')" title="Set all tasks to in progress">
<span class="bulk-icon">⟳</span> All In Progress
</button>
<button class="bulk-action-btn completed" onclick="bulkSetAllStatus('completed')" title="Set all tasks to completed">
<span class="bulk-icon">✓</span> All Completed
</button>
</div>
</div>
<div class="tasks-list" id="tasksListContent">
${showLoading ? `
<div class="tab-loading">Loading task details...</div>
` : (tasks.length === 0 ? `
<div class="tab-empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">No Tasks</div>
<div class="empty-text">This session has no tasks defined.</div>
</div>
` : tasks.map(task => renderDetailTaskItem(task)).join(''))}
</div>
</div>
`;
}
async function loadFullTaskDetails() {
const session = sessionDataStore[currentSessionDetailKey];
if (!session || !window.SERVER_MODE || !session.path) return;
const tasksContainer = document.getElementById('tasksListContent');
tasksContainer.innerHTML = '<div class="tab-loading">Loading full task details...</div>';
try {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=tasks`);
if (response.ok) {
const data = await response.json();
if (data.tasks && data.tasks.length > 0) {
// Populate drawer tasks for click-to-open functionality
currentDrawerTasks = data.tasks;
tasksContainer.innerHTML = data.tasks.map(task => renderDetailTaskItem(task)).join('');
} else {
tasksContainer.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">No Task Files</div>
<div class="empty-text">No IMPL-*.json files found in .task/</div>
</div>
`;
}
}
} catch (err) {
tasksContainer.innerHTML = `<div class="tab-error">Failed to load tasks: ${err.message}</div>`;
}
}
function renderDetailTaskItem(task) {
const taskId = task.task_id || task.id || 'Unknown';
const status = task.status || 'pending';
// Status options for dropdown
const statusOptions = ['pending', 'in_progress', 'completed'];
return `
<div class="detail-task-item ${status} status-${status}" data-task-id="${escapeHtml(taskId)}">
<div class="task-item-header">
<span class="task-id-badge">${escapeHtml(taskId)}</span>
<span class="task-title" onclick="openTaskDrawer('${escapeHtml(taskId)}')" style="cursor: pointer; flex: 1;">
${escapeHtml(task.title || task.meta?.title || 'Untitled')}
</span>
<div class="task-status-control" onclick="event.stopPropagation()">
<select class="task-status-select ${status}" onchange="updateSingleTaskStatus('${escapeHtml(taskId)}', this.value)" data-current="${status}">
${statusOptions.map(opt => `
<option value="${opt}" ${opt === status ? 'selected' : ''}>${formatStatusLabel(opt)}</option>
`).join('')}
</select>
</div>
</div>
</div>
`;
}
function formatStatusLabel(status) {
const labels = {
'pending': '○ Pending',
'in_progress': '⟳ In Progress',
'completed': '✓ Completed'
};
return labels[status] || status;
}
function getMetaPreview(task) {
const meta = task.meta || {};
const parts = [];
if (meta.type) parts.push(meta.type);
if (meta.action) parts.push(meta.action);
if (meta.scope) parts.push(meta.scope);
return parts.join(' | ') || 'No meta';
}
function getTaskContextPreview(task) {
const items = [];
const ctx = task.context || {};
if (ctx.requirements?.length) items.push(`${ctx.requirements.length} reqs`);
if (ctx.focus_paths?.length) items.push(`${ctx.focus_paths.length} paths`);
if (task.modification_points?.length) items.push(`${task.modification_points.length} mods`);
if (task.description) items.push('desc');
return items.join(' | ') || 'No context';
}
function getFlowPreview(task) {
const steps = task.flow_control?.implementation_approach?.length || task.implementation?.length || 0;
return steps > 0 ? `${steps} steps` : 'No steps';
}
function renderTaskContext(task) {
const sections = [];
const ctx = task.context || {};
// Description
if (task.description) {
sections.push(`
<div class="context-field">
<label>description:</label>
<p>${escapeHtml(task.description)}</p>
</div>
`);
}
// Requirements
if (ctx.requirements?.length) {
sections.push(`
<div class="context-field">
<label>requirements:</label>
<ul>${ctx.requirements.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>
`);
}
// Focus paths
if (ctx.focus_paths?.length) {
sections.push(`
<div class="context-field">
<label>focus_paths:</label>
<div class="path-tags">${ctx.focus_paths.map(p => `<span class="path-tag">${escapeHtml(p)}</span>`).join('')}</div>
</div>
`);
}
// Modification points
if (task.modification_points?.length) {
sections.push(`
<div class="context-field">
<label>modification_points:</label>
<div class="mod-points">
${task.modification_points.map(m => `
<div class="mod-point">
<span class="array-item path-item">${escapeHtml(m.file || m)}</span>
${m.target ? `<span class="mod-target">→ ${escapeHtml(m.target)}</span>` : ''}
${m.change ? `<p class="mod-change">${escapeHtml(m.change)}</p>` : ''}
</div>
`).join('')}
</div>
</div>
`);
}
// Acceptance criteria
const acceptance = ctx.acceptance || task.acceptance || [];
if (acceptance.length) {
sections.push(`
<div class="context-field">
<label>acceptance:</label>
<ul>${acceptance.map(a => `<li>${escapeHtml(a)}</li>`).join('')}</ul>
</div>
`);
}
return sections.length > 0
? `<div class="context-fields">${sections.join('')}</div>`
: '<div class="field-value json-value-null">No context data</div>';
}
function renderFlowControl(task) {
const sections = [];
const fc = task.flow_control || {};
// Implementation approach
const steps = fc.implementation_approach || task.implementation || [];
if (steps.length) {
sections.push(`
<div class="context-field">
<label>implementation_approach:</label>
<ol class="impl-steps">
${steps.map(s => `<li>${escapeHtml(typeof s === 'string' ? s : s.step || s.action || JSON.stringify(s))}</li>`).join('')}
</ol>
</div>
`);
}
// Pre-analysis
const preAnalysis = fc.pre_analysis || task.pre_analysis || [];
if (preAnalysis.length) {
sections.push(`
<div class="context-field">
<label>pre_analysis:</label>
<ul>${preAnalysis.map(p => `<li>${escapeHtml(p)}</li>`).join('')}</ul>
</div>
`);
}
// Target files
const targetFiles = fc.target_files || task.target_files || [];
if (targetFiles.length) {
sections.push(`
<div class="context-field">
<label>target_files:</label>
<div class="path-tags">${targetFiles.map(f => `<span class="path-tag">${escapeHtml(f)}</span>`).join('')}</div>
</div>
`);
}
return sections.length > 0
? `<div class="context-fields">${sections.join('')}</div>`
: '<div class="field-value json-value-null">No flow control data</div>';
}
async function loadAndRenderContextTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading context data...</div>';
try {
// Try to load context data from server (includes context, explorations, conflictResolution)
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=context`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderSessionContextContent(data.context, data.explorations, data.conflictResolution);
// Initialize collapsible sections for explorations
initCollapsibleSections(contentArea);
return;
}
}
// Fallback: show placeholder
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">📦</div>
<div class="empty-title">Context Data</div>
<div class="empty-text">Context data will be loaded from context-package.json</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load context: ${err.message}</div>`;
}
}
async function loadAndRenderSummaryTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading summaries...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderSummaryContent(data.summaries);
return;
}
}
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">📝</div>
<div class="empty-title">Summaries</div>
<div class="empty-text">Session summaries will be loaded from .summaries/</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load summaries: ${err.message}</div>`;
}
}
async function loadAndRenderImplPlanTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading IMPL plan...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=impl-plan`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderImplPlanContent(data.implPlan);
return;
}
}
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">📐</div>
<div class="empty-title">IMPL Plan</div>
<div class="empty-text">IMPL plan will be loaded from IMPL_PLAN.md</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load IMPL plan: ${err.message}</div>`;
}
}
async function loadAndRenderReviewTab(session, contentArea) {
contentArea.innerHTML = '<div class="tab-loading">Loading review data...</div>';
try {
if (window.SERVER_MODE && session.path) {
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=review`);
if (response.ok) {
const data = await response.json();
contentArea.innerHTML = renderReviewContent(data.review);
return;
}
}
contentArea.innerHTML = `
<div class="tab-empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-title">Review Data</div>
<div class="empty-text">Review data will be loaded from review files</div>
</div>
`;
} catch (err) {
contentArea.innerHTML = `<div class="tab-error">Failed to load review: ${err.message}</div>`;
}
}
function showRawSessionJson(sessionKey) {
const session = sessionDataStore[sessionKey];
if (!session) return;
// Close current modal
const currentModal = document.querySelector('.session-modal-overlay');
if (currentModal) currentModal.remove();
// Show JSON modal
const overlay = document.createElement('div');
overlay.className = 'json-modal-overlay active';
overlay.innerHTML = `
<div class="json-modal">
<div class="json-modal-header">
<div class="json-modal-title">
<span class="session-id-badge">${escapeHtml(session.session_id)}</span>
<span>Session JSON</span>
</div>
<button class="json-modal-close" onclick="closeJsonModal(this)">&times;</button>
</div>
<div class="json-modal-body">
<pre class="json-modal-content">${escapeHtml(JSON.stringify(session, null, 2))}</pre>
</div>
<div class="json-modal-footer">
<button class="json-modal-copy" onclick="copyJsonToClipboard(this)">Copy to Clipboard</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeJsonModal();
});
}
// ==========================================
// TASK STATUS MANAGEMENT
// ==========================================
async function updateSingleTaskStatus(taskId, newStatus) {
const session = sessionDataStore[currentSessionDetailKey];
if (!session || !window.SERVER_MODE || !session.path) {
showToast('Status update requires server mode', 'error');
return;
}
try {
const response = await fetch('/api/update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionPath: session.path,
taskId: taskId,
newStatus: newStatus
})
});
const result = await response.json();
if (result.success) {
// Update UI
updateTaskItemUI(taskId, newStatus);
updateTaskStatsBar();
showToast(`Task ${taskId}${formatStatusLabel(newStatus)}`, 'success');
} else {
showToast(result.error || 'Failed to update status', 'error');
// Revert select
revertTaskSelect(taskId);
}
} catch (error) {
showToast('Error updating status: ' + error.message, 'error');
revertTaskSelect(taskId);
}
}
async function bulkSetAllStatus(newStatus) {
const session = sessionDataStore[currentSessionDetailKey];
if (!session || !window.SERVER_MODE || !session.path) {
showToast('Bulk update requires server mode', 'error');
return;
}
const taskIds = currentDrawerTasks.map(t => t.task_id || t.id);
if (taskIds.length === 0) return;
if (!confirm(`Set all ${taskIds.length} tasks to "${formatStatusLabel(newStatus)}"?`)) {
return;
}
try {
const response = await fetch('/api/bulk-update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionPath: session.path,
taskIds: taskIds,
newStatus: newStatus
})
});
const result = await response.json();
if (result.success) {
// Update all task UIs
taskIds.forEach(id => updateTaskItemUI(id, newStatus));
updateTaskStatsBar();
showToast(`All ${taskIds.length} tasks → ${formatStatusLabel(newStatus)}`, 'success');
} else {
showToast(result.error || 'Failed to bulk update', 'error');
}
} catch (error) {
showToast('Error in bulk update: ' + error.message, 'error');
}
}
async function bulkSetPendingToInProgress() {
const session = sessionDataStore[currentSessionDetailKey];
if (!session || !window.SERVER_MODE || !session.path) {
showToast('Bulk update requires server mode', 'error');
return;
}
const pendingTaskIds = currentDrawerTasks
.filter(t => (t.status || 'pending') === 'pending')
.map(t => t.task_id || t.id);
if (pendingTaskIds.length === 0) {
showToast('No pending tasks to start', 'info');
return;
}
try {
const response = await fetch('/api/bulk-update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionPath: session.path,
taskIds: pendingTaskIds,
newStatus: 'in_progress'
})
});
const result = await response.json();
if (result.success) {
pendingTaskIds.forEach(id => updateTaskItemUI(id, 'in_progress'));
updateTaskStatsBar();
showToast(`${pendingTaskIds.length} tasks: Pending → In Progress`, 'success');
} else {
showToast(result.error || 'Failed to update', 'error');
}
} catch (error) {
showToast('Error: ' + error.message, 'error');
}
}
async function bulkSetInProgressToCompleted() {
const session = sessionDataStore[currentSessionDetailKey];
if (!session || !window.SERVER_MODE || !session.path) {
showToast('Bulk update requires server mode', 'error');
return;
}
const inProgressTaskIds = currentDrawerTasks
.filter(t => t.status === 'in_progress')
.map(t => t.task_id || t.id);
if (inProgressTaskIds.length === 0) {
showToast('No in-progress tasks to complete', 'info');
return;
}
try {
const response = await fetch('/api/bulk-update-task-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionPath: session.path,
taskIds: inProgressTaskIds,
newStatus: 'completed'
})
});
const result = await response.json();
if (result.success) {
inProgressTaskIds.forEach(id => updateTaskItemUI(id, 'completed'));
updateTaskStatsBar();
showToast(`${inProgressTaskIds.length} tasks: In Progress → Completed`, 'success');
} else {
showToast(result.error || 'Failed to update', 'error');
}
} catch (error) {
showToast('Error: ' + error.message, 'error');
}
}
function updateTaskItemUI(taskId, newStatus) {
const taskItem = document.querySelector(`.detail-task-item[data-task-id="${taskId}"]`);
if (!taskItem) return;
// Update classes
taskItem.className = `detail-task-item ${newStatus} status-${newStatus}`;
// Update select
const select = taskItem.querySelector('.task-status-select');
if (select) {
select.value = newStatus;
select.className = `task-status-select ${newStatus}`;
select.dataset.current = newStatus;
}
// Update drawer tasks data
const task = currentDrawerTasks.find(t => (t.task_id || t.id) === taskId);
if (task) {
task.status = newStatus;
}
}
function updateTaskStatsBar() {
const completed = currentDrawerTasks.filter(t => t.status === 'completed').length;
const inProgress = currentDrawerTasks.filter(t => t.status === 'in_progress').length;
const pending = currentDrawerTasks.filter(t => (t.status || 'pending') === 'pending').length;
const statsBar = document.querySelector('.task-stats-bar');
if (statsBar) {
statsBar.innerHTML = `
<span class="task-stat completed">✓ ${completed} completed</span>
<span class="task-stat in-progress">⟳ ${inProgress} in progress</span>
<span class="task-stat pending">○ ${pending} pending</span>
`;
}
}
function revertTaskSelect(taskId) {
const taskItem = document.querySelector(`.detail-task-item[data-task-id="${taskId}"]`);
if (!taskItem) return;
const select = taskItem.querySelector('.task-status-select');
if (select) {
select.value = select.dataset.current;
}
}
function showToast(message, type = 'info') {
// Remove existing toast
const existing = document.querySelector('.status-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `status-toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 300);
}, 3000);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,630 @@
<!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>
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ['class', '[data-theme="dark"]'],
safelist: [
// Background colors
'bg-card', 'bg-background', 'bg-hover', 'bg-accent', 'bg-muted', 'bg-primary', 'bg-success', 'bg-warning',
'bg-success-light', 'bg-warning-light', 'bg-primary-light', 'bg-sidebar-background', 'bg-destructive',
'bg-destructive/5', 'bg-destructive/10', 'bg-warning/5',
// Text colors
'text-foreground', 'text-muted-foreground', 'text-primary', 'text-card-foreground', 'text-success', 'text-warning',
'text-primary-foreground', 'text-accent-foreground', 'text-sidebar-foreground', 'text-destructive',
// Border colors
'border', 'border-border', 'border-primary', 'border-success', 'border-warning', 'border-muted',
'border-l-success', 'border-l-warning', 'border-l-muted-foreground', 'border-l-primary',
// Layout
'rounded', 'rounded-lg', 'rounded-md', 'rounded-full', 'shadow', 'shadow-sm', 'shadow-md', 'shadow-lg',
'p-2', 'p-3', 'p-4', 'p-5', 'px-3', 'px-4', 'px-5', 'py-2', 'py-3', 'py-4',
'm-2', 'mb-2', 'mb-3', 'mb-4', 'mt-2', 'mt-3', 'mt-4', 'mx-2', 'my-2',
'gap-2', 'gap-3', 'gap-4', 'space-y-2', 'space-y-3',
// Flex & Grid
'flex', 'flex-1', 'flex-col', 'flex-wrap', 'items-center', 'items-start', 'justify-between', 'justify-center',
'grid', 'grid-cols-1', 'grid-cols-2', 'grid-cols-3',
// Sizing
'w-full', 'w-5', 'w-8', 'h-2', 'h-5', 'h-8', 'min-w-0', 'max-w-full',
// Text
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl',
'font-medium', 'font-semibold', 'font-bold', 'font-mono', 'truncate', 'uppercase',
// States & Transitions
'hover:shadow-md', 'hover:bg-hover', 'hover:-translate-y-1', 'hover:text-foreground',
'transition-all', 'duration-200', 'duration-300', 'cursor-pointer',
// Opacity & visibility
'opacity-50', 'opacity-80', 'hidden', 'block', 'inline', 'inline-flex',
// Position
'relative', 'absolute', 'fixed', 'sticky', 'top-0', 'right-0', 'left-0', 'bottom-0',
'z-10', 'z-40', 'z-50', 'overflow-hidden', 'overflow-y-auto',
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))',
'card-foreground': 'hsl(var(--card-foreground))',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
'primary-light': 'hsl(var(--primary-light))',
secondary: 'hsl(var(--secondary))',
'secondary-foreground': 'hsl(var(--secondary-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
destructive: 'hsl(var(--destructive))',
'destructive-foreground': 'hsl(var(--destructive-foreground))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
'sidebar-background': 'hsl(var(--sidebar-background))',
'sidebar-foreground': 'hsl(var(--sidebar-foreground))',
hover: 'hsl(var(--hover))',
success: 'hsl(var(--success))',
'success-light': 'hsl(var(--success-light))',
warning: 'hsl(var(--warning))',
'warning-light': 'hsl(var(--warning-light))',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['Consolas', 'Monaco', 'Courier New', 'monospace'],
},
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'DEFAULT': '0 2px 8px rgb(0 0 0 / 0.08)',
'md': '0 4px 12px rgb(0 0 0 / 0.1)',
'lg': '0 8px 24px rgb(0 0 0 / 0.12)',
},
},
},
}
</script>
<style>
/* CSS Custom Properties - Light Mode */
:root {
--background: 0 0% 98%;
--foreground: 0 0% 13%;
--card: 0 0% 100%;
--card-foreground: 0 0% 13%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 220 65% 50%;
--primary: 220 65% 50%;
--primary-foreground: 0 0% 100%;
--primary-light: 220 65% 95%;
--secondary: 220 60% 65%;
--secondary-foreground: 0 0% 100%;
--accent: 220 40% 95%;
--accent-foreground: 0 0% 13%;
--destructive: 8 75% 55%;
--destructive-foreground: 0 0% 100%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--sidebar-background: 0 0% 97%;
--sidebar-foreground: 0 0% 13%;
--hover: 0 0% 93%;
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
--warning-light: 48 96% 89%;
}
/* Dark Mode */
[data-theme="dark"] {
--background: 220 13% 10%;
--foreground: 0 0% 90%;
--card: 220 13% 14%;
--card-foreground: 0 0% 90%;
--border: 220 13% 20%;
--input: 220 13% 20%;
--ring: 220 65% 55%;
--primary: 220 65% 55%;
--primary-foreground: 0 0% 100%;
--primary-light: 220 50% 25%;
--secondary: 220 60% 60%;
--secondary-foreground: 0 0% 100%;
--accent: 220 30% 20%;
--accent-foreground: 0 0% 90%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 220 13% 18%;
--muted-foreground: 0 0% 55%;
--sidebar-background: 220 13% 12%;
--sidebar-foreground: 0 0% 90%;
--hover: 220 13% 22%;
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
}
/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground)); }
/* Sidebar collapse state */
.sidebar.collapsed { width: 60px; }
.sidebar.collapsed .nav-text,
.sidebar.collapsed .nav-section-title,
.sidebar.collapsed .badge,
.sidebar.collapsed .toggle-text { display: none; }
.sidebar.collapsed .nav-section-header { justify-content: center; padding: 12px 0; }
.sidebar.collapsed .nav-item { justify-content: center; padding: 10px 0; }
.sidebar.collapsed .toggle-icon { transform: rotate(180deg); }
/* Path menu open state */
.path-menu.open { display: block; }
/* Mobile sidebar */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -260px;
top: 56px;
height: calc(100vh - 56px);
z-index: 200;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
}
.sidebar.open { left: 0; }
.sidebar-overlay.open { display: block; }
.menu-toggle-btn { display: block !important; }
}
/* Task drawer */
.task-detail-drawer {
transform: translateX(100%);
transition: transform 0.3s ease;
}
.task-detail-drawer.open { transform: translateX(0); }
.drawer-overlay.active { display: block; }
/* Injected from dashboard.css */
{{CSS_CONTENT}}
</style>
</head>
<body class="font-sans bg-background text-foreground leading-normal">
<div class="flex flex-col min-h-screen">
<!-- Top Bar -->
<header class="flex items-center justify-between px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm">
<div class="flex items-center gap-4">
<button class="hidden md:hidden p-2 text-foreground hover:bg-hover rounded menu-toggle-btn" id="menuToggle"></button>
<div class="flex items-center gap-2 text-lg font-semibold text-primary">
<span class="text-2xl"></span>
<span class="hidden sm:inline">Claude Code Workflow</span>
</div>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-3">
<!-- Path Selector -->
<div class="flex items-center gap-2 relative">
<label class="hidden sm:inline text-sm text-muted-foreground">Project:</label>
<div class="relative">
<button class="flex items-center gap-2 px-3 py-1.5 bg-background border border-border rounded text-sm text-foreground hover:bg-hover max-w-[300px]" id="pathButton">
<span class="truncate max-w-[240px]" id="currentPath">{{PROJECT_PATH}}</span>
<span class="text-xs text-muted-foreground"></span>
</button>
<div class="path-menu hidden absolute top-full right-0 mt-1 bg-card border border-border rounded-lg shadow-lg min-w-[280px] z-50" id="pathMenu">
<div class="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">Recent Projects</div>
<div id="recentPaths" class="border-t border-border">
<!-- Dynamic recent paths -->
</div>
<div class="p-2 border-t border-border">
<button class="w-full flex items-center justify-center gap-2 px-3 py-2 bg-background border border-border rounded text-sm text-muted-foreground hover:bg-hover" id="browsePath">
📂 Browse...
</button>
</div>
</div>
</div>
<!-- Refresh Button -->
<button class="refresh-btn p-1.5 text-muted-foreground hover:text-foreground hover:bg-hover rounded" id="refreshWorkspace" title="Refresh workspace">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 21h5v-5"/>
</svg>
</button>
</div>
<!-- Theme Toggle -->
<button class="p-2 text-xl hover:bg-hover rounded" id="themeToggle" title="Toggle theme">🌙</button>
</div>
</header>
<!-- Sidebar Overlay (mobile) -->
<div class="sidebar-overlay hidden fixed inset-0 bg-black/50 z-40" id="sidebarOverlay"></div>
<!-- Main Layout -->
<div class="flex flex-1">
<!-- Sidebar -->
<aside class="sidebar w-64 bg-sidebar-background border-r border-border flex flex-col sticky top-14 h-[calc(100vh-56px)] overflow-y-auto transition-all duration-300" id="sidebar">
<nav class="flex-1 py-3">
<!-- Project Overview Section -->
<div class="mb-2" id="projectOverviewNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">🏗️</span>
<span class="nav-section-title">Project</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="project-overview" data-tooltip="Project Overview">
<span>📊</span>
<span class="nav-text flex-1">Overview</span>
</li>
</ul>
</div>
<!-- Sessions Section -->
<div class="mb-2">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">📁</span>
<span class="nav-section-title">Sessions</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors active" data-filter="all" data-tooltip="All Sessions">
<span>📋</span>
<span class="nav-text flex-1">All</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeAll">0</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-filter="active" data-tooltip="Active Sessions">
<span>🟢</span>
<span class="nav-text flex-1">Active</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-success-light text-success" id="badgeActive">0</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-filter="archived" data-tooltip="Archived Sessions">
<span>📦</span>
<span class="nav-text flex-1">Archived</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeArchived">0</span>
</li>
</ul>
</div>
<!-- Lite Tasks Section -->
<div class="mb-2" id="liteTasksNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2"></span>
<span class="nav-section-title">Lite Tasks</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-lite="lite-plan" data-tooltip="Lite Plan Sessions">
<span>📝</span>
<span class="nav-text flex-1">Lite Plan</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeLitePlan">0</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-lite="lite-fix" data-tooltip="Lite Fix Sessions">
<span>🔧</span>
<span class="nav-text flex-1">Lite Fix</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeLiteFix">0</span>
</li>
</ul>
</div>
<!-- MCP Servers Section -->
<div class="mb-2" id="mcpServersNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">🔌</span>
<span class="nav-section-title">MCP Servers</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="mcp-manager" data-tooltip="MCP Server Management">
<span>⚙️</span>
<span class="nav-text flex-1">Manage</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeMcpServers">0</span>
</li>
</ul>
</div>
<!-- Hooks Section -->
<div class="mb-2" id="hooksNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<span class="mr-2">🪝</span>
<span class="nav-section-title">Hooks</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="hook-manager" data-tooltip="Hook Management">
<span>⚙️</span>
<span class="nav-text flex-1">Manage</span>
<span class="badge px-2 py-0.5 text-xs font-semibold rounded-full bg-hover text-muted-foreground" id="badgeHooks">0</span>
</li>
</ul>
</div>
</nav>
<!-- Sidebar Footer -->
<div class="p-3 border-t border-border">
<button class="flex items-center justify-center gap-2 w-full px-3 py-2 border border-border rounded text-sm text-muted-foreground hover:bg-hover transition-colors" id="sidebarToggle">
<span class="toggle-icon transition-transform duration-300"></span>
<span class="toggle-text">Collapse</span>
</button>
</div>
</aside>
<!-- Content Area -->
<main class="flex-1 p-6 overflow-y-auto min-w-0">
<!-- Stats Section: Left Metrics + Right Carousel -->
<section id="statsGrid" class="stats-section flex gap-4 mb-6">
<!-- Left: 4 Metrics Grid -->
<div class="stats-metrics grid grid-cols-2 gap-3 shrink-0">
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">📊</div>
<div class="text-2xl font-bold text-foreground" id="statTotalSessions">0</div>
<div class="text-xs text-muted-foreground mt-1">Total Sessions</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">🟢</div>
<div class="text-2xl font-bold text-foreground" id="statActiveSessions">0</div>
<div class="text-xs text-muted-foreground mt-1">Active Sessions</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1">📋</div>
<div class="text-2xl font-bold text-foreground" id="statTotalTasks">0</div>
<div class="text-xs text-muted-foreground mt-1">Total Tasks</div>
</div>
<div class="bg-card border border-border rounded-lg p-4 text-center hover:shadow-md transition-all duration-200 min-w-[140px]">
<div class="text-xl mb-1"></div>
<div class="text-2xl font-bold text-foreground" id="statCompletedTasks">0</div>
<div class="text-xs text-muted-foreground mt-1">Completed Tasks</div>
</div>
</div>
<!-- Right: Active Session Carousel (Image-style with dots) -->
<div class="stats-carousel flex-1 bg-card border border-border rounded-lg overflow-hidden min-h-[180px] flex flex-col relative">
<!-- Carousel Content (Full height) -->
<div class="carousel-content flex-1 relative overflow-hidden" id="carouselContent">
<!-- Dynamic carousel slides -->
<div class="carousel-empty flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<div class="text-3xl mb-2">🎯</div>
<p class="text-sm">No active sessions</p>
</div>
</div>
</div>
<!-- Bottom: Dots Indicator & Controls -->
<div class="carousel-footer flex items-center justify-center gap-3 py-2 border-t border-border bg-muted/20">
<!-- Previous Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselPrev" title="Previous">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<!-- Dots Indicator -->
<div class="carousel-dots flex items-center gap-1.5" id="carouselDots">
<!-- Dots will be rendered dynamically -->
</div>
<!-- Next Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground" id="carouselNext" title="Next">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
<!-- Pause Button -->
<button class="carousel-btn p-1 rounded hover:bg-hover text-muted-foreground hover:text-foreground ml-1" id="carouselPause" title="Pause auto-play">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="carouselPauseIcon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
</div>
</div>
</section>
<!-- Content Header -->
<div class="flex items-center justify-between flex-wrap gap-3 mb-5">
<h2 class="text-xl font-semibold text-foreground" id="contentTitle">All Sessions</h2>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-sm">🔍</span>
<input type="text" placeholder="Search..." id="searchInput"
class="pl-9 pr-4 py-2 w-60 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all">
</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="flex items-center justify-between px-5 h-10 bg-card border-t border-border text-xs text-muted-foreground">
<div>Generated: <span id="generatedAt">-</span></div>
<div>CCW Dashboard v1.0</div>
</footer>
<!-- Task Detail Drawer -->
<div class="task-detail-drawer fixed top-0 right-0 w-1/2 max-w-full h-full bg-card border-l border-border shadow-lg z-50 flex flex-col" id="taskDetailDrawer">
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-foreground" id="drawerTaskTitle">Task Details</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeTaskDrawer()">&times;</button>
</div>
<div class="flex-1 overflow-y-auto p-5" id="drawerContent">
<!-- Dynamic content -->
</div>
</div>
<div class="drawer-overlay hidden fixed inset-0 bg-black/50 z-40" id="drawerOverlay" onclick="closeTaskDrawer()"></div>
</div>
<!-- Markdown Preview Modal -->
<div id="markdownModal" class="markdown-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
<div class="markdown-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMarkdownModal()"></div>
<div class="markdown-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-4xl h-[85vh] flex flex-col">
<div class="markdown-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
<h3 class="text-lg font-semibold text-foreground" id="markdownModalTitle">Content Preview</h3>
<div class="flex items-center gap-2">
<div class="flex bg-muted rounded-lg p-0.5">
<button id="mdTabRaw" class="md-tab-btn px-3 py-1 text-sm rounded-md transition-colors" onclick="switchMarkdownTab('raw')">Raw</button>
<button id="mdTabPreview" class="md-tab-btn px-3 py-1 text-sm rounded-md transition-colors active" onclick="switchMarkdownTab('preview')">Preview</button>
</div>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMarkdownModal()">&times;</button>
</div>
</div>
<div class="markdown-modal-body flex-1 overflow-auto p-4">
<pre id="markdownRaw" class="hidden whitespace-pre-wrap text-sm font-mono text-foreground bg-muted p-4 rounded-lg overflow-auto h-full"></pre>
<div id="markdownPreview" class="markdown-preview prose prose-sm max-w-none"></div>
</div>
</div>
</div>
<!-- MCP Server Create Modal -->
<div id="mcpCreateModal" class="mcp-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
<div class="mcp-modal-backdrop absolute inset-0 bg-black/60" onclick="closeMcpCreateModal()"></div>
<div class="mcp-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col">
<div class="mcp-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-foreground">Create MCP Server</h3>
<div class="flex bg-muted rounded-lg p-0.5">
<button id="mcpTabForm" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors active" onclick="switchMcpCreateTab('form')">Form</button>
<button id="mcpTabJson" class="mcp-tab-btn px-3 py-1 text-sm rounded-md transition-colors" onclick="switchMcpCreateTab('json')">JSON</button>
</div>
</div>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeMcpCreateModal()">&times;</button>
</div>
<!-- Form Mode -->
<div id="mcpFormMode" class="mcp-modal-body p-4 space-y-4">
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Server Name <span class="text-destructive">*</span></label>
<input type="text" id="mcpServerName" placeholder="e.g., my-mcp-server"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
<input type="text" id="mcpServerCommand" placeholder="e.g., npx, uvx, node, python"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
<textarea id="mcpServerArgs" placeholder="e.g.,&#10;-y&#10;@smithery/cli@latest&#10;run&#10;exa" rows="4"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Environment Variables (KEY=VALUE per line)</label>
<textarea id="mcpServerEnv" placeholder="e.g.,&#10;API_KEY=your-api-key&#10;DEBUG=true" rows="3"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
</div>
<!-- JSON Mode -->
<div id="mcpJsonMode" class="mcp-modal-body p-4 space-y-4 hidden">
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Paste MCP Server JSON Configuration</label>
<textarea id="mcpServerJson" placeholder='{
"servers": {
"my-server": {
"command": "npx",
"args": ["-y", "@package/server"],
"env": {
"API_KEY": "your-key"
}
}
}
}' rows="12"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
<p class="text-xs text-muted-foreground mt-2">Supports <code class="bg-muted px-1 rounded">{"servers": {...}}</code>, <code class="bg-muted px-1 rounded">{"mcpServers": {...}}</code>, and direct server config formats.</p>
</div>
<div id="mcpJsonPreview" class="hidden">
<label class="block text-sm font-medium text-foreground mb-2">Preview (servers to be added):</label>
<div id="mcpJsonPreviewContent" class="bg-muted rounded-lg p-3 text-sm space-y-2 max-h-32 overflow-y-auto"></div>
</div>
</div>
<div class="mcp-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeMcpCreateModal()">Cancel</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitMcpCreate()">Create</button>
</div>
</div>
</div>
<!-- Hook Create Modal -->
<div id="hookCreateModal" class="hook-modal hidden fixed inset-0 z-[100] flex items-center justify-center">
<div class="hook-modal-backdrop absolute inset-0 bg-black/60" onclick="closeHookCreateModal()"></div>
<div class="hook-modal-content relative bg-card border border-border rounded-lg shadow-2xl w-[90vw] max-w-lg flex flex-col max-h-[90vh]">
<div class="hook-modal-header flex items-center justify-between px-4 py-3 border-b border-border">
<h3 class="text-lg font-semibold text-foreground" id="hookModalTitle">Create Hook</h3>
<button class="w-8 h-8 flex items-center justify-center text-xl text-muted-foreground hover:text-foreground hover:bg-hover rounded" onclick="closeHookCreateModal()">&times;</button>
</div>
<div class="hook-modal-body p-4 space-y-4 overflow-y-auto">
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Hook Event <span class="text-destructive">*</span></label>
<select id="hookEvent" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
<option value="">Select an event...</option>
<option value="PreToolUse">PreToolUse - Before a tool is executed</option>
<option value="PostToolUse">PostToolUse - After a tool completes</option>
<option value="Notification">Notification - On notifications</option>
<option value="Stop">Stop - When agent stops</option>
</select>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Matcher (optional)</label>
<input type="text" id="hookMatcher" placeholder="e.g., Write, Edit, Bash (leave empty for all)"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
<p class="text-xs text-muted-foreground mt-1">Tool name to match. Leave empty to match all tools.</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Command <span class="text-destructive">*</span></label>
<input type="text" id="hookCommand" placeholder="e.g., curl, bash, node"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20">
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Arguments (one per line)</label>
<textarea id="hookArgs" placeholder="e.g.,&#10;-X&#10;POST&#10;http://localhost:3456/api/hook" rows="4"
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm font-mono focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"></textarea>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-1">Scope</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="hookScope" value="project" checked class="text-primary focus:ring-primary">
<span class="text-sm text-foreground">Project (.claude/settings.json)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="hookScope" value="global" class="text-primary focus:ring-primary">
<span class="text-sm text-foreground">Global (~/.claude/settings.json)</span>
</label>
</div>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-foreground mb-2">Quick Templates</label>
<div class="grid grid-cols-2 gap-2">
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('ccw-notify')">
<span class="font-medium">CCW Notify</span>
<span class="block text-muted-foreground">Notify dashboard on Write</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('log-tool')">
<span class="font-medium">Log Tool Usage</span>
<span class="block text-muted-foreground">Log all tool executions</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('lint-check')">
<span class="font-medium">Lint Check</span>
<span class="block text-muted-foreground">Run eslint on file changes</span>
</button>
<button class="hook-template-btn px-3 py-2 text-xs bg-muted text-foreground rounded border border-border hover:bg-hover transition-colors text-left" onclick="applyHookTemplate('git-add')">
<span class="font-medium">Git Add</span>
<span class="block text-muted-foreground">Auto stage written files</span>
</button>
</div>
</div>
</div>
<div class="hook-modal-footer flex justify-end gap-2 px-4 py-3 border-t border-border">
<button class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-hover transition-colors" onclick="closeHookCreateModal()">Cancel</button>
<button class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity" onclick="submitHookCreate()">Create</button>
</div>
</div>
</div>
<!-- D3.js for Flowchart -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- Marked.js for Markdown rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
{{JS_CONTENT}}
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
background: 'hsl(var(--color-background))',
foreground: 'hsl(var(--color-foreground))',
card: 'hsl(var(--color-card))',
border: 'hsl(var(--color-border))',
},
},
},
}
</script>
<style>:root{--color-background:0 0% 100%;--color-foreground:0 0% 25%;}</style>
</head>
<body>
<div class="flex flex-col min-h-screen bg-background text-foreground">
<header class="flex items-center justify-between px-5 h-14 bg-card border-b border-border sticky top-0 z-50">
<div class="flex items-center gap-2">
<span class="text-xl"></span>
<span class="font-semibold">Claude Code Workflow</span>
</div>
</header>
<main class="flex-1 p-6">
<h1>Dashboard</h1>
</main>
</div>
<script>{{JS_CONTENT}}</script>
</body>
</html>

View File

@@ -0,0 +1,37 @@
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
background: 'hsl(var(--color-background))',
foreground: 'hsl(var(--color-foreground))',
card: 'hsl(var(--color-card))',
border: 'hsl(var(--color-border))',
input: 'hsl(var(--color-input))',
ring: 'hsl(var(--color-ring))',
primary: 'hsl(var(--color-interactive-primary-default))',
accent: 'hsl(var(--color-interactive-accent-default))',
muted: 'hsl(var(--color-muted))',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
}
</script>
<style>:root{--color-background:0 0% 100%;--color-foreground:0 0% 25%;--color-card:0 0% 100%;--color-border:0 0% 90%;--color-input:0 0% 90%;--color-ring:220 65% 50%;--color-interactive-primary-default:220 65% 50%;--color-interactive-accent-default:220 40% 95%;--color-muted:0 0% 97%;--color-muted-foreground:0 0% 50%;--color-sidebar-background:0 0% 97.5%;}[data-theme="dark"]{--color-background:0 0% 10%;--color-foreground:0 0% 90%;--color-card:0 0% 15%;--color-border:0 0% 25%;}</style>
</head>
<body>Test</body>
</html>

View File

@@ -0,0 +1,212 @@
/* Tailwind Base Styles with Design Tokens */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* CSS Custom Properties - Light Mode (Default) */
:root {
/* Base Colors */
--color-background: 0 0% 100%; /* oklch(1 0 0) -> white */
--color-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
--color-card: 0 0% 100%; /* oklch(1 0 0) -> white */
--color-card-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
--color-border: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
--color-input: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
--color-ring: 220 65% 50%; /* oklch(0.5 0.15 250) -> primary blue */
/* Interactive Colors - Primary */
--color-interactive-primary-default: 220 65% 50%; /* oklch(0.5 0.15 250) -> #4066bf */
--color-interactive-primary-hover: 220 65% 55%; /* oklch(0.55 0.15 250) -> lighter blue */
--color-interactive-primary-active: 220 65% 45%; /* oklch(0.45 0.15 250) -> darker blue */
--color-interactive-primary-disabled: 220 30% 70%; /* oklch(0.7 0.05 250) -> muted blue */
--color-interactive-primary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
/* Interactive Colors - Secondary */
--color-interactive-secondary-default: 220 60% 65%; /* oklch(0.65 0.12 250) -> #6b8ccc */
--color-interactive-secondary-hover: 220 60% 70%; /* oklch(0.7 0.12 250) -> lighter */
--color-interactive-secondary-active: 220 60% 60%; /* oklch(0.6 0.12 250) -> darker */
--color-interactive-secondary-disabled: 220 30% 80%; /* oklch(0.8 0.05 250) -> muted */
--color-interactive-secondary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
/* Interactive Colors - Accent */
--color-interactive-accent-default: 220 40% 95%; /* oklch(0.95 0.02 250) -> #eef3fa */
--color-interactive-accent-hover: 220 45% 92%; /* oklch(0.92 0.03 250) -> slightly darker */
--color-interactive-accent-active: 220 35% 97%; /* oklch(0.97 0.02 250) -> slightly lighter */
--color-interactive-accent-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
/* Interactive Colors - Destructive */
--color-interactive-destructive-default: 8 75% 55%; /* oklch(0.55 0.22 25) -> #d93025 */
--color-interactive-destructive-hover: 8 75% 60%; /* oklch(0.6 0.22 25) -> lighter red */
--color-interactive-destructive-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
/* Semantic Colors */
--color-muted: 0 0% 97%; /* oklch(0.97 0 0) -> very light gray */
--color-muted-foreground: 0 0% 50%; /* oklch(0.5 0 0) -> medium gray */
/* Sidebar Colors */
--color-sidebar-background: 0 0% 97.5%; /* oklch(0.975 0 0) -> #f8f8f8 */
--color-sidebar-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
--color-sidebar-primary: 220 65% 50%; /* oklch(0.5 0.15 250) -> primary blue */
--color-sidebar-primary-foreground: 0 0% 100%; /* oklch(1 0 0) -> white */
--color-sidebar-accent: 220 40% 95%; /* oklch(0.95 0.02 250) -> light blue */
--color-sidebar-accent-foreground: 0 0% 25%; /* oklch(0.25 0 0) -> dark gray */
--color-sidebar-border: 0 0% 90%; /* oklch(0.9 0 0) -> light gray */
/* Typography */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 1.875rem; /* 30px */
--font-size-4xl: 2.25rem; /* 36px */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
--letter-spacing-tight: -0.025em;
--letter-spacing-normal: 0;
--letter-spacing-wide: 0.025em;
/* Spacing */
--spacing-0: 0;
--spacing-1: 0.25rem; /* 4px */
--spacing-2: 0.5rem; /* 8px */
--spacing-3: 0.75rem; /* 12px */
--spacing-4: 1rem; /* 16px */
--spacing-6: 1.5rem; /* 24px */
--spacing-8: 2rem; /* 32px */
--spacing-12: 3rem; /* 48px */
--spacing-16: 4rem; /* 64px */
/* Effects */
--opacity-disabled: 0.5;
--opacity-hover: 0.8;
--opacity-active: 1;
/* Shadows */
--shadow-2xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-xs: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-sm: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
/* Border Radius */
--border-radius-sm: calc(0.375rem - 4px); /* 2px */
--border-radius-md: calc(0.375rem - 2px); /* 4px */
--border-radius-lg: 0.375rem; /* 6px */
--border-radius-xl: calc(0.375rem + 4px); /* 10px */
--border-radius-default: 0.375rem; /* 6px */
/* Animations */
--duration-instant: 0ms;
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-medium: 300ms;
--duration-slow: 500ms;
--easing-linear: linear;
--easing-ease-in: cubic-bezier(0.4, 0, 1, 1);
--easing-ease-out: cubic-bezier(0, 0, 0.2, 1);
--easing-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--easing-spring: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Dark Mode Theme */
[data-theme="dark"] {
/* Base Colors - Dark Mode */
--color-background: 0 0% 10%; /* Dark background */
--color-foreground: 0 0% 90%; /* Light text */
--color-card: 0 0% 15%; /* Dark card background */
--color-card-foreground: 0 0% 90%; /* Light card text */
--color-border: 0 0% 25%; /* Dark border */
--color-input: 0 0% 25%; /* Dark input border */
--color-ring: 220 65% 60%; /* Brighter ring for dark mode */
/* Interactive Colors - Primary (Dark Mode) */
--color-interactive-primary-default: 220 70% 60%; /* Brighter blue for dark mode */
--color-interactive-primary-hover: 220 70% 65%; /* Even brighter on hover */
--color-interactive-primary-active: 220 70% 55%; /* Slightly darker on active */
--color-interactive-primary-disabled: 220 30% 40%; /* Muted blue for dark mode */
--color-interactive-primary-foreground: 0 0% 100%; /* White text */
/* Interactive Colors - Secondary (Dark Mode) */
--color-interactive-secondary-default: 220 60% 70%; /* Brighter secondary */
--color-interactive-secondary-hover: 220 60% 75%; /* Brighter on hover */
--color-interactive-secondary-active: 220 60% 65%; /* Slightly darker on active */
--color-interactive-secondary-disabled: 220 30% 50%; /* Muted */
--color-interactive-secondary-foreground: 0 0% 100%; /* White text */
/* Interactive Colors - Accent (Dark Mode) */
--color-interactive-accent-default: 220 30% 25%; /* Dark accent */
--color-interactive-accent-hover: 220 35% 30%; /* Slightly lighter on hover */
--color-interactive-accent-active: 220 25% 20%; /* Darker on active */
--color-interactive-accent-foreground: 0 0% 90%; /* Light text */
/* Interactive Colors - Destructive (Dark Mode) */
--color-interactive-destructive-default: 8 75% 60%; /* Brighter red for visibility */
--color-interactive-destructive-hover: 8 75% 65%; /* Even brighter on hover */
--color-interactive-destructive-foreground: 0 0% 100%; /* White text */
/* Semantic Colors (Dark Mode) */
--color-muted: 0 0% 20%; /* Dark muted background */
--color-muted-foreground: 0 0% 60%; /* Lighter muted text */
/* Sidebar Colors (Dark Mode) */
--color-sidebar-background: 0 0% 12%; /* Slightly lighter than background */
--color-sidebar-foreground: 0 0% 90%; /* Light text */
--color-sidebar-primary: 220 70% 60%; /* Brighter blue */
--color-sidebar-primary-foreground: 0 0% 100%; /* White text */
--color-sidebar-accent: 220 30% 25%; /* Dark accent */
--color-sidebar-accent-foreground: 0 0% 90%; /* Light text */
--color-sidebar-border: 0 0% 25%; /* Dark border */
}
/* Base typography */
* {
box-sizing: border-box;
}
body {
@apply bg-background text-foreground font-sans leading-normal;
margin: 0;
padding: 0;
}
/* Focus styles */
*:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
}
@layer utilities {
/* Custom utility classes */
.text-balance {
text-wrap: balance;
}
.transition-default {
transition: all var(--duration-normal) var(--easing-ease-out);
}
.transition-fast {
transition: all var(--duration-fast) var(--easing-ease-out);
}
.transition-medium {
transition: all var(--duration-medium) var(--easing-ease-in-out);
}
.transition-slow {
transition: all var(--duration-slow) var(--easing-ease-in-out);
}
}

View File

@@ -0,0 +1,401 @@
<!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>Workflow Dashboard - Task Board</title>
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ['class', '[data-theme="dark"]'],
safelist: [
'bg-card', 'bg-background', 'bg-hover', 'bg-accent', 'bg-muted', 'bg-primary', 'bg-success', 'bg-warning',
'bg-success-light', 'bg-warning-light', 'text-foreground', 'text-muted-foreground', 'text-primary',
'text-success', 'text-warning', 'text-primary-foreground', 'border', 'border-border', 'border-primary',
'border-l-success', 'border-l-warning', 'border-l-muted-foreground', 'rounded', 'rounded-lg', 'rounded-full',
'shadow', 'shadow-sm', 'shadow-md', 'p-2', 'p-3', 'p-4', 'p-5', 'px-3', 'px-4', 'py-2', 'py-3',
'mb-2', 'mb-3', 'mb-4', 'mt-2', 'mt-4', 'mx-auto', 'gap-2', 'gap-3', 'gap-4', 'space-y-2',
'flex', 'flex-1', 'flex-col', 'flex-wrap', 'items-center', 'items-start', 'justify-between', 'justify-center',
'grid', 'w-full', 'w-5', 'h-2', 'h-5', 'text-xs', 'text-sm', 'text-lg', 'text-xl', 'text-2xl', 'text-3xl',
'font-medium', 'font-semibold', 'font-bold', 'font-mono', 'truncate', 'uppercase',
'hover:shadow-md', 'hover:bg-hover', 'hover:-translate-y-1', 'hover:text-foreground', 'hover:opacity-90',
'hover:scale-110', 'transition-all', 'duration-200', 'duration-300', 'cursor-pointer',
'opacity-50', 'hidden', 'block', 'relative', 'absolute', 'fixed', 'z-50', 'overflow-hidden',
'col-span-full', 'text-center', 'min-h-screen', 'max-w-7xl',
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))',
'card-foreground': 'hsl(var(--card-foreground))',
border: 'hsl(var(--border))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
hover: 'hsl(var(--hover))',
success: 'hsl(var(--success))',
'success-light': 'hsl(var(--success-light))',
warning: 'hsl(var(--warning))',
'warning-light': 'hsl(var(--warning-light))',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
},
},
}
</script>
<style>
/* CSS Custom Properties - Light Mode */
:root {
--background: 0 0% 98%;
--foreground: 0 0% 13%;
--card: 0 0% 100%;
--card-foreground: 0 0% 13%;
--border: 0 0% 90%;
--primary: 220 65% 50%;
--primary-foreground: 0 0% 100%;
--accent: 220 40% 95%;
--accent-foreground: 0 0% 13%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--hover: 0 0% 93%;
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
--warning-light: 48 96% 89%;
}
/* Dark Mode */
[data-theme="dark"] {
--background: 220 13% 10%;
--foreground: 0 0% 90%;
--card: 220 13% 14%;
--card-foreground: 0 0% 90%;
--border: 220 13% 20%;
--primary: 220 65% 55%;
--primary-foreground: 0 0% 100%;
--accent: 220 30% 20%;
--accent-foreground: 0 0% 90%;
--muted: 220 13% 18%;
--muted-foreground: 0 0% 55%;
--hover: 220 13% 22%;
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
}
/* Progress bar gradient */
.progress-fill {
background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--success)));
}
/* Task checkbox pseudo-elements */
.task-checkbox.completed::after { content: '✓'; }
.task-checkbox.in_progress::after { content: '⟳'; }
</style>
</head>
<body class="font-sans bg-background text-foreground leading-normal min-h-screen">
<div class="max-w-7xl mx-auto px-5 py-5">
<!-- Header -->
<header class="bg-card shadow rounded-lg p-5 mb-7 border border-border">
<h1 class="text-2xl font-bold text-primary mb-2">🚀 Workflow Dashboard</h1>
<p class="text-muted-foreground">Task Board - Active and Archived Sessions</p>
<div class="flex flex-wrap gap-4 items-center mt-4">
<!-- Search Box -->
<div class="flex-1 min-w-[250px] relative">
<input type="text" id="searchInput" placeholder="🔍 Search tasks or sessions..."
class="w-full px-4 py-2.5 border border-border rounded-lg bg-background text-foreground text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all" />
</div>
<!-- Filter Buttons -->
<div class="flex gap-2 flex-wrap">
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all" data-filter="all">All</button>
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-card text-foreground hover:bg-hover transition-all" data-filter="active">Active</button>
<button class="btn px-5 py-2.5 border border-border rounded-lg text-sm font-medium bg-card text-foreground hover:bg-hover transition-all" data-filter="archived">Archived</button>
</div>
</div>
</header>
<!-- Stats Grid -->
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-5 mb-7">
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<div class="text-3xl font-bold text-primary" id="totalSessions">0</div>
<div class="text-sm text-muted-foreground mt-1">Total Sessions</div>
</div>
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<div class="text-3xl font-bold text-primary" id="activeSessionsCount">0</div>
<div class="text-sm text-muted-foreground mt-1">Active Sessions</div>
</div>
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<div class="text-3xl font-bold text-primary" id="totalTasks">0</div>
<div class="text-sm text-muted-foreground mt-1">Total Tasks</div>
</div>
<div class="bg-card p-5 rounded-lg border border-border shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<div class="text-3xl font-bold text-primary" id="completedTasks">0</div>
<div class="text-sm text-muted-foreground mt-1">Completed Tasks</div>
</div>
</div>
<!-- Active Sessions Section -->
<div class="mb-10" id="activeSectionContainer">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-semibold text-foreground">📋 Active Sessions</h2>
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] gap-5" id="activeSessionsGrid"></div>
</div>
<!-- Archived Sessions Section -->
<div class="mb-10" id="archivedSectionContainer">
<div class="flex justify-between items-center mb-5">
<h2 class="text-xl font-semibold text-foreground">📦 Archived Sessions</h2>
</div>
<div class="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] gap-5" id="archivedSessionsGrid"></div>
</div>
</div>
<!-- Theme Toggle Button -->
<button class="fixed bottom-7 right-7 w-14 h-14 rounded-full bg-primary text-primary-foreground text-2xl shadow-lg hover:scale-110 transition-all duration-200 z-50" id="themeToggle">🌙</button>
<!-- Workflow data injected by dashboard-generator -->
<script id="workflow-data" type="application/json">{{WORKFLOW_DATA}}</script>
<script>
// Parse workflow data from JSON script tag, with fallback for direct file access
let workflowData;
try {
const dataScript = document.getElementById('workflow-data');
const rawData = dataScript ? dataScript.textContent.trim() : '';
// Check if placeholder was replaced (doesn't start with '{{')
if (rawData && !rawData.startsWith('{{')) {
workflowData = JSON.parse(rawData);
} else {
throw new Error('Data not injected');
}
} catch (e) {
console.warn('Workflow data not injected, using empty defaults');
workflowData = { activeSessions: [], archivedSessions: [] };
}
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
}
function updateThemeIcon(theme) {
document.getElementById('themeToggle').textContent = theme === 'dark' ? '☀️' : '🌙';
}
// Statistics calculation
function updateStatistics() {
const stats = {
totalSessions: workflowData.activeSessions.length + workflowData.archivedSessions.length,
activeSessions: workflowData.activeSessions.length,
totalTasks: 0,
completedTasks: 0
};
workflowData.activeSessions.forEach(session => {
stats.totalTasks += session.tasks.length;
stats.completedTasks += session.tasks.filter(t => t.status === 'completed').length;
});
workflowData.archivedSessions.forEach(session => {
stats.totalTasks += session.taskCount || 0;
stats.completedTasks += session.taskCount || 0;
});
document.getElementById('totalSessions').textContent = stats.totalSessions;
document.getElementById('activeSessionsCount').textContent = stats.activeSessions;
document.getElementById('totalTasks').textContent = stats.totalTasks;
document.getElementById('completedTasks').textContent = stats.completedTasks;
}
// Render session card with Tailwind classes
function createSessionCard(session, isActive) {
const card = document.createElement('div');
card.className = 'bg-card rounded-lg border border-border shadow-sm p-5 transition-all duration-300 hover:-translate-y-1 hover:shadow-md';
card.dataset.sessionType = isActive ? 'active' : 'archived';
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: (session.taskCount || 0);
const totalTasks = isActive ? session.tasks.length : (session.taskCount || 0);
const progress = totalTasks > 0 ? (completedTasks / totalTasks * 100) : 0;
let tasksHtml = '';
if (isActive && session.tasks.length > 0) {
tasksHtml = `
<div class="mt-4 space-y-2">
${session.tasks.map(task => {
const statusClasses = {
completed: 'border-l-success bg-success-light/30',
in_progress: 'border-l-warning bg-warning-light/30',
pending: 'border-l-muted-foreground bg-muted/30'
};
const checkboxClasses = {
completed: 'bg-success border-success text-white',
in_progress: 'border-warning text-warning',
pending: 'border-border'
};
return `
<div class="flex items-center gap-3 p-2.5 rounded border-l-[3px] ${statusClasses[task.status] || statusClasses.pending}">
<div class="task-checkbox w-5 h-5 rounded-full border-2 flex items-center justify-center text-xs font-bold flex-shrink-0 ${checkboxClasses[task.status] || checkboxClasses.pending} ${task.status}"></div>
<div class="flex-1 text-sm text-foreground">${task.title || 'Untitled Task'}</div>
<span class="text-xs font-mono text-muted-foreground">${task.task_id || ''}</span>
</div>
`;
}).join('')}
</div>
`;
}
const statusBadgeClass = isActive
? 'bg-success-light text-success'
: 'bg-hover text-muted-foreground';
card.innerHTML = `
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-semibold text-foreground">${session.session_id || 'Unknown Session'}</h3>
<div class="text-sm text-muted-foreground mt-1">${session.project || ''}</div>
</div>
<span class="px-3 py-1 text-xs font-semibold uppercase rounded-full ${statusBadgeClass}">
${isActive ? 'Active' : 'Archived'}
</span>
</div>
<div class="flex gap-4 text-sm text-muted-foreground mb-3">
<span>📅 ${session.created_at || session.archived_at || 'N/A'}</span>
<span>📊 ${completedTasks}/${totalTasks} tasks</span>
</div>
${totalTasks > 0 ? `
<div class="h-2 bg-hover rounded overflow-hidden my-4">
<div class="progress-fill h-full rounded transition-all duration-300" style="width: ${progress}%"></div>
</div>
<div class="text-center text-sm text-muted-foreground">${Math.round(progress)}% Complete</div>
` : ''}
${tasksHtml}
${!isActive && session.archive_path ? `
<div class="mt-4 pt-4 border-t border-border text-sm text-muted-foreground">
📁 Archive: ${session.archive_path}
</div>
` : ''}
`;
return card;
}
// Render all sessions
function renderSessions(filter = 'all') {
const activeContainer = document.getElementById('activeSessionsGrid');
const archivedContainer = document.getElementById('archivedSessionsGrid');
activeContainer.innerHTML = '';
archivedContainer.innerHTML = '';
if (filter === 'all' || filter === 'active') {
if (workflowData.activeSessions.length === 0) {
activeContainer.innerHTML = `
<div class="text-center py-16 text-muted-foreground col-span-full">
<div class="text-5xl mb-4 opacity-50">📭</div>
<p>No active sessions</p>
</div>
`;
} else {
workflowData.activeSessions.forEach(session => {
activeContainer.appendChild(createSessionCard(session, true));
});
}
}
if (filter === 'all' || filter === 'archived') {
if (workflowData.archivedSessions.length === 0) {
archivedContainer.innerHTML = `
<div class="text-center py-16 text-muted-foreground col-span-full">
<div class="text-5xl mb-4 opacity-50">📦</div>
<p>No archived sessions</p>
</div>
`;
} else {
workflowData.archivedSessions.forEach(session => {
archivedContainer.appendChild(createSessionCard(session, false));
});
}
}
// Show/hide sections
document.getElementById('activeSectionContainer').style.display =
(filter === 'all' || filter === 'active') ? 'block' : 'none';
document.getElementById('archivedSectionContainer').style.display =
(filter === 'all' || filter === 'archived') ? 'block' : 'none';
}
// Search functionality
function setupSearch() {
const searchInput = document.getElementById('searchInput');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const cards = document.querySelectorAll('[data-session-type]');
cards.forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(query) ? 'block' : 'none';
});
});
}
// Filter functionality
function setupFilters() {
const filterButtons = document.querySelectorAll('[data-filter]');
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => {
b.classList.remove('bg-primary', 'text-primary-foreground');
b.classList.add('bg-card', 'text-foreground');
});
btn.classList.remove('bg-card', 'text-foreground');
btn.classList.add('bg-primary', 'text-primary-foreground');
renderSessions(btn.dataset.filter);
});
});
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
initTheme();
updateStatistics();
renderSessions();
setupSearch();
setupFilters();
document.getElementById('themeToggle').addEventListener('click', toggleTheme);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
import open from 'open';
import { platform } from 'os';
import { resolve } from 'path';
/**
* Launch a file in the default browser
* Cross-platform compatible (Windows/macOS/Linux)
* @param {string} filePath - Path to HTML file
* @returns {Promise<void>}
*/
export async function launchBrowser(filePath) {
const absolutePath = resolve(filePath);
// Construct file:// URL based on platform
let url;
if (platform() === 'win32') {
// Windows: file:///C:/path/to/file.html
url = `file:///${absolutePath.replace(/\\/g, '/')}`;
} else {
// Unix: file:///path/to/file.html
url = `file://${absolutePath}`;
}
try {
// Use the 'open' package which handles cross-platform browser launching
await open(url);
} catch (error) {
// Fallback: try opening the file path directly
try {
await open(absolutePath);
} catch (fallbackError) {
throw new Error(`Failed to open browser: ${error.message}`);
}
}
}
/**
* Check if we're running in a headless/CI environment
* @returns {boolean}
*/
export function isHeadlessEnvironment() {
return !!(
process.env.CI ||
process.env.CONTINUOUS_INTEGRATION ||
process.env.GITHUB_ACTIONS ||
process.env.GITLAB_CI ||
process.env.JENKINS_URL
);
}

View File

@@ -0,0 +1,48 @@
import { readFileSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
/**
* Safely read a JSON file
* @param {string} filePath - Path to JSON file
* @returns {Object|null} - Parsed JSON or null on error
*/
export function readJsonFile(filePath) {
if (!existsSync(filePath)) return null;
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
/**
* Safely read a text file
* @param {string} filePath - Path to text file
* @returns {string|null} - File contents or null on error
*/
export function readTextFile(filePath) {
if (!existsSync(filePath)) return null;
try {
return readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
/**
* Write content to a file
* @param {string} filePath - Path to file
* @param {string} content - Content to write
*/
export function writeTextFile(filePath, content) {
writeFileSync(filePath, content, 'utf8');
}
/**
* Check if a path exists
* @param {string} filePath - Path to check
* @returns {boolean}
*/
export function pathExists(filePath) {
return existsSync(filePath);
}

View File

@@ -0,0 +1,279 @@
import { resolve, join, relative, isAbsolute } from 'path';
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
/**
* Resolve a path, handling ~ for home directory
* @param {string} inputPath - Path to resolve
* @returns {string} - Absolute path
*/
export function resolvePath(inputPath) {
if (!inputPath) return process.cwd();
// Handle ~ for home directory
if (inputPath.startsWith('~')) {
return join(homedir(), inputPath.slice(1));
}
return resolve(inputPath);
}
/**
* Validate and sanitize a user-provided path
* Prevents path traversal attacks and validates path is within allowed boundaries
* @param {string} inputPath - User-provided path
* @param {Object} options - Validation options
* @param {string} options.baseDir - Base directory to restrict paths within (optional)
* @param {boolean} options.mustExist - Whether path must exist (default: false)
* @param {boolean} options.allowHome - Whether to allow home directory paths (default: true)
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
*/
export function validatePath(inputPath, options = {}) {
const { baseDir = null, mustExist = false, allowHome = true } = options;
// Check for empty/null input
if (!inputPath || typeof inputPath !== 'string') {
return { valid: false, path: null, error: 'Path is required' };
}
// Trim whitespace
const trimmedPath = inputPath.trim();
// Check for suspicious patterns (null bytes, control characters)
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Path contains invalid characters' };
}
// Resolve the path
let resolvedPath;
try {
resolvedPath = resolvePath(trimmedPath);
} catch (err) {
return { valid: false, path: null, error: `Invalid path: ${err.message}` };
}
// Check if path exists when required
if (mustExist && !existsSync(resolvedPath)) {
return { valid: false, path: null, error: `Path does not exist: ${resolvedPath}` };
}
// Get real path if it exists (resolves symlinks)
let realPath = resolvedPath;
if (existsSync(resolvedPath)) {
try {
realPath = realpathSync(resolvedPath);
} catch (err) {
return { valid: false, path: null, error: `Cannot resolve path: ${err.message}` };
}
}
// Check if within base directory when specified
if (baseDir) {
const resolvedBase = resolvePath(baseDir);
const relativePath = relative(resolvedBase, realPath);
// Path traversal detection: relative path should not start with '..'
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return {
valid: false,
path: null,
error: `Path must be within ${resolvedBase}`
};
}
}
// Check home directory restriction
if (!allowHome) {
const home = homedir();
if (realPath === home || realPath.startsWith(home + '/') || realPath.startsWith(home + '\\')) {
// This is fine, we're just checking if it's explicitly the home dir itself
}
}
return { valid: true, path: realPath, error: null };
}
/**
* Validate output file path for writing
* @param {string} outputPath - Output file path
* @param {string} defaultDir - Default directory if path is relative
* @returns {Object} - { valid: boolean, path: string|null, error: string|null }
*/
export function validateOutputPath(outputPath, defaultDir = process.cwd()) {
if (!outputPath || typeof outputPath !== 'string') {
return { valid: false, path: null, error: 'Output path is required' };
}
const trimmedPath = outputPath.trim();
// Check for suspicious patterns
if (/[\x00-\x1f]/.test(trimmedPath)) {
return { valid: false, path: null, error: 'Output path contains invalid characters' };
}
// Resolve the path
let resolvedPath;
try {
resolvedPath = isAbsolute(trimmedPath) ? trimmedPath : join(defaultDir, trimmedPath);
resolvedPath = resolve(resolvedPath);
} catch (err) {
return { valid: false, path: null, error: `Invalid output path: ${err.message}` };
}
// Ensure it's not a directory
if (existsSync(resolvedPath)) {
try {
const stat = statSync(resolvedPath);
if (stat.isDirectory()) {
return { valid: false, path: null, error: 'Output path is a directory, expected a file' };
}
} catch {
// Ignore stat errors
}
}
return { valid: true, path: resolvedPath, error: null };
}
/**
* Get potential template locations
* @returns {string[]} - Array of existing template directories
*/
export function getTemplateLocations() {
const locations = [
join(homedir(), '.claude', 'templates'),
join(process.cwd(), '.claude', 'templates')
];
return locations.filter(loc => existsSync(loc));
}
/**
* Find a template file in known locations
* @param {string} templateName - Name of template file (e.g., 'workflow-dashboard.html')
* @returns {string|null} - Path to template or null if not found
*/
export function findTemplate(templateName) {
const locations = getTemplateLocations();
for (const loc of locations) {
const templatePath = join(loc, templateName);
if (existsSync(templatePath)) {
return templatePath;
}
}
return null;
}
/**
* Ensure directory exists, creating if necessary
* @param {string} dirPath - Directory path to ensure
*/
export function ensureDir(dirPath) {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
}
}
/**
* Get the .workflow directory path from project path
* @param {string} projectPath - Path to project
* @returns {string} - Path to .workflow directory
*/
export function getWorkflowDir(projectPath) {
return join(resolvePath(projectPath), '.workflow');
}
/**
* Normalize path for display (handle Windows backslashes)
* @param {string} filePath - Path to normalize
* @returns {string}
*/
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
}
}
/**
* Remove a specific path from recent paths
* @param {string} pathToRemove - Path to remove
* @returns {boolean} - True if removed, false if not found
*/
export function removeRecentPath(pathToRemove) {
try {
const normalized = normalizePathForDisplay(resolvePath(pathToRemove));
let paths = getRecentPaths();
const originalLength = paths.length;
// Filter out the path to remove
paths = paths.filter(p => normalizePathForDisplay(p) !== normalized);
if (paths.length < originalLength) {
// Save updated list
writeFileSync(RECENT_PATHS_FILE, JSON.stringify({ paths }, null, 2), 'utf8');
return true;
}
return false;
} catch {
return false;
}
}

148
ccw/src/utils/ui.js Normal file
View File

@@ -0,0 +1,148 @@
import chalk from 'chalk';
import figlet from 'figlet';
import boxen from 'boxen';
import gradient from 'gradient-string';
import ora from 'ora';
// Custom gradient colors
const claudeGradient = gradient(['#00d4ff', '#00ff88']);
const codeGradient = gradient(['#00ff88', '#ffff00']);
const workflowGradient = gradient(['#ffff00', '#ff8800']);
/**
* Display ASCII art banner
*/
export function showBanner() {
console.log('');
// CLAUDE in cyan gradient
try {
const claudeText = figlet.textSync('Claude', { font: 'Standard' });
console.log(claudeGradient(claudeText));
} catch {
console.log(chalk.cyan.bold(' Claude'));
}
// CODE in green gradient
try {
const codeText = figlet.textSync('Code', { font: 'Standard' });
console.log(codeGradient(codeText));
} catch {
console.log(chalk.green.bold(' Code'));
}
// WORKFLOW in yellow gradient
try {
const workflowText = figlet.textSync('Workflow', { font: 'Standard' });
console.log(workflowGradient(workflowText));
} catch {
console.log(chalk.yellow.bold(' Workflow'));
}
console.log('');
}
/**
* Display header with version info
* @param {string} version - Version number
* @param {string} mode - Installation mode
*/
export function showHeader(version, mode = '') {
showBanner();
const versionText = version ? `v${version}` : '';
const modeText = mode ? ` (${mode})` : '';
console.log(boxen(
chalk.cyan.bold('Claude Code Workflow System') + '\n' +
chalk.gray(`Installer ${versionText}${modeText}`) + '\n\n' +
chalk.white('Unified workflow system with comprehensive coordination'),
{
padding: 1,
margin: { top: 0, bottom: 1, left: 2, right: 2 },
borderStyle: 'round',
borderColor: 'cyan'
}
));
}
/**
* Create a spinner
* @param {string} text - Spinner text
* @returns {ora.Ora}
*/
export function createSpinner(text) {
return ora({
text,
color: 'cyan',
spinner: 'dots'
});
}
/**
* Display success message
* @param {string} message
*/
export function success(message) {
console.log(chalk.green('✓') + ' ' + chalk.green(message));
}
/**
* Display info message
* @param {string} message
*/
export function info(message) {
console.log(chalk.cyan('') + ' ' + chalk.cyan(message));
}
/**
* Display warning message
* @param {string} message
*/
export function warning(message) {
console.log(chalk.yellow('⚠') + ' ' + chalk.yellow(message));
}
/**
* Display error message
* @param {string} message
*/
export function error(message) {
console.log(chalk.red('✖') + ' ' + chalk.red(message));
}
/**
* Display step message
* @param {number} step - Step number
* @param {number} total - Total steps
* @param {string} message - Step message
*/
export function step(stepNum, total, message) {
console.log(chalk.gray(`[${stepNum}/${total}]`) + ' ' + chalk.white(message));
}
/**
* Display summary box
* @param {Object} options
* @param {string} options.title - Box title
* @param {string[]} options.lines - Content lines
* @param {string} options.borderColor - Border color
*/
export function summaryBox({ title, lines, borderColor = 'green' }) {
const content = lines.join('\n');
console.log(boxen(content, {
title,
titleAlignment: 'center',
padding: 1,
margin: { top: 1, bottom: 1, left: 2, right: 2 },
borderStyle: 'round',
borderColor
}));
}
/**
* Display a divider line
*/
export function divider() {
console.log(chalk.gray('─'.repeat(60)));
}

156
ccw/tailwind.config.js Normal file
View File

@@ -0,0 +1,156 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/templates/**/*.html",
"./src/**/*.py",
"./static/**/*.js",
],
theme: {
extend: {
colors: {
// Base colors
background: "hsl(var(--color-background))",
foreground: "hsl(var(--color-foreground))",
card: {
DEFAULT: "hsl(var(--color-card))",
foreground: "hsl(var(--color-card-foreground))",
},
border: "hsl(var(--color-border))",
input: "hsl(var(--color-input))",
ring: "hsl(var(--color-ring))",
// Interactive colors
primary: {
DEFAULT: "hsl(var(--color-interactive-primary-default))",
hover: "hsl(var(--color-interactive-primary-hover))",
active: "hsl(var(--color-interactive-primary-active))",
disabled: "hsl(var(--color-interactive-primary-disabled))",
foreground: "hsl(var(--color-interactive-primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--color-interactive-secondary-default))",
hover: "hsl(var(--color-interactive-secondary-hover))",
active: "hsl(var(--color-interactive-secondary-active))",
disabled: "hsl(var(--color-interactive-secondary-disabled))",
foreground: "hsl(var(--color-interactive-secondary-foreground))",
},
accent: {
DEFAULT: "hsl(var(--color-interactive-accent-default))",
hover: "hsl(var(--color-interactive-accent-hover))",
active: "hsl(var(--color-interactive-accent-active))",
foreground: "hsl(var(--color-interactive-accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--color-interactive-destructive-default))",
hover: "hsl(var(--color-interactive-destructive-hover))",
foreground: "hsl(var(--color-interactive-destructive-foreground))",
},
// Semantic colors
muted: {
DEFAULT: "hsl(var(--color-muted))",
foreground: "hsl(var(--color-muted-foreground))",
},
// Sidebar colors
sidebar: {
background: "hsl(var(--color-sidebar-background))",
foreground: "hsl(var(--color-sidebar-foreground))",
primary: "hsl(var(--color-sidebar-primary))",
"primary-foreground": "hsl(var(--color-sidebar-primary-foreground))",
accent: "hsl(var(--color-sidebar-accent))",
"accent-foreground": "hsl(var(--color-sidebar-accent-foreground))",
border: "hsl(var(--color-sidebar-border))",
},
},
fontFamily: {
sans: ["var(--font-sans)", "Inter", "system-ui", "-apple-system", "sans-serif"],
mono: ["var(--font-mono)", "Consolas", "Monaco", "Courier New", "monospace"],
},
fontSize: {
xs: "var(--font-size-xs)",
sm: "var(--font-size-sm)",
base: "var(--font-size-base)",
lg: "var(--font-size-lg)",
xl: "var(--font-size-xl)",
"2xl": "var(--font-size-2xl)",
"3xl": "var(--font-size-3xl)",
"4xl": "var(--font-size-4xl)",
},
lineHeight: {
tight: "var(--line-height-tight)",
normal: "var(--line-height-normal)",
relaxed: "var(--line-height-relaxed)",
},
letterSpacing: {
tight: "var(--letter-spacing-tight)",
normal: "var(--letter-spacing-normal)",
wide: "var(--letter-spacing-wide)",
},
spacing: {
0: "var(--spacing-0)",
1: "var(--spacing-1)",
2: "var(--spacing-2)",
3: "var(--spacing-3)",
4: "var(--spacing-4)",
6: "var(--spacing-6)",
8: "var(--spacing-8)",
12: "var(--spacing-12)",
16: "var(--spacing-16)",
},
borderRadius: {
sm: "var(--border-radius-sm)",
md: "var(--border-radius-md)",
lg: "var(--border-radius-lg)",
xl: "var(--border-radius-xl)",
DEFAULT: "var(--border-radius-default)",
},
boxShadow: {
"2xs": "var(--shadow-2xs)",
xs: "var(--shadow-xs)",
sm: "var(--shadow-sm)",
md: "var(--shadow-md)",
lg: "var(--shadow-lg)",
xl: "var(--shadow-xl)",
},
opacity: {
disabled: "var(--opacity-disabled)",
hover: "var(--opacity-hover)",
active: "var(--opacity-active)",
},
transitionDuration: {
instant: "var(--duration-instant)",
fast: "var(--duration-fast)",
normal: "var(--duration-normal)",
medium: "var(--duration-medium)",
slow: "var(--duration-slow)",
},
transitionTimingFunction: {
linear: "var(--easing-linear)",
"ease-in": "var(--easing-ease-in)",
"ease-out": "var(--easing-ease-out)",
"ease-in-out": "var(--easing-ease-in-out)",
spring: "var(--easing-spring)",
},
animation: {
// Add custom animations here if needed
},
keyframes: {
// Add custom keyframes here if needed
},
},
},
plugins: [],
}

1947
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "claude-code-workflow",
"version": "6.0.1",
"description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution",
"type": "module",
"main": "ccw/src/index.js",
"bin": {
"ccw": "./ccw/bin/ccw.js"
},
"scripts": {
"start": "node ccw/bin/ccw.js",
"test": "node --test",
"prepublishOnly": "echo 'Ready to publish @dyw/claude-code-workflow'"
},
"keywords": [
"claude",
"workflow",
"ai",
"cli",
"dashboard",
"code-review",
"automation",
"development"
],
"author": "dyw",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"boxen": "^7.1.0",
"chalk": "^5.3.0",
"commander": "^11.0.0",
"figlet": "^1.7.0",
"glob": "^10.3.0",
"gradient-string": "^2.0.2",
"inquirer": "^9.2.0",
"open": "^9.1.0",
"ora": "^7.0.0"
},
"files": [
"ccw/bin/",
"ccw/src/",
".claude/agents/",
".claude/commands/",
".claude/output-styles/",
".claude/workflows/",
".claude/scripts/",
".claude/prompt-templates/",
".claude/python_script/",
".claude/skills/",
".codex/",
".gemini/",
".qwen/",
"CLAUDE.md",
"README.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/catlog22/Claude-Code-Workflow.git"
},
"bugs": {
"url": "https://github.com/catlog22/Claude-Code-Workflow/issues"
},
"homepage": "https://github.com/catlog22/Claude-Code-Workflow#readme"
}