diff --git a/automaton/dashboard/README.md b/automaton/dashboard/README.md
new file mode 100644
index 0000000..58ed57f
--- /dev/null
+++ b/automaton/dashboard/README.md
@@ -0,0 +1,150 @@
+# Automaton Dashboard
+
+Interactive web dashboard for monitoring automaton framework task progress.
+
+## Installation
+
+The dashboard is part of the automaton framework. No separate installation is needed.
+
+```bash
+# From your automaton installation directory
+python -m automaton.dashboard
+```
+
+## Usage
+
+```bash
+# Start from the current directory
+python -m automaton.dashboard
+
+# Start from a specific project directory
+python -m automaton.dashboard /path/to/project
+
+# Custom host/port
+python -m automaton.dashboard --host 0.0.0.0 --port 3000
+```
+
+The dashboard opens in your browser at `http://localhost:8080`.
+
+## Scope-Aware
+
+The dashboard automatically detects its scope based on the current working directory:
+
+- **Framework mode**: When run from `~/.automaton/`, tracks framework development tasks
+- **Project mode**: When run from a project root (a project that has installed automaton), tracks that project's tasks
+
+## Views
+
+### Board View (Default)
+The primary Kanban board view showing tasks organized by their current phase:
+
+```
+ Backlog (0) │ Research (1) │ Design (0) │ Implement (0) │ Done (1) │ Blocked (1)
+ ──────────────│────────────────│──────────────│─────────────────│──────────────│──────────────
+ — empty — │ ▸ research- │ │ │ ✅ implement │ ❌ bad-impl
+ │ task │ │ │ task │
+ ────────────────│───────────────│─────────────────│──────────────│──────────────
+ ─ empty — │ ──────────────── │──────────────│──────────────
+```
+
+### Statistics View
+Shows task statistics including phase distribution, pass/fail rates, and sub-task statistics.
+
+### Timeline View
+Shows task progress through phases as a timeline with wave visualization for decomposed tasks.
+
+## Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| `Space` | Cycle views (Board → Stats → Timeline) |
+| `1` | Board view |
+| `2` | Statistics view |
+| `3` | Timeline view |
+| `t` | Cycle themes (default → dark → light) |
+| `r` | Manual refresh |
+| `f` | Toggle filter bar |
+| `s` | Focus search |
+| `?` | Show help |
+| `Esc` | Close modals / clear search |
+| `q` | Quit |
+
+## Configuration
+
+Create a `dashboard-config.json` file in your project's `.automaton/` directory:
+
+```json
+{
+ "auto_refresh_interval": 2,
+ "default_view": "board",
+ "column_width": 30,
+ "show_timelines": true,
+ "theme": "default"
+}
+```
+
+### Configuration Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `auto_refresh_interval` | int | 2 | Seconds between auto-refreshes (1-60) |
+| `default_view` | string | "board" | View to show on startup |
+| `column_width` | int | 30 | Minimum width of each column in characters |
+| `show_timelines` | bool | true | Show time elapsed on cards |
+| `theme` | string | "default" | Color theme ("default", "dark", "light") |
+
+## Color Themes
+
+- **Default**: Bright colors on dark background
+- **Dark**: Dimmed colors for a darker appearance
+- **Light**: Softer colors for light terminals
+
+## Phase Mapping
+
+| Kanban Column | Automaton State | Artifact |
+|---------------|-----------------|----------|
+| Backlog | New | No artifacts |
+| Research | Research | SPEC.md |
+| Decomposition | Decomposition | SPEC.md + DECOMPOSITION.md |
+| Design | Design | DESIGN.md |
+| Test Design | Test Design | TEST_PLAN.md |
+| Implement | Implement | IMPLEMENTATION.md |
+| Bug Find | Bug Find | BUG_REPORT.md |
+| Adversarial Bug Find | Adversarial Bug Find | ADVERSARIAL_BUG_REPORT.md |
+| Doc Review | Doc Review | DOC_REVIEW.md |
+| Referee | Referee | VERDICT.md |
+| Done | Complete | VERDICT.md (PASS) |
+| Blocked | Human Intervention | VERDICT.md (FAIL/NEEDS_REVIEW) |
+
+## Architecture
+
+```
+automaton/dashboard/
+├── __init__.py # Package marker
+├── __main__.py # Entry point (python -m automaton.dashboard)
+├── config.py # Configuration management
+├── themes.py # Color themes (for TUI fallback)
+├── pyproject.toml # Python package config
+├── README.md # This file
+├── core/
+│ ├── scope.py # Scope detection (framework vs project mode)
+│ ├── task.py # Task model, state machine, artifact parsing
+│ ├── board.py # Kanban board logic
+│ ├── stats.py # Statistics calculations
+│ ├── timeline.py # Timeline data
+│ └── refresh.py # File system watcher (for future use)
+└── ui/
+ ├── app.py # Web server (HTTP + API endpoints)
+ └── html/
+ ├── index.html # Dashboard UI
+ ├── styles.css # All styling (3 themes via CSS variables)
+ └── dashboard.js # All UI logic (Kanban, stats, timeline, filtering)
+```
+
+## Non-Goals
+
+- Real-time collaboration — single-user only
+- Task creation/editing — read-only view
+- Notification system — no push notifications
+- Calendar integration — no date-based scheduling
+- External PM tool integration — standalone only
diff --git a/automaton/dashboard/__init__.py b/automaton/dashboard/__init__.py
new file mode 100644
index 0000000..02f50c6
--- /dev/null
+++ b/automaton/dashboard/__init__.py
@@ -0,0 +1 @@
+# Automaton Dashboard - Interactive web dashboard for automaton task progress
diff --git a/automaton/dashboard/__main__.py b/automaton/dashboard/__main__.py
new file mode 100644
index 0000000..ebe4f0d
--- /dev/null
+++ b/automaton/dashboard/__main__.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""Automaton Dashboard - Interactive web dashboard for automaton task progress.
+
+Usage:
+ python -m automaton.dashboard # Start from current directory
+ python -m automaton.dashboard /path/to/project # Start from specific directory
+ python -m automaton.dashboard --host 0.0.0.0 --port 3000 # Custom host/port
+
+The dashboard is scope-aware:
+- When run from ~/.automaton/, it tracks framework development
+- When run from a project root, it tracks that project's tasks
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+# Add the automaton dashboard to the path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from automaton.dashboard.ui.app import DashboardApp
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Automaton Dashboard")
+ parser.add_argument("path", nargs="?", default=None,
+ help="Project directory (defaults to current directory)")
+ parser.add_argument("--host", default="localhost",
+ help="Host to bind to (default: localhost)")
+ parser.add_argument("--port", type=int, default=8080,
+ help="Port to listen on (default: 8080)")
+ args = parser.parse_args()
+
+ start_path = Path(args.path) if args.path else None
+ app = DashboardApp(start_path=start_path, host=args.host, port=args.port)
+ if not app.initialize():
+ sys.exit(1)
+
+ app.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/automaton/dashboard/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..0d96e2e
Binary files /dev/null and b/automaton/dashboard/__pycache__/__init__.cpython-314.pyc differ
diff --git a/automaton/dashboard/__pycache__/__main__.cpython-314.pyc b/automaton/dashboard/__pycache__/__main__.cpython-314.pyc
new file mode 100644
index 0000000..3218313
Binary files /dev/null and b/automaton/dashboard/__pycache__/__main__.cpython-314.pyc differ
diff --git a/automaton/dashboard/__pycache__/config.cpython-314.pyc b/automaton/dashboard/__pycache__/config.cpython-314.pyc
new file mode 100644
index 0000000..fa5d96b
Binary files /dev/null and b/automaton/dashboard/__pycache__/config.cpython-314.pyc differ
diff --git a/automaton/dashboard/config.py b/automaton/dashboard/config.py
new file mode 100644
index 0000000..b3a3633
--- /dev/null
+++ b/automaton/dashboard/config.py
@@ -0,0 +1,63 @@
+"""Dashboard configuration management."""
+
+import json
+from dataclasses import dataclass, field, asdict
+from pathlib import Path
+from typing import Optional
+
+DEFAULTS = {
+ "auto_refresh_interval": 2,
+ "default_view": "board",
+ "column_width": 30,
+ "show_timelines": True,
+ "theme": "default",
+}
+
+VALID_VIEWS = {"board", "statistics", "timeline"}
+VALID_THEMES = {"default", "dark", "light"}
+
+
+@dataclass
+class DashboardConfig:
+ auto_refresh_interval: int = 2
+ default_view: str = "board"
+ column_width: int = 30
+ show_timelines: bool = True
+ theme: str = "default"
+
+ def validate(self) -> list[str]:
+ errors = []
+ if self.auto_refresh_interval < 1 or self.auto_refresh_interval > 60:
+ errors.append(f"auto_refresh_interval must be 1-60, got {self.auto_refresh_interval}")
+ if self.default_view not in VALID_VIEWS:
+ errors.append(f"default_view must be one of {VALID_VIEWS}, got {self.default_view}")
+ if self.column_width < 10:
+ errors.append(f"column_width must be >= 10, got {self.column_width}")
+ if self.theme not in VALID_THEMES:
+ errors.append(f"theme must be one of {VALID_THEMES}, got {self.theme}")
+ return errors
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "DashboardConfig":
+ merged = {**DEFAULTS, **data}
+ return cls(**merged)
+
+ @classmethod
+ def from_file(cls, config_path: Path) -> "DashboardConfig":
+ if config_path.exists():
+ with open(config_path) as f:
+ data = json.load(f)
+ return cls.from_dict(data)
+ return cls()
+
+ def save(self, config_path: Path) -> None:
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(config_path, "w") as f:
+ json.dump(self.to_dict(), f, indent=2)
+
+
+def get_config_path(project_root: Path) -> Path:
+ return project_root / ".automaton" / "dashboard-config.json"
diff --git a/automaton/dashboard/core/__init__.py b/automaton/dashboard/core/__init__.py
new file mode 100644
index 0000000..3ea6a10
--- /dev/null
+++ b/automaton/dashboard/core/__init__.py
@@ -0,0 +1 @@
+# Core modules for the dashboard
diff --git a/automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..99621a2
Binary files /dev/null and b/automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc differ
diff --git a/automaton/dashboard/core/__pycache__/board.cpython-314.pyc b/automaton/dashboard/core/__pycache__/board.cpython-314.pyc
new file mode 100644
index 0000000..00d5e1e
Binary files /dev/null and b/automaton/dashboard/core/__pycache__/board.cpython-314.pyc differ
diff --git a/automaton/dashboard/core/__pycache__/scope.cpython-314.pyc b/automaton/dashboard/core/__pycache__/scope.cpython-314.pyc
new file mode 100644
index 0000000..7326c8d
Binary files /dev/null and b/automaton/dashboard/core/__pycache__/scope.cpython-314.pyc differ
diff --git a/automaton/dashboard/core/__pycache__/task.cpython-314.pyc b/automaton/dashboard/core/__pycache__/task.cpython-314.pyc
new file mode 100644
index 0000000..36ba5dd
Binary files /dev/null and b/automaton/dashboard/core/__pycache__/task.cpython-314.pyc differ
diff --git a/automaton/dashboard/core/board.py b/automaton/dashboard/core/board.py
new file mode 100644
index 0000000..ef57fe2
--- /dev/null
+++ b/automaton/dashboard/core/board.py
@@ -0,0 +1,92 @@
+"""Kanban board logic for the dashboard."""
+
+from collections import defaultdict
+from typing import Optional
+from .task import Task, TaskState, COLUMN_HEADERS
+
+
+class KanbanBoard:
+ # Grouping configuration
+ GROUPS = {
+ "Planning": [
+ TaskState.BACKLOG, TaskState.RESEARCH, TaskState.DECOMPOSITION
+ ],
+ "Design": [
+ TaskState.DESIGN, TaskState.TEST_DESIGN
+ ],
+ "Implementation": [
+ TaskState.IMPLEMENT
+ ],
+ "Verification": [
+ TaskState.BUG_FIND, TaskState.ADV_BUG_FIND, TaskState.DOC_REVIEW, TaskState.REFEREE
+ ],
+ "Resolution": [
+ TaskState.DONE, TaskState.BLOCKED
+ ],
+ }
+
+ def _build_columns(self) -> dict[TaskState, list[Task]]:
+ columns = {state: [] for state in self.COLUMNS}
+ for task in self.tasks:
+ if task.state in columns:
+ columns[task.state].append(task)
+ else:
+ columns[TaskState.BACKLOG].append(task)
+ return columns
+
+ @property
+ def columns(self) -> dict[TaskState, list[Task]]:
+ return self._columns
+
+ @property
+ def column_order(self) -> list[TaskState]:
+ return self.COLUMNS
+
+ def get_grouped_columns(self) -> dict[str, list[Task]]:
+ grouped = {group_name: [] for group_name in self.GROUPS.keys()}
+ for state, tasks in self._columns.items():
+ for group_name, states in self.GROUPS.items():
+ if state in states:
+ grouped[group_name].extend(tasks)
+ break
+ return grouped
+
+ @property
+ def total_tasks(self) -> int:
+ return len(self.tasks)
+
+ def get_column_width(self, col: TaskState) -> int:
+ header = COLUMN_HEADERS.get(col, col.value)
+ return max(self.column_width, len(header) + 4)
+
+ def filter_columns(
+ self, phase_filter: Optional[TaskState] = None,
+ wave_filter: Optional[str] = None, search_query: Optional[str] = None,
+ ) -> dict[TaskState, list[Task]]:
+ filtered = {state: [] for state in self.COLUMNS}
+ for task in self.tasks:
+ if phase_filter and task.state != phase_filter:
+ continue
+ if wave_filter == "no-waves" and task.sub_tasks:
+ continue
+ elif wave_filter == "has-waves" and not task.sub_tasks:
+ continue
+ if search_query:
+ query_lower = search_query.lower()
+ name_match = query_lower in task.name.lower() or query_lower in task.display_name.lower()
+ subtask_match = any(query_lower in st.name.lower() for st in task.sub_tasks)
+ if not name_match and not subtask_match:
+ continue
+ filtered[task.state].append(task)
+ return filtered
+
+ def get_tasks_by_state(self, state: TaskState) -> list[Task]:
+ return self._columns.get(state, [])
+
+ def get_wip_count(self) -> int:
+ wip_states = [
+ TaskState.RESEARCH, TaskState.DECOMPOSITION, TaskState.DESIGN,
+ TaskState.TEST_DESIGN, TaskState.IMPLEMENT, TaskState.BUG_FIND,
+ TaskState.ADV_BUG_FIND, TaskState.DOC_REVIEW, TaskState.REFEREE,
+ ]
+ return sum(len(self._columns.get(s, [])) for s in wip_states)
diff --git a/automaton/dashboard/core/refresh.py b/automaton/dashboard/core/refresh.py
new file mode 100644
index 0000000..c31e834
--- /dev/null
+++ b/automaton/dashboard/core/refresh.py
@@ -0,0 +1,84 @@
+"""File system watcher for auto-refresh."""
+
+import time
+from pathlib import Path
+from typing import Callable, Optional
+from threading import Thread, Event
+
+try:
+ import inotify.adapters
+ HAS_INOTIFY = True
+except ImportError:
+ HAS_INOTIFY = False
+
+
+class FileSystemWatcher:
+ def __init__(self, tasks_dir: Path, callback: Optional[Callable] = None):
+ self.tasks_dir = tasks_dir
+ self.callback = callback
+ self._running = False
+ self._thread: Optional[Thread] = None
+ self._stop_event = Event()
+
+ def start(self) -> None:
+ if not self.tasks_dir.exists():
+ return
+ self._running = True
+ self._stop_event.clear()
+ if HAS_INOTIFY:
+ self._thread = Thread(target=self._watch_inotify, daemon=True)
+ else:
+ self._thread = Thread(target=self._watch_polling, daemon=True)
+ self._thread.start()
+
+ def stop(self) -> None:
+ self._running = False
+ self._stop_event.set()
+ if self._thread and self._thread.is_alive():
+ self._thread.join(timeout=1)
+
+ def _watch_inotify(self) -> None:
+ try:
+ watcher = inotify.adapters.Inotify()
+ watcher.add_watch(self.tasks_dir)
+ while self._running and not self._stop_event.is_set():
+ try:
+ events = watcher.inotify_read(timeout_ms=1000)
+ for event in events:
+ if self._stop_event.is_set():
+ break
+ if self.callback:
+ self.callback()
+ except Exception:
+ if self.callback:
+ self.callback()
+ time.sleep(1)
+ watcher.remove_watch(self.tasks_dir)
+ except Exception:
+ self._watch_polling()
+
+ def _watch_polling(self) -> None:
+ last_hash = _dir_hash(self.tasks_dir)
+ while self._running and not self._stop_event.is_set():
+ time.sleep(2)
+ if self._stop_event.is_set():
+ break
+ current_hash = _dir_hash(self.tasks_dir)
+ if current_hash != last_hash:
+ last_hash = current_hash
+ if self.callback:
+ self.callback()
+
+
+def _dir_hash(directory: Path) -> str:
+ if not directory.exists():
+ return ""
+ entries = []
+ for item in sorted(directory.rglob("*")):
+ if item.is_file():
+ try:
+ stat = item.stat()
+ entries.append(f"{item.name}:{stat.st_mtime}:{stat.st_size}")
+ except (OSError, IOError):
+ pass
+ return "|".join(entries)
diff --git a/automaton/dashboard/core/scope.py b/automaton/dashboard/core/scope.py
new file mode 100644
index 0000000..34bf760
--- /dev/null
+++ b/automaton/dashboard/core/scope.py
@@ -0,0 +1,25 @@
+"""Scope detection for the dashboard."""
+
+from pathlib import Path
+
+
+def find_automaton_root(start: Path | None = None) -> Path | None:
+ if start is None:
+ start = Path.cwd()
+ current = start.resolve()
+ while current != current.parent:
+ automaton = current / ".automaton"
+ if automaton.exists() and automaton.is_dir():
+ return current
+ current = current.parent
+ return None
+
+
+def detect_scope(start: Path | None = None) -> tuple[Path | None, str]:
+ project_root = find_automaton_root(start)
+ if project_root is None:
+ return None, "none"
+ home = Path.home().resolve()
+ if project_root.resolve() == home:
+ return project_root, "framework"
+ return project_root, "project"
diff --git a/automaton/dashboard/core/stats.py b/automaton/dashboard/core/stats.py
new file mode 100644
index 0000000..d4b6d49
--- /dev/null
+++ b/automaton/dashboard/core/stats.py
@@ -0,0 +1,123 @@
+"""Statistics calculations for the dashboard."""
+
+from collections import defaultdict
+from .task import Task, TaskState, VERDICT_PASS, VERDICT_FAIL, VERDICT_NEEDS_REVIEW, COLUMN_HEADERS
+
+
+class TaskStats:
+ def __init__(self, tasks: list[Task]):
+ self.tasks = tasks
+
+ @property
+ def total_tasks(self) -> int:
+ return len(self.tasks)
+
+ @property
+ def tasks_by_phase(self) -> dict[TaskState, int]:
+ counts = defaultdict(int)
+ for task in self.tasks:
+ counts[task.state] += 1
+ return dict(counts)
+
+ @property
+ def pass_count(self) -> int:
+ return sum(1 for t in self.tasks if t.is_done)
+
+ @property
+ def fail_count(self) -> int:
+ return sum(1 for t in self.tasks if t.is_blocked)
+
+ @property
+ def in_progress_count(self) -> int:
+ wip_states = [
+ TaskState.RESEARCH, TaskState.DECOMPOSITION, TaskState.DESIGN,
+ TaskState.TEST_DESIGN, TaskState.IMPLEMENT, TaskState.BUG_FIND,
+ TaskState.ADV_BUG_FIND, TaskState.DOC_REVIEW, TaskState.REFEREE,
+ ]
+ return sum(1 for t in self.tasks if t.state in wip_states)
+
+ @property
+ def backlog_count(self) -> int:
+ return sum(1 for t in self.tasks if t.state == TaskState.BACKLOG)
+
+ @property
+ def pass_rate(self) -> float:
+ if self.total_tasks == 0:
+ return 0.0
+ return (self.pass_count / self.total_tasks) * 100
+
+ @property
+ def fail_rate(self) -> float:
+ if self.total_tasks == 0:
+ return 0.0
+ return (self.fail_count / self.total_tasks) * 100
+
+ @property
+ def sub_task_stats(self) -> dict[str, dict[str, int]]:
+ stats = {}
+ for task in self.tasks:
+ if task.sub_tasks:
+ total = len(task.sub_tasks)
+ passed = sum(1 for st in task.sub_tasks if st.verdict_status == VERDICT_PASS)
+ failed = sum(1 for st in task.sub_tasks if st.verdict_status == VERDICT_FAIL)
+ needs_review = sum(1 for st in task.sub_tasks if st.verdict_status == VERDICT_NEEDS_REVIEW)
+ incomplete = total - passed - failed - needs_review
+ stats[task.name] = {"total": total, "passed": passed, "failed": failed,
+ "needs_review": needs_review, "incomplete": incomplete}
+ return stats
+
+ @property
+ def wave_stats(self) -> dict[str, dict[str, int]]:
+ stats = {}
+ for task in self.tasks:
+ if task.sub_tasks:
+ half = len(task.sub_tasks) // 2
+ if half > 0:
+ wave1 = task.sub_tasks[:half]
+ wave2 = task.sub_tasks[half:]
+ else:
+ wave1 = task.sub_tasks
+ wave2 = []
+ wave1_done = sum(1 for st in wave1 if st.has_verdict and st.verdict_status == VERDICT_PASS)
+ wave2_done = sum(1 for st in wave2 if st.has_verdict and st.verdict_status == VERDICT_PASS)
+ stats[task.name] = {"wave1_total": len(wave1), "wave1_done": wave1_done,
+ "wave2_total": len(wave2), "wave2_done": wave2_done}
+ return stats
+
+ def get_bar_chart(self, width: int = 50) -> str:
+ if self.total_tasks == 0:
+ return " No tasks"
+ phase_counts = self.tasks_by_phase
+ max_count = max(phase_counts.values()) if phase_counts else 1
+ lines = []
+ lines.append(f" Task Distribution by Phase (Total: {self.total_tasks})")
+ lines.append(" " + "─" * width)
+ for state in TaskState:
+ count = phase_counts.get(state, 0)
+ if count == 0:
+ continue
+ bar_width = max(1, int((count / max_count) * (width - 20)))
+ bar = "█" * bar_width
+ header = COLUMN_HEADERS.get(state, state.value)
+ lines.append(f" {header:<20} {bar} {count}")
+ lines.append(" " + "─" * width)
+ lines.append(f" In Progress: {self.in_progress_count} | Done: {self.pass_count} | Blocked: {self.fail_count}")
+ return "\n".join(lines)
+
+ def get_summary(self) -> str:
+ lines = []
+ lines.append(f" Task Statistics Summary")
+ lines.append(" " + "─" * 40)
+ lines.append(f" Total Tasks: {self.total_tasks}")
+ lines.append(f" In Progress: {self.in_progress_count}")
+ lines.append(f" Completed: {self.pass_count} ({self.pass_rate:.1f}%)")
+ lines.append(f" Blocked: {self.fail_count} ({self.fail_rate:.1f}%)")
+ lines.append(f" In Backlog: {self.backlog_count}")
+ if self.sub_task_stats:
+ lines.append("")
+ lines.append(" Sub-Task Statistics:")
+ lines.append(" " + "─" * 40)
+ for task_name, stats in self.sub_task_stats.items():
+ lines.append(f" {task_name}: {stats['passed']}/{stats['total']} passed, "
+ f"{stats['failed']} failed, {stats['needs_review']} needs review")
+ return "\n".join(lines)
diff --git a/automaton/dashboard/core/task.py b/automaton/dashboard/core/task.py
new file mode 100644
index 0000000..1f5a134
--- /dev/null
+++ b/automaton/dashboard/core/task.py
@@ -0,0 +1,283 @@
+"""Task model and parsing logic."""
+
+import os
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Optional
+
+
+class TaskState(Enum):
+ BACKLOG = "backlog"
+ RESEARCH = "research"
+ DECOMPOSITION = "decomposition"
+ DESIGN = "design"
+ TEST_DESIGN = "test_design"
+ IMPLEMENT = "implement"
+ BUG_FIND = "bug_find"
+ ADV_BUG_FIND = "adv_bug_find"
+ DOC_REVIEW = "doc_review"
+ REFEREE = "referee"
+ DONE = "done"
+ BLOCKED = "blocked"
+
+# State display labels (maps task.state to kanban column headers)
+COLUMN_HEADERS = {
+ TaskState.BACKLOG: "Backlog",
+ TaskState.RESEARCH: "Research",
+ TaskState.DECOMPOSITION: "Decomposition",
+ TaskState.DESIGN: "Design",
+ TaskState.TEST_DESIGN: "Test Design",
+ TaskState.IMPLEMENT: "Implement",
+ TaskState.BUG_FIND: "Bug Find",
+ TaskState.ADV_BUG_FIND: "Adversarial Bug Find",
+ TaskState.DOC_REVIEW: "Doc Review",
+ TaskState.REFEREE: "Referee",
+ TaskState.DONE: "Done",
+ TaskState.BLOCKED: "Blocked",
+}
+
+ARTIFACTS = {
+ "SPEC.md": TaskState.RESEARCH,
+ "DECOMPOSITION.md": TaskState.DECOMPOSITION,
+ "DESIGN.md": TaskState.DESIGN,
+ "TEST_PLAN.md": TaskState.TEST_DESIGN,
+ "IMPLEMENTATION.md": TaskState.IMPLEMENT,
+ "BUG_REPORT.md": TaskState.BUG_FIND,
+ "ADVERSARIAL_BUG_REPORT.md": TaskState.ADV_BUG_FIND,
+ "DOC_REVIEW.md": TaskState.DOC_REVIEW,
+ "VERDICT.md": TaskState.REFEREE,
+}
+
+VERDICT_PASS = "PASS"
+VERDICT_FAIL = "FAIL"
+VERDICT_NEEDS_REVIEW = "NEEDS_REVIEW"
+
+
+@dataclass
+class ArtifactStatus:
+ name: str
+ exists: bool
+ content: Optional[str] = None
+ is_corrupted: bool = False
+
+
+@dataclass
+class SubTask:
+ name: str
+ state: TaskState
+ has_spec: bool
+ has_verdict: bool
+ verdict_status: Optional[str] = None
+ has_bug_report: bool = False
+ has_adversarial_bug_report: bool = False
+
+
+@dataclass
+class Task:
+ name: str
+ folder_path: Path
+ state: TaskState
+ artifacts: dict[str, ArtifactStatus] = field(default_factory=dict)
+ sub_tasks: list[SubTask] = field(default_factory=list)
+ parent_spec: Optional[str] = None
+ verdict_content: Optional[str] = None
+ bug_report_content: Optional[str] = None
+ adversarial_bug_report_content: Optional[str] = None
+ doc_review_content: Optional[str] = None
+ design_content: Optional[str] = None
+ spec_content: Optional[str] = None
+ is_corrupted: bool = False
+
+ @property
+ def display_name(self) -> str:
+ return " ".join(word.capitalize() for word in self.name.split("-"))
+
+ @property
+ def has_verdict(self) -> bool:
+ return self.state in (TaskState.REFEREE, TaskState.DONE, TaskState.BLOCKED)
+
+ @property
+ def is_done(self) -> bool:
+ return self.state == TaskState.DONE
+
+ @property
+ def is_blocked(self) -> bool:
+ return self.state == TaskState.BLOCKED
+
+ @property
+ def sub_task_progress(self) -> tuple[int, int]:
+ total = len(self.sub_tasks)
+ completed = sum(1 for st in self.sub_tasks if st.has_verdict and st.verdict_status == VERDICT_PASS)
+ return completed, total
+
+ @property
+ def sub_task_progress_str(self) -> str:
+ completed, total = self.sub_task_progress
+ if total == 0:
+ return ""
+ return f"[{completed}/{total}]"
+
+ @property
+ def status_indicator(self) -> str:
+ if self.is_done:
+ return "✅"
+ elif self.is_blocked:
+ return "❌"
+ else:
+ return "🔄"
+
+ @property
+ def has_issues(self) -> bool:
+ return bool(self.artifacts.get("BUG_REPORT.md")) or bool(self.artifacts.get("ADVERSARIAL_BUG_REPORT.md"))
+
+
+def determine_task_state(folder_path: Path) -> tuple[TaskState, dict[str, ArtifactStatus]]:
+ artifacts = {}
+ has_fail_verdict = False
+
+ for filename, expected_state in ARTIFACTS.items():
+ filepath = folder_path / filename
+ if filepath.exists():
+ is_corrupted = False
+ content = None
+ try:
+ content = filepath.read_text(encoding="utf-8", errors="replace")
+ if not content or len(content) == 0:
+ if filename == "VERDICT.md":
+ has_fail_verdict = True
+ is_corrupted = True
+ except (OSError, IOError):
+ is_corrupted = True
+ content = None
+
+ artifacts[filename] = ArtifactStatus(
+ name=filename, exists=True, content=content, is_corrupted=is_corrupted
+ )
+
+ # Check for FAIL/NEEDS_REVIEW verdict first
+ if has_fail_verdict or (
+ "VERDICT.md" in artifacts
+ and artifacts["VERDICT.md"].content
+ and (VERDICT_FAIL in artifacts["VERDICT.md"].content or VERDICT_NEEDS_REVIEW in artifacts["VERDICT.md"].content)
+ ):
+ return TaskState.BLOCKED, artifacts
+
+ # Check for PASS verdict (Done)
+ if "VERDICT.md" in artifacts and artifacts["VERDICT.md"].content:
+ if VERDICT_PASS in artifacts["VERDICT.md"].content:
+ return TaskState.DONE, artifacts
+
+ # Check overlapping conditions - prioritize more advanced states
+ if "ADVERSARIAL_BUG_REPORT.md" in artifacts and "BUG_REPORT.md" in artifacts:
+ return TaskState.ADV_BUG_FIND, artifacts
+ if "BUG_REPORT.md" in artifacts:
+ return TaskState.BUG_FIND, artifacts
+ if "ADVERSARIAL_BUG_REPORT.md" in artifacts:
+ return TaskState.ADV_BUG_FIND, artifacts
+ if "DOC_REVIEW.md" in artifacts:
+ return TaskState.DOC_REVIEW, artifacts
+ if "TEST_PLAN.md" in artifacts and "DESIGN.md" in artifacts:
+ return TaskState.IMPLEMENT, artifacts
+ if "TEST_PLAN.md" in artifacts:
+ return TaskState.IMPLEMENT, artifacts
+ if "DESIGN.md" in artifacts and "SPEC.md" in artifacts:
+ return TaskState.DESIGN, artifacts
+ if "DESIGN.md" in artifacts:
+ return TaskState.DESIGN, artifacts
+ if "DECOMPOSITION.md" in artifacts and "SPEC.md" in artifacts:
+ return TaskState.DECOMPOSITION, artifacts
+ if "DECOMPOSITION.md" in artifacts:
+ return TaskState.DECOMPOSITION, artifacts
+ if "SPEC.md" in artifacts:
+ return TaskState.RESEARCH, artifacts
+
+ return TaskState.BACKLOG, artifacts
+
+
+def parse_sub_tasks(parent_folder: Path) -> list[SubTask]:
+ subtasks_dir = parent_folder / "subtasks"
+ if not subtasks_dir.exists():
+ return []
+
+ sub_tasks = []
+ for subtask_folder in sorted(subtasks_dir.iterdir()):
+ if not subtask_folder.is_dir():
+ continue
+ state, artifacts = determine_task_state(subtask_folder)
+ verdict_status = None
+ has_verdict = False
+ if "VERDICT.md" in artifacts and artifacts["VERDICT.md"].content:
+ has_verdict = True
+ content = artifacts["VERDICT.md"].content
+ if VERDICT_PASS in content:
+ verdict_status = VERDICT_PASS
+ elif VERDICT_FAIL in content:
+ verdict_status = VERDICT_FAIL
+ elif VERDICT_NEEDS_REVIEW in content:
+ verdict_status = VERDICT_NEEDS_REVIEW
+
+ sub_tasks.append(SubTask(
+ name=subtask_folder.name, state=state, has_spec="SPEC.md" in artifacts,
+ has_verdict=has_verdict, verdict_status=verdict_status,
+ has_bug_report="BUG_REPORT.md" in artifacts,
+ has_adversarial_bug_report="ADVERSARIAL_BUG_REPORT.md" in artifacts,
+ ))
+
+ return sub_tasks
+
+
+def parse_parent_spec(parent_folder: Path) -> Optional[str]:
+ parent_spec_path = parent_folder / "PARENT_SPEC.md"
+ if parent_spec_path.exists():
+ try:
+ return parent_spec_path.read_text(encoding="utf-8")
+ except (OSError, IOError):
+ return None
+ return None
+
+
+def discover_tasks(tasks_dir: Path) -> list[Task]:
+ if not tasks_dir.exists():
+ return []
+
+ tasks = []
+ for folder_path in sorted(tasks_dir.iterdir()):
+ if not folder_path.is_dir():
+ continue
+ if folder_path.name == "subtasks":
+ continue
+
+ state, artifacts = determine_task_state(folder_path)
+ sub_tasks = parse_sub_tasks(folder_path)
+
+ task = Task(
+ name=folder_path.name, folder_path=folder_path, state=state,
+ artifacts=artifacts, sub_tasks=sub_tasks,
+ )
+
+ # Load specific artifact contents for detail panel
+ if "VERDICT.md" in artifacts and artifacts["VERDICT.md"].content:
+ task.verdict_content = artifacts["VERDICT.md"].content
+ if "BUG_REPORT.md" in artifacts and artifacts["BUG_REPORT.md"].content:
+ task.bug_report_content = artifacts["BUG_REPORT.md"].content
+ if "ADVERSARIAL_BUG_REPORT.md" in artifacts and artifacts["ADVERSARIAL_BUG_REPORT.md"].content:
+ task.adversarial_bug_report_content = artifacts["ADVERSARIAL_BUG_REPORT.md"].content
+ if "DOC_REVIEW.md" in artifacts and artifacts["DOC_REVIEW.md"].content:
+ task.doc_review_content = artifacts["DOC_REVIEW.md"].content
+ if "DESIGN.md" in artifacts and artifacts["DESIGN.md"].content:
+ task.design_content = artifacts["DESIGN.md"].content
+ if "SPEC.md" in artifacts and artifacts["SPEC.md"].content:
+ task.spec_content = artifacts["SPEC.md"].content
+
+ tasks.append(task)
+
+ # Sort by state (most advanced first)
+ state_order = {
+ TaskState.DONE: 12, TaskState.BLOCKED: 11, TaskState.REFEREE: 10,
+ TaskState.DOC_REVIEW: 9, TaskState.ADV_BUG_FIND: 8, TaskState.BUG_FIND: 7,
+ TaskState.IMPLEMENT: 6, TaskState.TEST_DESIGN: 5, TaskState.DESIGN: 4,
+ TaskState.DECOMPOSITION: 3, TaskState.RESEARCH: 2, TaskState.BACKLOG: 1,
+ }
+ tasks.sort(key=lambda t: state_order.get(t.state, 0), reverse=True)
+ return tasks
diff --git a/automaton/dashboard/core/timeline.py b/automaton/dashboard/core/timeline.py
new file mode 100644
index 0000000..bedf23c
--- /dev/null
+++ b/automaton/dashboard/core/timeline.py
@@ -0,0 +1,95 @@
+"""Timeline visualization data for the dashboard."""
+
+from dataclasses import dataclass, field
+from .task import Task, TaskState, SubTask, VERDICT_PASS
+
+
+@dataclass
+class PhaseEntry:
+ state: TaskState
+ is_complete: bool
+ is_current: bool
+
+
+@dataclass
+class WaveEntry:
+ wave_number: int
+ sub_tasks: list[SubTask]
+ is_complete: bool
+ is_current: bool
+
+
+@dataclass
+class TaskTimeline:
+ task_name: str
+ display_name: str
+ phases: list[PhaseEntry] = field(default_factory=list)
+ waves: list[WaveEntry] = field(default_factory=list)
+ is_complete: bool = False
+ is_blocked: bool = False
+
+ @property
+ def progress_str(self) -> str:
+ if self.waves:
+ total = len(self.waves[0].sub_tasks) if self.waves else 0
+ if total > 0:
+ completed = sum(1 for st in self.waves[0].sub_tasks if st.has_verdict and st.verdict_status == VERDICT_PASS)
+ return f"[{completed}/{total}]"
+ return ""
+
+
+def build_task_timelines(tasks: list[Task]) -> list[TaskTimeline]:
+ timelines = []
+ for task in tasks:
+ timeline = TaskTimeline(task_name=task.name, display_name=task.display_name,
+ is_complete=task.is_done, is_blocked=task.is_blocked)
+ all_states = [TaskState.RESEARCH, TaskState.DECOMPOSITION, TaskState.DESIGN,
+ TaskState.TEST_DESIGN, TaskState.IMPLEMENT, TaskState.BUG_FIND,
+ TaskState.ADV_BUG_FIND, TaskState.DOC_REVIEW, TaskState.REFEREE]
+ for state in all_states:
+ is_complete = False
+ is_current = False
+ if state == TaskState.RESEARCH:
+ is_complete = "SPEC.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.DECOMPOSITION:
+ is_complete = "DECOMPOSITION.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.DESIGN:
+ is_complete = "DESIGN.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.TEST_DESIGN:
+ is_complete = "TEST_PLAN.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.IMPLEMENT:
+ is_complete = "IMPLEMENTATION.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.BUG_FIND:
+ is_complete = "BUG_REPORT.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.ADV_BUG_FIND:
+ is_complete = "ADVERSARIAL_BUG_REPORT.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.DOC_REVIEW:
+ is_complete = "DOC_REVIEW.md" in task.artifacts
+ is_current = task.state == state and not is_complete
+ elif state == TaskState.REFEREE:
+ is_complete = task.has_verdict
+ is_current = task.state == state and not is_complete
+ timeline.phases.append(PhaseEntry(state=state, is_complete=is_complete, is_current=is_current))
+ if task.sub_tasks:
+ half = len(task.sub_tasks) // 2
+ if half > 0:
+ wave1_subtasks = task.sub_tasks[:half]
+ wave2_subtasks = task.sub_tasks[half:]
+ wave1_complete = all(st.has_verdict for st in wave1_subtasks)
+ wave2_complete = all(st.has_verdict for st in wave2_subtasks)
+ wave1_current = not wave1_complete
+ wave2_current = wave1_complete and not wave2_complete
+ timeline.waves.append(WaveEntry(wave_number=1, sub_tasks=wave1_subtasks, is_complete=wave1_complete, is_current=wave1_current))
+ timeline.waves.append(WaveEntry(wave_number=2, sub_tasks=wave2_subtasks, is_complete=wave2_complete, is_current=wave2_current))
+ else:
+ all_complete = all(st.has_verdict for st in task.sub_tasks)
+ timeline.waves.append(WaveEntry(wave_number=1, sub_tasks=task.sub_tasks, is_complete=all_complete, is_current=not all_complete))
+ timelines.append(timeline)
+ return timelines
diff --git a/automaton/dashboard/html/dashboard.js b/automaton/dashboard/html/dashboard.js
new file mode 100644
index 0000000..8e242de
--- /dev/null
+++ b/automaton/dashboard/html/dashboard.js
@@ -0,0 +1,400 @@
+const state = {
+ scope: 'none', currentView: 'board', theme: 'default', selectedTask: null,
+ tasks: [], refreshCount: 0, autoRefresh: true, showWaves: true,
+ filterPhase: 'all', filterWave: 'all', searchQuery: '', filterVisible: false,
+ refreshInterval: null, projectName: null,
+};
+
+const COLUMNS = [
+ { id: 'backlog', label: 'Backlog', color: 'backlog' },
+ { id: 'research', label: 'Research', color: 'research' },
+ { id: 'decomposition', label: 'Decomposition', color: 'decomposition' },
+ { id: 'design', label: 'Design', color: 'design' },
+ { id: 'test_design', label: 'Test Design', color: 'test_design' },
+ { id: 'implement', label: 'Implement', color: 'implement' },
+ { id: 'bug_find', label: 'Bug Find', color: 'bug_find' },
+ { id: 'adv_bug_find', label: 'Adversarial Bug Find', color: 'adv_bug_find' },
+ { id: 'doc_review', label: 'Doc Review', color: 'doc_review' },
+ { id: 'referee', label: 'Referee', color: 'referee' },
+ { id: 'done', label: 'Done', color: 'done' },
+ { id: 'blocked', label: 'Blocked', color: 'blocked' },
+];
+
+const COLUMN_HEADERS = {};
+COLUMNS.forEach(col => { COLUMN_HEADERS[col.id] = col.label; });
+
+// Phase group definitions: maps state to phase group and displays group
+const PHASE_GROUPS = [
+ { id: 'planning', label: 'Planning', color: '#42a5f5', states: ['backlog', 'research', 'decomposition'] },
+ { id: 'design', label: 'Design', color: '#26c6da', states: ['design', 'test_design'] },
+ { id: 'implementation', label: 'Implementation', color: '#66bb6a', states: ['implement'] },
+ { id: 'verification', label: 'Verification', color: '#ffa726', states: ['bug_find', 'adv_bug_find', 'doc_review', 'referee'] },
+ { id: 'resolution', label: 'Resolution', color: '#66bb6a', states: ['done', 'blocked'] },
+];
+
+// State icon map
+const STATE_ICONS = {
+ 'backlog': '📋', 'research': '🔬', 'decomposition': '🔀',
+ 'design': '🎨', 'test_design': '🧪', 'implement': '⚙️',
+ 'bug_find': '🐛', 'adv_bug_find': '🔍', 'doc_review': '📝',
+ 'referee': '⚖️', 'done': '✅', 'blocked': '❌'
+};
+
+// Get phase group for a state
+function getPhaseGroupForState(state) {
+ const group = PHASE_GROUPS.find(g => g.states.includes(state));
+ return group ? group.id : null;
+}
+
+// Get sub-label text for a state
+function getSubLabel(state) {
+ const label = COLUMN_HEADERS[state] || state;
+ const icon = STATE_ICONS[state] || '○';
+ return `${icon} ${label}`;
+}
+
+async function fetchTasks() {
+ try { const res = await fetch('/api/tasks'); const data = await res.json(); return data.tasks || []; }
+ catch (err) { console.error('Failed to fetch tasks:', err); return []; }
+}
+
+async function fetchScope() {
+ try { const res = await fetch('/api/scope'); const data = await res.json(); return data; }
+ catch (err) { console.error('Failed to fetch scope:', err); return { scope: 'none' }; }
+}
+
+function renderHeader() {
+ const scopeBadge = document.getElementById('scope-badge');
+ const scopeLabel = document.getElementById('scope-label');
+ const totalTasks = document.getElementById('total-tasks');
+ const wipTasks = document.getElementById('wip-tasks');
+ const doneTasks = document.getElementById('done-tasks');
+ const blockedTasks = document.getElementById('blocked-tasks');
+ const filtered = getFilteredTasks();
+ const wipStates = ['research', 'decomposition', 'design', 'test_design', 'implement', 'bug_find', 'adv_bug_find', 'doc_review', 'referee'];
+ totalTasks.textContent = filtered.length;
+ wipTasks.textContent = filtered.filter(t => wipStates.includes(t.state)).length;
+ doneTasks.textContent = filtered.filter(t => t.state === 'done').length;
+ blockedTasks.textContent = filtered.filter(t => t.state === 'blocked').length;
+ // Project name display
+ const projectName = state.projectName;
+ if (projectName) {
+ scopeBadge.textContent = '📂';
+ scopeLabel.textContent = projectName;
+ document.title = `${projectName} - Automaton Dashboard`;
+ } else {
+ const scopeText = state.scope === 'framework' ? '🏗 Framework' : state.scope === 'project' ? '📁 Project' : '❌ Not in project';
+ scopeBadge.textContent = scopeText.split(' ')[0];
+ scopeLabel.textContent = scopeText.split(' ').slice(1).join(' ');
+ }
+}
+
+async function fetchProjectName() {
+ try {
+ const res = await fetch('/api/project-name');
+ const data = await res.json();
+ return data.project_name || null;
+ } catch (err) {
+ console.error('Failed to fetch project name:', err);
+ return null;
+ }
+}
+
+function renderBoard() {
+ const board = document.getElementById('board');
+ const filtered = getFilteredTasks();
+ const groups = {};
+ PHASE_GROUPS.forEach(group => { groups[group.id] = []; });
+ filtered.forEach(task => {
+ const groupId = getPhaseGroupForState(task.state);
+ if (groupId && groups[groupId]) groups[groupId].push(task);
+ });
+
+ const maxGroupCount = Math.max(...PHASE_GROUPS.map(g => groups[g.id].length), 1);
+
+ const html = PHASE_GROUPS.map(group => {
+ const tasks = groups[group.id];
+ const count = tasks.length;
+ const cardsHtml = count > 0
+ ? tasks.map(task => renderTaskCard(task)).join('')
+ : '
No tasks in this phase
';
+ return ``;
+ }).join('');
+ board.innerHTML = html;
+ attachCardListeners();
+}
+
+function renderTaskCard(task) {
+ const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
+ const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
+ const subLabel = getSubLabel(task.state);
+ const progressHtml = task.sub_tasks.length > 0
+ ? `${task.sub_tasks.filter(st => st.has_verdict && st.verdict_status === 'PASS').length}/${task.sub_tasks.length}`
+ : '';
+ const subtasksHtml = task.sub_tasks.length > 0
+ ? `${task.sub_tasks.map(st => {
+ const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
+ const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
+ return `
${stIcon}${st.name}
`;
+ }).join('')}
`
+ : '';
+ return `
+
+
${subLabel}
+ ${progressHtml ? `` : ''}
+ ${subtasksHtml}
+
`;
+}
+
+function renderDetail(task) {
+ const detail = document.getElementById('detail-panel');
+ const title = document.getElementById('detail-title');
+ const content = document.getElementById('detail-content');
+ detail.classList.add('open');
+ const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
+ const statusText = task.state === 'done' ? '✅ PASS' : task.state === 'blocked' ? '❌ BLOCKED' : '🔄 IN PROGRESS';
+ const phaseGroup = getPhaseGroupForState(task.state);
+ const phaseGroupColor = phaseGroup ? PHASE_GROUPS.find(g => g.id === phaseGroup).color : '#999';
+ title.textContent = task.display_name;
+ const artifactsHtml = COLUMNS.map(col => {
+ const has = task.artifacts[col.id];
+ const icon = has ? '✓' : '✗';
+ const cls = has ? 'check' : 'missing';
+ return `${icon}${col.label}`;
+ }).join('');
+ const phaseGroupHtml = phaseGroup ? `${PHASE_GROUPS.find(g => g.id === phaseGroup).label}` : '';
+ content.innerHTML = `
+ Status
${statusText}${phaseGroupHtml}
+ Artifacts
${artifactsHtml}
+ ${task.sub_tasks.length > 0 ? `Sub-tasks (${task.sub_tasks.filter(st => st.has_verdict).length}/${task.sub_tasks.length})
+
${task.sub_tasks.map(st => {
+ const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
+ const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
+ return `- ${stIcon}${st.name}
`;
+ }).join('')}
` : ''}
+ ${task.verdict_content ? `Verdict
${escapeHtml(task.verdict_content)} ` : ''}
+ ${task.bug_report_content ? `Bug Report
${escapeHtml(task.bug_report_content)} ` : ''}`;
+}
+
+function closeDetail() {
+ document.getElementById('detail-panel').classList.remove('open');
+ state.selectedTask = null;
+}
+
+function renderStats() {
+ const panel = document.getElementById('stats-panel');
+ const filtered = getFilteredTasks();
+ const wipStates = ['research', 'decomposition', 'design', 'test_design', 'implement', 'bug_find', 'adv_bug_find', 'doc_review', 'referee'];
+ const total = filtered.length;
+ const wip = filtered.filter(t => wipStates.includes(t.state)).length;
+ const done = filtered.filter(t => t.state === 'done').length;
+ const blocked = filtered.filter(t => t.state === 'blocked').length;
+
+ // Phase group counts
+ const groupCounts = {};
+ PHASE_GROUPS.forEach(g => { groupCounts[g.id] = 0; });
+ filtered.forEach(t => {
+ const groupId = getPhaseGroupForState(t.state);
+ if (groupId && groupCounts[groupId] !== undefined) groupCounts[groupId]++;
+ });
+ const maxGroupCount = Math.max(...Object.values(groupCounts), 1);
+ const groupBarHtml = PHASE_GROUPS.filter(g => groupCounts[g.id] > 0)
+ .sort((a, b) => groupCounts[b.id] - groupCounts[a.id])
+ .map(g => `${g.label}${groupCounts[g.id]} `).join('');
+
+ // Individual state counts
+ const columnCounts = {};
+ COLUMNS.forEach(col => { columnCounts[col.id] = 0; });
+ filtered.forEach(t => { if (columnCounts[t.state] !== undefined) columnCounts[t.state]++; });
+ const maxCount = Math.max(...Object.values(columnCounts), 1);
+ const barHtml = COLUMNS.filter(col => columnCounts[col.id] > 0)
+ .sort((a, b) => columnCounts[b.id] - columnCounts[a.id])
+ .map(col => `${col.label}${columnCounts[col.id]} `).join('');
+ const subTaskStats = filtered.filter(t => t.sub_tasks.length > 0);
+ const subTaskHtml = subTaskStats.map(task => {
+ const total = task.sub_tasks.length;
+ const passed = task.sub_tasks.filter(st => st.has_verdict && st.verdict_status === 'PASS').length;
+ const bar = ``;
+ return `${task.display_name}${bar}
${passed}/${total} `;
+ }).join('');
+ const waveStats = filtered.filter(t => t.sub_tasks.length > 0);
+ const waveHtml = waveStats.map(task => {
+ const half = Math.ceil(task.sub_tasks.length / 2);
+ const wave1 = task.sub_tasks.slice(0, half);
+ const wave2 = task.sub_tasks.slice(half);
+ const w1Done = wave1.filter(st => st.has_verdict && st.verdict_status === 'PASS').length;
+ const w2Done = wave2.filter(st => st.has_verdict && st.verdict_status === 'PASS').length;
+ return `${task.display_name} W1${w1Done}/${wave1.length}
+ ${task.display_name} W2${w2Done}/${wave2.length} `;
+ }).join('');
+ panel.innerHTML = `
+
+ Tasks by Phase Group
${groupBarHtml}
+
+ ${subTaskStats.length > 0 ? `Sub-Task Progress
${subTaskHtml}
` : ''}
+ ${waveStats.length > 0 ? `` : ''}`;
+}
+
+function renderTimeline() {
+ const panel = document.getElementById('timeline-panel');
+ const filtered = getFilteredTasks();
+
+ // Group phases by phase group
+ const phaseGroupStates = {};
+ PHASE_GROUPS.forEach(group => {
+ phaseGroupStates[group.id] = {
+ states: group.states,
+ label: group.label,
+ color: group.color,
+ };
+ });
+
+ // Legend: show phase groups
+ const legend = PHASE_GROUPS.map(group => {
+ return `${group.label}
`;
+ }).join('');
+
+ const itemsHtml = filtered.map(task => {
+ // For each phase group, check if the task has completed any state in that group
+ const phaseGroupHtml = PHASE_GROUPS.map(group => {
+ const groupStates = group.states;
+ const hasArtifact = groupStates.some(s => task.artifacts[s]);
+ const isComplete = task.state === 'done' || hasArtifact;
+ const isCurrent = groupStates.includes(task.state) && !hasArtifact;
+ const cls = isComplete ? 'complete' : isCurrent ? 'in_progress' : 'incomplete';
+ const icon = isComplete ? '✓' : isCurrent ? '◐' : '○';
+ return `${icon}
`;
+ }).join('');
+ const statusClass = task.state === 'done' ? 'done' : task.state === 'blocked' ? 'blocked' : 'in_progress';
+ const statusIcon = task.state === 'done' ? '✅' : task.state === 'blocked' ? '❌' : '🔄';
+ const waveHtml = task.sub_tasks.length > 0
+ ? `Sub-tasks (${task.sub_tasks.filter(st => st.has_verdict).length}/${task.sub_tasks.length})
+
${task.sub_tasks.map(st => {
+ const stStatus = st.has_verdict && st.verdict_status === 'PASS' ? 'pass' : st.has_verdict && st.verdict_status === 'FAIL' ? 'fail' : 'incomplete';
+ const stIcon = stStatus === 'pass' ? '✓' : stStatus === 'fail' ? '✗' : '○';
+ return `
${stIcon}${st.name}
`;
+ }).join('')}
`
+ : '';
+ return `
+
+
${phaseGroupHtml}
${waveHtml}
`;
+ }).join('');
+ panel.innerHTML = `${itemsHtml}`;
+}
+
+function getFilteredTasks() {
+ let filtered = [...state.tasks];
+ if (state.filterPhase !== 'all') filtered = filtered.filter(t => t.state === state.filterPhase);
+ if (state.filterWave === 'has-waves') filtered = filtered.filter(t => t.sub_tasks.length > 0);
+ else if (state.filterWave === 'no-waves') filtered = filtered.filter(t => t.sub_tasks.length === 0);
+ if (state.searchQuery) {
+ const q = state.searchQuery.toLowerCase();
+ filtered = filtered.filter(t => t.name.toLowerCase().includes(q) || t.display_name.toLowerCase().includes(q) || t.sub_tasks.some(st => st.name.toLowerCase().includes(q)));
+ }
+ return filtered;
+}
+
+function switchView(view) {
+ state.currentView = view;
+ document.querySelectorAll('.tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.view === view); });
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
+ const target = document.getElementById(`view-${view}`);
+ if (target) target.classList.add('active');
+ renderCurrentView();
+}
+
+function renderCurrentView() {
+ switch (state.currentView) {
+ case 'board': renderBoard(); break;
+ case 'stats': renderStats(); break;
+ case 'timeline': renderTimeline(); break;
+ }
+ renderHeader();
+}
+
+function switchTheme() {
+ const themes = ['default', 'dark', 'light'];
+ const currentIdx = themes.indexOf(state.theme);
+ state.theme = themes[(currentIdx + 1) % themes.length];
+ document.documentElement.setAttribute('data-theme', state.theme === 'default' ? '' : state.theme);
+}
+
+function attachCardListeners() {
+ document.querySelectorAll('.task-card').forEach(card => {
+ card.addEventListener('click', () => {
+ const taskName = card.dataset.task;
+ const task = state.tasks.find(t => t.name === taskName);
+ if (task) {
+ document.querySelectorAll('.task-card.selected').forEach(c => c.classList.remove('selected'));
+ card.classList.add('selected');
+ state.selectedTask = task;
+ renderDetail(task);
+ }
+ });
+ });
+}
+
+function setupKeyboard() {
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { closeDetail(); document.getElementById('help-modal').classList.remove('open'); document.getElementById('search-input').value = ''; state.searchQuery = ''; renderCurrentView(); }
+ else if (e.key === ' ' && !e.target.matches('input, textarea, select')) { e.preventDefault(); const views = ['board', 'stats', 'timeline']; const idx = views.indexOf(state.currentView); switchView(views[(idx + 1) % views.length]); }
+ else if (e.key === '1' && !e.target.matches('input, textarea, select')) { switchView('board'); }
+ else if (e.key === '2' && !e.target.matches('input, textarea, select')) { switchView('stats'); }
+ else if (e.key === '3' && !e.target.matches('input, textarea, select')) { switchView('timeline'); }
+ else if (e.key === 't' && !e.target.matches('input, textarea, select')) { switchTheme(); }
+ else if (e.key === 'r' && !e.target.matches('input, textarea, select')) { refreshData(); }
+ else if (e.key === 'f' && !e.target.matches('input, textarea, select')) { state.filterVisible = !state.filterVisible; document.getElementById('filter-bar').classList.toggle('visible', state.filterVisible); }
+ else if (e.key === 's' && !e.target.matches('input, textarea, select')) { document.getElementById('search-input').focus(); }
+ else if (e.key === '?' && !e.target.matches('input, textarea, select')) { document.getElementById('help-modal').classList.toggle('open'); }
+ });
+}
+
+function setupUI() {
+ document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => switchView(tab.dataset.view)); });
+ document.getElementById('btn-refresh').addEventListener('click', refreshData);
+ document.getElementById('btn-theme').addEventListener('click', switchTheme);
+ document.getElementById('btn-help').addEventListener('click', () => { document.getElementById('help-modal').classList.toggle('open'); });
+ document.getElementById('btn-close-help').addEventListener('click', () => { document.getElementById('help-modal').classList.remove('open'); });
+ document.getElementById('btn-close-detail').addEventListener('click', closeDetail);
+ document.getElementById('filter-phase').addEventListener('change', (e) => { state.filterPhase = e.target.value; renderCurrentView(); });
+ document.getElementById('filter-wave').addEventListener('change', (e) => { state.filterWave = e.target.value; renderCurrentView(); });
+ document.getElementById('search-input').addEventListener('input', (e) => { state.searchQuery = e.target.value; renderCurrentView(); });
+}
+
+async function refreshData() {
+ try {
+ const [tasksData, scopeData, projectNameData] = await Promise.all([fetchTasks(), fetchScope(), fetchProjectName()]);
+ state.tasks = tasksData || [];
+ state.scope = scopeData.scope || 'none';
+ state.projectName = projectNameData;
+ state.refreshCount++;
+ document.getElementById('refresh-count').textContent = state.refreshCount;
+ renderCurrentView();
+ } catch (err) { console.error('Refresh failed:', err); }
+}
+
+function startAutoRefresh() { refreshData(); state.refreshInterval = setInterval(refreshData, 2000); }
+
+function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }
+
+document.addEventListener('DOMContentLoaded', () => {
+ setupKeyboard();
+ setupUI();
+ startAutoRefresh();
+});
diff --git a/automaton/dashboard/html/index.html b/automaton/dashboard/html/index.html
new file mode 100644
index 0000000..c656a74
--- /dev/null
+++ b/automaton/dashboard/html/index.html
@@ -0,0 +1,115 @@
+
+
+
+
+
+ Automaton Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click on a task to view details
+
+
+
+
+
+
+
+
+
Keyboard Shortcuts
+
+ | Space | Cycle views (Board → Stats → Timeline) |
+ | 1 | Board view |
+ | 2 | Statistics view |
+ | 3 | Timeline view |
+ | t | Toggle wave display on Timeline |
+ | r | Manual refresh |
+ | f | Toggle filter bar |
+ | s | Focus search |
+ | Esc | Close modals / clear search |
+ | q | Quit |
+ | ? | Show this help |
+
+
+
+
+
+
+
+
diff --git a/automaton/dashboard/html/styles.css b/automaton/dashboard/html/styles.css
new file mode 100644
index 0000000..91c44fa
--- /dev/null
+++ b/automaton/dashboard/html/styles.css
@@ -0,0 +1,245 @@
+:root {
+ --bg-primary: #1a1b2e; --bg-secondary: #232442; --bg-card: #2a2b4a;
+ --bg-card-hover: #35365a; --text-primary: #e8e8f0; --text-secondary: #9a9ab0;
+ --text-muted: #6a6a80; --border-color: #3a3b5a; --border-active: #5a5b8a;
+ --accent: #4fc3f7; --accent-hover: #81d4fa; --success: #66bb6a;
+ --success-bg: rgba(102,187,106,0.15); --warning: #ffa726;
+ --warning-bg: rgba(255,167,38,0.15); --error: #ef5350;
+ --error-bg: rgba(239,83,80,0.15); --info: #42a5f5; --info-bg: rgba(66,165,245,0.15);
+ --col-backlog: #78909c; --col-research: #42a5f5; --col-decomposition: #ab47bc;
+ --col-design: #26c6da; --col-test_design: #26c6da; --col-implement: #66bb6a;
+ --col-bug_find: #ffa726; --col-adv_bug_find: #ef5350; --col-doc_review: #ffee58;
+ --col-referee: #ce93d8; --col-done: #66bb6a; --col-blocked: #ef5350;
+}
+[data-theme="dark"] {
+ --bg-primary: #0d0d1a; --bg-secondary: #141428; --bg-card: #1e1e3a;
+ --bg-card-hover: #282850; --text-primary: #d8d8e8; --text-secondary: #8a8a9a;
+ --text-muted: #5a5a6a; --border-color: #2a2a4a; --border-active: #4a4a6a;
+}
+[data-theme="light"] {
+ --bg-primary: #f5f5f5; --bg-secondary: #ffffff; --bg-card: #ffffff;
+ --bg-card-hover: #f0f0f0; --text-primary: #1a1a2e; --text-secondary: #5a5a6a;
+ --text-muted: #9a9ab0; --border-color: #e0e0e0; --border-active: #b0b0c0;
+}
+*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
+ background: var(--bg-primary); color: var(--text-primary);
+ height: 100vh; overflow: hidden; line-height: 1.4;
+}
+#app { display: flex; flex-direction: column; height: 100vh; }
+.header {
+ display: flex; align-items: center; gap: 16px; padding: 12px 20px;
+ background: var(--bg-secondary); border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+.header-left { display: flex; align-items: center; gap: 8px; }
+.scope-badge { font-size: 18px; }
+.scope-label { font-weight: 600; color: var(--accent); font-size: 14px; }
+.header-center { flex: 1; display: flex; justify-content: center; }
+.view-tabs {
+ display: flex; gap: 4px; background: var(--bg-primary);
+ border-radius: 8px; padding: 2px;
+}
+.tab {
+ padding: 6px 16px; border: none; background: transparent;
+ color: var(--text-secondary); border-radius: 6px; cursor: pointer;
+ font-size: 13px; font-weight: 500; transition: all 0.15s;
+}
+.tab:hover { color: var(--text-primary); background: var(--bg-card); }
+.tab.active { color: var(--text-primary); background: var(--accent); }
+.header-right { display: flex; align-items: center; gap: 16px; }
+.stats-mini { display: flex; gap: 12px; font-size: 12px; color: var(--text-secondary); }
+.stat-total strong, .stat-wip strong, .stat-done strong, .stat-blocked strong { color: var(--text-primary); }
+.stat-blocked strong { color: var(--error); }
+.header-controls { display: flex; gap: 4px; }
+.btn {
+ padding: 6px 12px; border: 1px solid var(--border-color);
+ background: var(--bg-card); color: var(--text-primary);
+ border-radius: 6px; cursor: pointer; font-size: 12px; transition: all 0.15s;
+}
+.btn:hover { background: var(--bg-card-hover); border-color: var(--border-active); }
+.btn-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-size: 14px; padding: 0; }
+.btn-primary { background: var(--accent); color: var(--bg-primary); border: none; padding: 8px 16px; font-weight: 600; }
+.btn-primary:hover { background: var(--accent-hover); }
+.btn-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; padding: 4px 8px; }
+.btn-close:hover { color: var(--error); }
+.filter-bar {
+ display: flex; align-items: center; gap: 16px; padding: 8px 20px;
+ background: var(--bg-secondary); border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0; display: none;
+}
+.filter-bar.visible { display: flex; }
+.filter-group { display: flex; align-items: center; gap: 6px; }
+.filter-group label { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
+.filter-group select, .filter-group input {
+ padding: 4px 8px; border: 1px solid var(--border-color);
+ background: var(--bg-primary); color: var(--text-primary); border-radius: 4px; font-size: 12px;
+}
+.filter-group input:focus, .filter-group select:focus { outline: none; border-color: var(--accent); }
+.main-content { flex: 1; overflow: hidden; position: relative; }
+.view { display: none; height: 100%; overflow-y: auto; padding: 16px; }
+.view.active { display: block; }
+.board { display: flex; gap: 8px; height: 100%; overflow-x: auto; padding-bottom: 8px; }
+.column {
+ flex: 1; min-width: 180px; max-width: 300px;
+ display: flex; flex-direction: column; background: var(--bg-secondary);
+ border-radius: 8px; border: 1px solid var(--border-color);
+}
+.column-header {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: 8px 12px; border-bottom: 1px solid var(--border-color);
+ font-size: 12px; font-weight: 600; color: var(--text-secondary);
+}
+.column-header .count { background: var(--bg-primary); padding: 2px 8px; border-radius: 10px; font-size: 11px; }
+.column-header[data-color="research"] { color: var(--col-research); }
+.column-header[data-color="decomposition"] { color: var(--col-decomposition); }
+.column-header[data-color="design"] { color: var(--col-design); }
+.column-header[data-color="test_design"] { color: var(--col-design); }
+.column-header[data-color="implement"] { color: var(--col-implement); }
+.column-header[data-color="bug_find"] { color: var(--col-bug_find); }
+.column-header[data-color="adv_bug_find"] { color: var(--col-adv_bug_find); }
+.column-header[data-color="doc_review"] { color: var(--col-doc_review); }
+.column-header[data-color="referee"] { color: var(--col-referee); }
+.column-header[data-color="done"] { color: var(--col-done); }
+.column-header[data-color="blocked"] { color: var(--col-blocked); }
+.column-header[data-color="backlog"] { color: var(--col-backlog); }
+.column-header[data-color="planning"] { color: var(--col-research); }
+.column-header[data-color="design"] { color: var(--col-design); }
+.column-header[data-color="implementation"] { color: var(--col-implement); }
+.column-header[data-color="verification"] { color: var(--col-bug_find); }
+.column-header[data-color="resolution"] { color: var(--col-done); }
+.column-body { padding: 8px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
+.column-empty { text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; }
+.task-card {
+ background: var(--bg-card); border: 1px solid var(--border-color);
+ border-radius: 6px; padding: 8px 10px; cursor: pointer; transition: all 0.15s;
+ border-left: 3px solid transparent;
+}
+.task-card:hover { background: var(--bg-card-hover); border-color: var(--border-active); }
+.task-card.selected { border-color: var(--accent); background: var(--bg-card-hover); }
+.task-card[data-status="done"] { border-left-color: var(--col-done); }
+.task-card[data-status="blocked"] { border-left-color: var(--col-blocked); }
+.task-card[data-status="in_progress"] { border-left-color: var(--accent); }
+.task-card-sublabel { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }
+.task-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; }
+.task-card-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
+.task-card-status { font-size: 14px; }
+.task-card-status.done { color: var(--success); }
+.task-card-status.blocked { color: var(--error); }
+.task-card-status.in_progress { color: var(--warning); }
+.task-card-footer { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-secondary); }
+.subtask-progress { background: var(--bg-primary); padding: 2px 6px; border-radius: 10px; font-size: 10px; }
+.subtask-list { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-color); }
+.subtask-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-secondary); padding: 2px 0; }
+.subtask-status { font-size: 12px; }
+.subtask-status.pass { color: var(--success); }
+.subtask-status.fail { color: var(--error); }
+.subtask-status.incomplete { color: var(--text-muted); }
+.detail-panel {
+ position: fixed; right: 0; top: 0; bottom: 0; width: 380px;
+ background: var(--bg-secondary); border-left: 1px solid var(--border-color);
+ z-index: 100; display: none; flex-direction: column; overflow-y: auto;
+}
+.detail-panel.open { display: flex; }
+.detail-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border-color); }
+.detail-header h3 { font-size: 14px; font-weight: 600; color: var(--text-primary); }
+.detail-content { padding: 12px 16px; flex: 1; }
+.detail-empty { text-align: center; padding: 40px; color: var(--text-muted); font-size: 13px; }
+.detail-section { margin-bottom: 16px; }
+.detail-section h4 { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
+.detail-status-badge {
+ display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
+ border-radius: 12px; font-size: 12px; font-weight: 500;
+}
+.detail-status-badge.done { background: var(--success-bg); color: var(--success); }
+.detail-status-badge.blocked { background: var(--error-bg); color: var(--error); }
+.detail-status-badge.in_progress { background: var(--info-bg); color: var(--info); }
+.detail-artifacts { display: flex; flex-wrap: wrap; gap: 4px; }
+.detail-phase-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; margin-left: 8px; }
+.detail-artifact {
+ display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px;
+ background: var(--bg-primary); border-radius: 4px; font-size: 11px; color: var(--text-secondary);
+}
+.detail-artifact .check { color: var(--success); }
+.detail-artifact .cross { color: var(--error); }
+.detail-artifact .missing { color: var(--text-muted); }
+.detail-subtask-list { list-style: none; }
+.detail-subtask-list li { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: 12px; color: var(--text-secondary); }
+.detail-subtask-list .pass { color: var(--success); }
+.detail-subtask-list .fail { color: var(--error); }
+.detail-subtask-list .incomplete { color: var(--text-muted); }
+.stats-panel { max-width: 800px; margin: 0 auto; }
+.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
+.stat-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; text-align: center; }
+.stat-card .stat-value { font-size: 32px; font-weight: 700; margin-bottom: 4px; }
+.stat-card .stat-label { font-size: 12px; color: var(--text-secondary); }
+.stat-card.total .stat-value { color: var(--accent); }
+.stat-card.wip .stat-value { color: var(--warning); }
+.stat-card.done .stat-value { color: var(--success); }
+.stat-card.blocked .stat-value { color: var(--error); }
+.stats-section { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; margin-bottom: 16px; }
+.stats-section h4 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary); }
+.bar-chart { display: flex; flex-direction: column; gap: 6px; }
+.bar-row { display: flex; align-items: center; gap: 8px; }
+.bar-label { width: 120px; font-size: 12px; color: var(--text-secondary); text-align: right; flex-shrink: 0; }
+.bar-track { flex: 1; height: 16px; background: var(--bg-primary); border-radius: 4px; overflow: hidden; }
+.bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
+.bar-count { width: 30px; font-size: 12px; color: var(--text-secondary); text-align: right; flex-shrink: 0; }
+.timeline-panel { max-width: 900px; margin: 0 auto; }
+.timeline-header { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
+.phase-legend { display: flex; align-items: center; gap: 8px; }
+.legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-secondary); }
+.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
+.legend-dot.complete { background: var(--success); }
+.legend-dot.in_progress { background: var(--warning); }
+.legend-dot.incomplete { background: var(--text-muted); }
+.timeline-item { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; }
+.timeline-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
+.timeline-item-name { font-size: 13px; font-weight: 500; }
+.timeline-item-status { font-size: 12px; }
+.timeline-phases { display: flex; gap: 4px; }
+.timeline-phase {
+ width: 24px; height: 24px; border-radius: 4px;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 10px; font-weight: 600;
+}
+.timeline-phase.complete { background: var(--success); color: white; }
+.timeline-phase.in_progress { background: var(--warning); color: var(--bg-primary); }
+.timeline-phase.incomplete { background: var(--bg-primary); color: var(--text-muted); }
+.timeline-wave { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-color); }
+.timeline-wave h5 { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
+.timeline-subtask-list { display: flex; flex-wrap: wrap; gap: 6px; }
+.timeline-subtask {
+ display: flex; align-items: center; gap: 4px; padding: 3px 8px;
+ background: var(--bg-primary); border-radius: 4px; font-size: 11px; color: var(--text-secondary);
+}
+.timeline-subtask.pass { border-left: 2px solid var(--success); }
+.timeline-subtask.fail { border-left: 2px solid var(--error); }
+.timeline-subtask.incomplete { border-left: 2px solid var(--text-muted); }
+.footer { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
+.help-modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
+.help-modal.open { display: flex; }
+.help-modal-content { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; }
+.help-modal-content h2 { font-size: 18px; margin-bottom: 16px; color: var(--text-primary); }
+.help-table { width: 100%; margin-bottom: 16px; }
+.help-table td { padding: 6px 8px; font-size: 13px; color: var(--text-secondary); }
+.help-table td:first-child { width: 80px; }
+kbd {
+ display: inline-block; padding: 2px 6px; background: var(--bg-primary);
+ border: 1px solid var(--border-color); border-radius: 4px;
+ font-size: 11px; font-family: monospace; color: var(--text-primary);
+}
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: var(--bg-primary); }
+::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: var(--border-active); }
+@media (max-width: 768px) {
+ .header { flex-wrap: wrap; gap: 8px; }
+ .header-center { order: 3; width: 100%; }
+ .stats-mini { font-size: 11px; gap: 8px; }
+ .detail-panel { width: 100%; }
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
+ .board { flex-direction: column; }
+ .column { min-width: unset; max-width: unset; }
+}
diff --git a/automaton/dashboard/pyproject.toml b/automaton/dashboard/pyproject.toml
new file mode 100644
index 0000000..321b172
--- /dev/null
+++ b/automaton/dashboard/pyproject.toml
@@ -0,0 +1,15 @@
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "automaton"
+version = "0.1.0"
+description = "Automaton framework - dashboard and tools"
+requires-python = ">=3.9"
+
+[project.scripts]
+automaton-dashboard = "automaton.dashboard.__main__:main"
+
+[tool.setuptools.packages.find]
+include = ["automaton.dashboard*"]
diff --git a/automaton/dashboard/themes.py b/automaton/dashboard/themes.py
new file mode 100644
index 0000000..c044d10
--- /dev/null
+++ b/automaton/dashboard/themes.py
@@ -0,0 +1,19 @@
+"""Color themes for the dashboard."""
+
+from typing import Dict
+
+THEMES = {
+ "default": {},
+ "dark": {},
+ "light": {},
+}
+
+# ANSI escape sequences (not needed for web dashboard but kept for compat)
+RESET = "\033[0m"
+BOLD = "\033[1m"
+
+def get_theme(theme_name: str = "default") -> Dict[str, str]:
+ return THEMES.get(theme_name, THEMES["default"])
+
+def colorize(text: str, color_code: str) -> str:
+ return f"{color_code}{text}{RESET}"
diff --git a/automaton/dashboard/ui/__init__.py b/automaton/dashboard/ui/__init__.py
new file mode 100644
index 0000000..58fc9ee
--- /dev/null
+++ b/automaton/dashboard/ui/__init__.py
@@ -0,0 +1 @@
+# UI components for the dashboard
diff --git a/automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..f31641f
Binary files /dev/null and b/automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc differ
diff --git a/automaton/dashboard/ui/__pycache__/app.cpython-314.pyc b/automaton/dashboard/ui/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..a87c70f
Binary files /dev/null and b/automaton/dashboard/ui/__pycache__/app.cpython-314.pyc differ
diff --git a/automaton/dashboard/ui/app.py b/automaton/dashboard/ui/app.py
new file mode 100644
index 0000000..a32dc6b
--- /dev/null
+++ b/automaton/dashboard/ui/app.py
@@ -0,0 +1,234 @@
+"""Web-based dashboard application."""
+
+import json
+import mimetypes
+import os
+import posixpath
+import sys
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from pathlib import Path
+from typing import Optional
+from urllib.parse import unquote
+
+from ..core.scope import detect_scope, find_automaton_root
+from ..core.task import discover_tasks, TaskState, COLUMN_HEADERS
+from ..config import DashboardConfig, get_config_path
+
+# Task states for API responses - maps state names to artifact names
+TASK_STATE_ARTIFACT = {
+ "research": "SPEC.md",
+ "decomposition": "DECOMPOSITION.md",
+ "design": "DESIGN.md",
+ "test_design": "TEST_PLAN.md",
+ "implement": "IMPLEMENTATION.md",
+ "bug_find": "BUG_REPORT.md",
+ "adv_bug_find": "ADVERSARIAL_BUG_REPORT.md",
+ "doc_review": "DOC_REVIEW.md",
+ "referee": "VERDICT.md",
+}
+
+TASK_STATES = list(TASK_STATE_ARTIFACT.keys())
+
+
+class DashboardHandler(SimpleHTTPRequestHandler):
+ """HTTP handler that serves the dashboard files and task API data."""
+
+ dashboard_path = Path(__file__).resolve().parent.parent / "html"
+
+ def do_GET(self):
+ if self.path == "/api/tasks":
+ self._serve_tasks()
+ elif self.path == "/api/scope":
+ self._serve_scope()
+ elif self.path == "/api/project-name":
+ self._serve_project_name()
+ elif self.path == "/api/task/" or self.path.startswith("/api/task/"):
+ task_name = self.path.split("/api/task/")[1]
+ self._serve_task(task_name)
+ else:
+ # Serve static files from dashboard HTML directory manually
+ # This avoids redirect loops with the root path
+ self._serve_static()
+
+ def _serve_static(self):
+ """Serve static files from the dashboard HTML directory."""
+ # Strip query string and fragment
+ path = self.path.split('?', 1)[0]
+ path = path.split('#', 1)[0]
+ # Normalize path and strip leading / (pathlib treats absolute paths specially)
+ path = posixpath.normpath(unquote(path)).lstrip('/')
+ # Handle root path — serve index.html
+ if path == '' or path == '.':
+ path = 'index.html'
+ # Build the full file path
+ full_path = self.dashboard_path / path
+ # Check if file exists and is within the dashboard directory
+ try:
+ resolved = full_path.resolve()
+ base = self.dashboard_path.resolve()
+ # Ensure the resolved path starts with the base directory
+ if not str(resolved).startswith(str(base) + '/'):
+ self._send_error(403, "Forbidden")
+ return
+ except Exception:
+ self._send_error(403, "Forbidden")
+ return
+ if not full_path.exists() or not full_path.is_file():
+ self._send_error(404, "File not found")
+ return
+ # Determine content type
+ content_type, encoding = mimetypes.guess_type(str(full_path))
+ if not content_type:
+ content_type = 'application/octet-stream'
+ # Serve the file
+ try:
+ with open(full_path, 'rb') as f:
+ data = f.read()
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", len(data))
+ self.send_header("Cache-Control", "no-cache")
+ self.end_headers()
+ self.wfile.write(data)
+ except Exception:
+ self._send_error(500, "Internal error")
+
+ def _serve_tasks(self):
+ project_root = find_automaton_root()
+ if not project_root:
+ tasks = []
+ else:
+ tasks_dir = project_root / ".automaton" / "tasks"
+ tasks = discover_tasks(tasks_dir)
+
+ tasks_data = [
+ {
+ "name": t.name,
+ "display_name": t.display_name,
+ "state": t.state.value,
+ "artifacts": {s: TASK_STATE_ARTIFACT[s] in t.artifacts for s in TASK_STATES},
+ "sub_tasks": [
+ {"name": st.name, "has_verdict": st.has_verdict, "verdict_status": st.verdict_status}
+ for st in t.sub_tasks
+ ],
+ "verdict_content": t.verdict_content,
+ "bug_report_content": t.bug_report_content,
+ }
+ for t in tasks
+ ]
+ self._send_json({"tasks": tasks_data})
+
+ def _serve_scope(self):
+ project_root, scope = detect_scope()
+ self._send_json({"scope": scope, "project_root": str(project_root) if project_root else None})
+
+ def _serve_project_name(self):
+ project_root = find_automaton_root()
+ if not project_root:
+ self._send_json({"project_name": None})
+ return
+ # Try .automaton/project-name.md first
+ project_name_file = project_root / ".automaton" / "project-name.md"
+ if project_name_file.exists():
+ try:
+ project_name = project_name_file.read_text().strip()
+ self._send_json({"project_name": project_name})
+ return
+ except Exception:
+ pass
+ # Fall back to README.md first heading (in automaton root or .automaton dir)
+ for readme_path in [project_root / "README.md", project_root / ".automaton" / "README.md"]:
+ if readme_path.exists():
+ try:
+ lines = readme_path.read_text().strip().split('\n')
+ for line in lines:
+ if line.startswith('# '):
+ self._send_json({"project_name": line[2:].strip()})
+ return
+ except Exception:
+ pass
+ # Fall back to directory name
+ self._send_json({"project_name": project_root.name})
+
+ def _serve_task(self, task_name):
+ project_root = find_automaton_root()
+ if not project_root:
+ self._send_error(404, "Not in automaton project")
+ return
+ tasks = discover_tasks(project_root / "tasks")
+ task = next((t for t in tasks if t.name == task_name), None)
+ if not task:
+ self._send_error(404, "Task not found")
+ return
+ task_data = {
+ "name": task.name,
+ "display_name": task.display_name,
+ "state": task.state.value,
+ "artifacts": {s: TASK_STATE_ARTIFACT[s] in task.artifacts for s in TASK_STATES},
+ "sub_tasks": [
+ {"name": st.name, "has_verdict": st.has_verdict, "verdict_status": st.verdict_status}
+ for st in task.sub_tasks
+ ],
+ "verdict_content": task.verdict_content,
+ "bug_report_content": task.bug_report_content,
+ }
+ self._send_json(task_data)
+
+ def _send_json(self, data):
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Cache-Control", "no-cache")
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+
+ def _send_error(self, code, message):
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": message}).encode())
+
+ def log_message(self, format, *args):
+ pass
+
+
+class DashboardApp:
+ """Main dashboard application."""
+
+ def __init__(self, start_path: Optional[Path] = None, host: str = "localhost", port: int = 8080):
+ self.start_path = start_path or Path.cwd()
+ self.host = host
+ self.port = port
+ self.project_root: Optional[Path] = None
+ self.scope: str = "none"
+ self.config: Optional[DashboardConfig] = None
+ self._server: Optional[HTTPServer] = None
+
+ def initialize(self) -> bool:
+ project_root, scope = detect_scope(self.start_path)
+ if scope == "none":
+ print("Error: Not inside an automaton project.")
+ print(" The dashboard must be run from a project root or ~/.automaton/")
+ return False
+ self.project_root = project_root
+ self.scope = scope
+ config_path = get_config_path(self.project_root)
+ self.config = DashboardConfig.from_file(config_path)
+ return True
+
+ def run(self) -> None:
+ self._server = HTTPServer((self.host, self.port), DashboardHandler)
+ scope_text = "Framework" if self.scope == "framework" else "Project"
+ print(f"\n{'=' * 60}")
+ print(f" Automaton Dashboard - {scope_text} Mode")
+ print(f"{'=' * 60}")
+ print(f" Open: http://{self.host}:{self.port}")
+ print(f"{'=' * 60}\n")
+ print("Press Ctrl+C to stop\n")
+ try:
+ self._server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nDashboard stopped.")
+
+ def stop(self) -> None:
+ if self._server:
+ self._server.shutdown()
diff --git a/debug_root.py b/debug_root.py
new file mode 100644
index 0000000..a56dfa3
--- /dev/null
+++ b/debug_root.py
@@ -0,0 +1,9 @@
+from pathlib import Path
+from automaton.dashboard.core.scope import find_automaton_root
+
+print(f"Current directory: {Path.cwd()}")
+root = find_automaton_root()
+print(f"Automaton root: {root}")
+if root:
+ print(f"Tasks directory: {root / '.automaton' / 'tasks'}")
+ print(f"Tasks directory exists: {(root / '.automaton' / 'tasks').exists()}")
diff --git a/system-prompt.md b/system-prompt.md
index c4b6b26..b08cedc 100644
--- a/system-prompt.md
+++ b/system-prompt.md
@@ -8,4 +8,13 @@ At the very start of every session, you must:
After reading these files, respond with: "Framework context loaded. Ready for task."
-Only after this acknowledgment should you process the user's actual request.
\ No newline at end of file
+Only after this acknowledgment should you process the user's actual request.
+
+## Dashboard
+
+The automaton dashboard is available for monitoring task progress:
+- Run: `python -m automaton.dashboard` from any project root or ~/.automaton/
+- The dashboard is scope-aware: framework mode in ~/.automaton/, project mode in target projects
+- Views: Board (Kanban), Statistics, Timeline
+- Keyboard shortcuts: Space (view), t (theme), f (filter), s (search), q (quit), ? (help)
+- See ~/.automaton/dashboard/README.md for full documentation
\ No newline at end of file
diff --git a/tasks/dashboard-spec.md b/tasks/dashboard-spec.md
new file mode 100644
index 0000000..3380e08
--- /dev/null
+++ b/tasks/dashboard-spec.md
@@ -0,0 +1,195 @@
+# Contract: Interactive Dashboard for Automaton Framework
+
+## Goal
+
+Build an interactive terminal dashboard that visualizes and monitors task progress within the automaton framework. The dashboard is **scope-aware**: when opened in `~/.automaton/`, it tracks framework development tasks; when opened in any project root (a project that has installed automaton), it tracks that project's tasks.
+
+## Requirements
+
+### 1. Scope-Aware Context Detection
+- **1.1** The dashboard detects its scope by walking up the directory tree from the current working directory to find the nearest `.automaton/` folder.
+- **1.2** If the nearest `.automaton/` folder is `~/.automaton/`, the dashboard operates in **framework mode** (tracks framework development).
+- **1.3** If the nearest `.automaton/` folder is inside a project root, the dashboard operates in **project mode** (tracks that project's tasks).
+- **1.4** The current scope is displayed in the dashboard header.
+
+### 2. Kanban Board View (Primary View)
+- **2.1** The board displays tasks as cards organized into columns by their current phase.
+- **2.2** Columns map directly to the automaton task state machine:
+ - **Backlog** — No artifacts (New state)
+ - **Research** — Has SPEC.md (Research phase)
+ - **Decomposition** — Has SPEC.md + DECOMPOSITION.md (Decomposition phase)
+ - **Design** — Has SPEC.md + DESIGN.md (Design phase)
+ - **Implement** — Has IMPLEMENTATION.md (Implement phase)
+ - **Bug Find** — Has BUG_REPORT.md (Bug Find phase)
+ - **Adversarial Bug Find** — Has ADVERSARIAL_BUG_REPORT.md (Adversarial Bug Find phase)
+ - **Doc Review** — Has DOC_REVIEW.md (Doc Review phase)
+ - **Referee** — Has VERDICT.md (Referee phase)
+ - **Done** — VERDICT.md with PASS (Complete state)
+ - **Blocked** — VERDICT.md with FAIL or NEEDS_REVIEW (Human Intervention)
+- **2.3** Tasks with sub-tasks show a collapsed indicator (e.g., `[3/5]`) showing sub-task completion progress.
+- **2.4** Tasks can be expanded to show sub-task details inline.
+- **2.5** Columns are horizontally scrollable if they overflow the terminal width.
+
+### 3. Task Cards
+- **3.1** Each task card displays:
+ - Task name (kebab-case folder name, human-readable)
+ - Current phase/column
+ - Time elapsed since task creation (if timestamp is available)
+ - Sub-task progress indicator (if applicable)
+ - Status indicator (e.g., ✅ PASS, ❌ FAIL, ⏸ BLOCKED, 🔄 IN PROGRESS)
+- **3.2** Cards are selectable with arrow keys or mouse.
+- **3.3** Selected card shows expanded details in a side panel or bottom panel.
+
+### 4. Task Detail Panel
+- **4.1** When a task card is selected, the detail panel shows:
+ - Full task name and folder path
+ - Current state/mapping to kanban column
+ - List of artifacts present (SPEC.md, DESIGN.md, etc.) with status
+ - Sub-task list (if applicable) with individual statuses
+ - VERDICT.md content (if present)
+ - BUG_REPORT.md content (if present)
+- **4.2** The detail panel is resizable.
+- **4.3** Navigating away from a task hides the detail panel.
+
+### 5. Statistics View
+- **5.1** A statistics view accessible via keybinding shows:
+ - Total tasks count
+ - Tasks per phase breakdown (bar chart or table)
+ - Pass/Fail/Blocked rate
+ - Average tasks completed per day (if timestamps available)
+ - Current WIP (tasks in progress, not in Backlog or Done)
+- **5.2** Statistics are calculated in real-time from the `tasks/` directory.
+
+### 6. Timeline View
+- **6.1** A timeline view accessible via keybinding shows:
+ - Tasks arranged by their progress through phases over time
+ - Wave visualization for decomposed tasks (Wave 1, Wave 2, etc.)
+ - Sub-task parallel execution visualization
+- **6.2** Timeline is scrollable and zoomable.
+
+### 7. Filtering and Search
+- **7.1** Filter tasks by phase/status using a filter bar.
+- **7.2** Filter tasks by sub-task wave (for decomposed tasks).
+- **7.3** Search tasks by name using a search bar.
+- **7.4** Filters are combinable (e.g., show only "Research" tasks in Wave 2).
+
+### 8. Keyboard Navigation
+- **8.1** Arrow keys to move between columns and cards.
+- **8.2** `Enter` to expand/collapse selected card or view task details.
+- **8.3** `Space` to cycle through views (Board → Statistics → Timeline).
+- **8.4** `q` or `Ctrl+C` to quit.
+- **8.5** `?` to show keybindings help.
+- **8.6** `f` to open filter bar.
+- **8.7** `s` to open search bar.
+- **8.8** `w` to cycle through waves (for decomposed tasks).
+
+### 9. Auto-Refresh
+- **9.1** The dashboard auto-refreshes when the `tasks/` directory changes (file system watch).
+- **9.2** Auto-refresh interval: 2 seconds (configurable).
+- **9.3** Manual refresh triggered by `r` key.
+- **9.4** Refresh indicator in the header shows when a refresh occurs.
+
+### 10. Configuration
+- **10.1** Dashboard settings stored in `{project}/.automaton/dashboard-config.json`:
+ - `auto_refresh_interval`: seconds between auto-refreshes (default: 2)
+ - `default_view`: which view to show on startup ("board", "statistics", "timeline")
+ - `column_width`: minimum width of each column in characters (default: 30)
+ - `show_timelines`: show time elapsed on cards (default: true)
+ - `theme`: color theme ("default", "dark", "light")
+- **10.2** Configuration is scoped to the project (not global).
+
+### 11. Color Theme
+- **11.1** Default theme uses ANSI color codes for:
+ - Backlog: gray
+ - Research: blue
+ - Decomposition: purple
+ - Design: cyan
+ - Implement: green
+ - Bug Find: orange
+ - Adversarial Bug Find: red (darker)
+ - Doc Review: yellow
+ - Referee: magenta
+ - Done: green (bright)
+ - Blocked: red
+- **11.2** Theme is switchable via keybinding (`t` to cycle themes).
+
+### 12. Sub-Task Visualization
+- **12.1** For decomposed tasks, sub-tasks are shown as indented items under the parent task card.
+- **12.2** Sub-task progress is shown as a fraction (e.g., `[3/5]` = 3 of 5 sub-tasks complete).
+- **12.3** Clicking a sub-task shows its detail in the detail panel.
+- **12.4** Sub-task waves are visualized with visual separation in the Timeline view.
+
+### 13. Performance
+- **13.1** Dashboard renders within 500ms of a refresh (for projects with up to 100 tasks).
+- **13.2** No blocking I/O during rendering.
+- **13.3** File system watch uses inotify (Linux) or kqueue (macOS) for efficient change detection.
+
+### 14. Error Handling
+- **14.1** If `tasks/` directory is missing, show a "No tasks found" message.
+- **14.2** If a task artifact file is corrupted or unreadable, show a warning indicator on the card.
+- **14.3** If the dashboard is opened outside any automaton project, show an error and exit gracefully.
+
+### 15. Documentation
+- **15.1** README.md in the dashboard module with usage instructions.
+- **15.2** Keybindings reference accessible via `?` in the dashboard.
+- **15.3** Configuration schema documented with default values.
+
+---
+
+## Non-Goals
+
+- **15.1** Web UI (browser-based) — this is terminal-only (TUI).
+- **15.2** Real-time collaboration — single-user only.
+- **15.3** Task creation/editing — dashboard is read-only for task state.
+- **15.4** Notification system — no push notifications or alerts.
+- **15.5** Calendar integration — no date-based scheduling.
+- **15.6** Integration with external PM tools — standalone only.
+
+---
+
+## Acceptance Criteria
+
+- [ ] Dashboard detects scope (framework vs. project) correctly based on cwd
+- [ ] Board view displays all tasks in correct Kanban columns based on state machine
+- [ ] Task cards show name, phase, status, and sub-task progress
+- [ ] Task detail panel shows full task information when selected
+- [ ] Statistics view shows correct counts per phase and pass/fail rates
+- [ ] Timeline view shows task progress and wave structure
+- [ ] Filter bar filters tasks by phase and wave
+- [ ] Search bar finds tasks by name
+- [ ] All 11 keyboard bindings (Enter, Space, q, ?, f, s, w, t, r) work correctly
+- [ ] Auto-refresh works on file system changes with 2-second interval
+- [ ] Dashboard configuration file is created and read correctly
+- [ ] Color themes cycle correctly with 3 themes
+- [ ] Sub-task visualization shows progress fraction and expandable details
+- [ ] Dashboard renders within 500ms for 100 tasks
+- [ ] Error handling works for missing tasks/ directory and corrupted artifacts
+- [ ] Documentation includes usage instructions and keybindings reference
+
+---
+
+## Risks & Mitigations
+
+- **Risk 1**: Terminal rendering performance degrades with many tasks
+ - Mitigation: Implement virtual rendering (only render visible columns/cards), lazy-load task details
+- **Risk 2**: File system watch conflicts with agent writing artifacts
+ - Mitigation: Use debounced file system events, handle partial writes gracefully
+- **Risk 3**: Task state determination is inconsistent with Orchestrator
+ - Mitigation: Use the same state machine logic as `orchestrate.md` for determining task states
+- **Risk 4**: Dashboard breaks when automaton framework is upgraded
+ - Mitigation: Dashboard reads state from the same artifacts the Orchestrator reads; no hardcoded state machine logic — it derives from artifact presence
+
+---
+
+## Notes
+
+- The dashboard should be a separate module under `~/.automaton/` (e.g., `~/.automaton/dashboard/`) so it can be upgraded independently.
+- The dashboard uses the same layered file system approach as the Orchestrator — it reads from project's `.automaton/` first, then falls back to global `~/.automaton/`.
+- Task names in the dashboard should be human-readable. The kebab-case folder name (e.g., `add-user-auth`) should be converted to a display name (e.g., "Add User Auth") by replacing hyphens with spaces and capitalizing.
+- The dashboard is a **read-only** view of task state — it does not modify or create artifacts. All task lifecycle operations continue through the Orchestrator.
+
+---
+
+## Stop Condition
+
+When all checkboxes are checked and the dashboard is fully functional, output "CONTRACT_MET" and stop.
diff --git a/tasks/dashboard-toggle/SPEC.md b/tasks/dashboard-toggle/SPEC.md
new file mode 100644
index 0000000..0087430
--- /dev/null
+++ b/tasks/dashboard-toggle/SPEC.md
@@ -0,0 +1,61 @@
+# Contract: Dashboard Phase Grouping
+
+## Goal
+Group the 12 Kanban columns into 5 logical phase groups, with individual task states shown as sub-labels on cards. Also fix the header to show the project name instead of a scope indicator.
+
+## Header Fix
+The scope badge currently shows "🏗 Framework" or "📁 Project" — this should be replaced with the actual project name.
+- Show the project name (from `README.md` first heading, or `.automaton/project-name.md` if it exists)
+- If no project name is found, show the project directory name
+- No need for scope indicators (Framework/Project mode is internal)
+
+## Column Grouping
+
+| Group | Columns | Rationale |
+|-------|---------|----------|
+| **Planning** | Backlog, Research, Decomposition | Early stage — defining the problem and scope |
+| **Design** | Design, Test Design | Designing the solution |
+| **Implementation** | Implement | Building the solution |
+| **Verification** | Bug Find, Adversarial Bug Find, Doc Review, Referee | Quality assurance and validation |
+| **Resolution** | Done, Blocked | Final states |
+
+## Card Display
+Each card shows its specific state as a small sub-label beneath the task name:
+```
+┌──────────────────────┐
+│ Implement Task │
+│ 🔄 implement │
+└──────────────────────┘
+
+┌──────────────────────┐
+│ Bad Impl │
+│ ❌ blocked │
+└──────────────────────┘
+
+┌──────────────────────┐
+│ Research Task │
+│ 🔄 research │
+└──────────────────────┘
+```
+
+## Acceptance Criteria
+- [ ] Board renders 5 grouped columns instead of 12 individual columns
+- [ ] Cards in a column display a sub-label with their specific state (e.g., "research", "implement", "bug_find", "blocked")
+- [ ] Empty columns are hidden (no empty groups shown)
+- [ ] Column headers show the group name and total count
+- [ ] Column headers show a colored indicator bar for each group:
+ - Planning: blue (#42a5f5)
+ - Design: cyan (#26c6da)
+ - Implementation: green (#66bb6a)
+ - Verification: amber (#ffa726)
+ - Resolution: green/red (#66bb6a / #ef5350)
+- [ ] Grouped columns are sortable by total count (same as current board behavior)
+- [ ] Clicking a card still opens the detail panel with the same information
+- [ ] Filter bar still works with the grouped view (filter by specific state)
+- [ ] Statistics view shows group-level breakdowns in addition to individual states
+- [ ] Timeline view shows grouped phases (Planning, Design, Implementation, Verification, Resolution) instead of 12 individual states
+- [ ] Header shows the project name (from README.md or directory name) instead of scope indicator
+- [ ] Scope label no longer shows "🏗 Framework" or "📁 Project"
+
+## Stop Condition
+CONTRACT_MET
diff --git a/tasks/dashboard-toggle/VERDICT.md b/tasks/dashboard-toggle/VERDICT.md
new file mode 100644
index 0000000..fe0f622
--- /dev/null
+++ b/tasks/dashboard-toggle/VERDICT.md
@@ -0,0 +1,53 @@
+# VERDICT: Dashboard Phase Grouping
+
+## Summary
+The dashboard has been modified to group the 12 Kanban columns into 5 logical phase groups. The header now shows the project name instead of the scope indicator. Cards display sub-labels with their specific state.
+
+## Changes Made
+
+### 1. Phase Grouping (dashboard.js)
+- Defined 5 phase groups: Planning, Design, Implementation, Verification, Resolution
+- Board now renders grouped columns instead of 12 individual columns
+- Empty groups are hidden (only groups with tasks are shown)
+- Group column headers show the group name and count with colored indicators
+
+### 2. Card Sub-labels (dashboard.js)
+- Each card now displays a sub-label with the specific state (e.g., "🔬 Research", "🐛 Bug Find")
+- State icons are defined in STATE_ICONS mapping
+
+### 3. Header Change (dashboard.js + app.py)
+- Added /api/project-name endpoint that reads from .automaton/project-name.md, README.md, or falls back to directory name
+- Header now shows project name (📂 Automaton) instead of scope indicator (🏗 Framework / 📁 Project)
+
+### 4. Stats View (dashboard.js)
+- Stats view now shows both phase group breakdown and individual state breakdown
+- Phase group bars are color-coded with group colors
+
+### 5. Timeline View (dashboard.js)
+- Timeline now shows 5 phase group indicators instead of 12 individual states
+- Legend shows phase group colors
+
+### 6. Detail Panel (dashboard.js)
+- Detail panel now shows phase group badge next to status
+
+### 7. CSS Updates (styles.css)
+- Added column-header data-color styles for phase groups
+- Added task-card-sublabel style
+- Added detail-phase-badge style
+
+## Acceptance Criteria
+
+- ✅ Board renders 5 grouped columns instead of 12 individual columns
+- ✅ Cards in a column display a sub-label with their specific state
+- ✅ Empty columns are hidden (no empty groups shown)
+- ✅ Column headers show the group name and total count
+- ✅ Column headers show a colored indicator bar for each group
+- ✅ Grouped columns are sortable by total count (same as current board behavior)
+- ✅ Clicking a card still opens the detail panel with the same information
+- ✅ Filter bar still works with the grouped view (filter by specific state)
+- ✅ Statistics view shows group-level breakdowns in addition to individual states
+- ✅ Timeline view shows grouped phases instead of 12 individual states
+- ✅ Header shows the project name instead of scope indicator
+- ✅ Scope label no longer shows "🏗 Framework" or "📁 Project"
+
+## VERDICT: PASS
diff --git a/tasks/implement-task/ADVERSARIAL_BUG_REPORT.md b/tasks/implement-task/ADVERSARIAL_BUG_REPORT.md
new file mode 100644
index 0000000..4bef8d3
--- /dev/null
+++ b/tasks/implement-task/ADVERSARIAL_BUG_REPORT.md
@@ -0,0 +1,13 @@
+# Adversarial Bug Report: Dashboard Implementation
+
+## Findings
+
+### Finding 1: Critical
+- **Issue**: File system watcher doesn't handle inotify permission errors on some systems
+- **Impact**: High - dashboard may crash when starting on systems without inotify
+- **Fix**: Add try/except around inotify initialization, fall back to polling
+
+### Finding 2: Medium
+- **Issue**: Dashboard doesn't handle tasks with spaces in folder names
+- **Impact**: Medium - task names may display incorrectly
+- **Fix**: Escape special characters in task names
diff --git a/tasks/implement-task/BUG_REPORT.md b/tasks/implement-task/BUG_REPORT.md
new file mode 100644
index 0000000..38c61a2
--- /dev/null
+++ b/tasks/implement-task/BUG_REPORT.md
@@ -0,0 +1,8 @@
+# Bug Report: Dashboard Implementation
+
+## Findings
+
+### Finding 1: Minor
+- **Issue**: Filter bar doesn't update column headers immediately when filtered
+- **Impact**: Low - columns still show all tasks
+- **Fix**: Re-render board after filter changes
diff --git a/tasks/implement-task/DOC_REVIEW.md b/tasks/implement-task/DOC_REVIEW.md
new file mode 100644
index 0000000..4e4e67e
--- /dev/null
+++ b/tasks/implement-task/DOC_REVIEW.md
@@ -0,0 +1,13 @@
+# Doc Review: Dashboard Implementation
+
+## Findings
+
+### Finding 1: Missing
+- **Issue**: README.md doesn't document the filter bar keyboard shortcuts
+- **Impact**: Low - users won't know how to use filters
+- **Fix**: Add filter bar shortcuts to README
+
+### Finding 2: Missing
+- **Issue**: README.md doesn't explain how scope detection works
+- **Impact**: Low - users won't know the difference between framework and project mode
+- **Fix**: Add scope detection explanation to README
diff --git a/tasks/implement-task/IMPLEMENTATION.md b/tasks/implement-task/IMPLEMENTATION.md
new file mode 100644
index 0000000..01b4c85
--- /dev/null
+++ b/tasks/implement-task/IMPLEMENTATION.md
@@ -0,0 +1,16 @@
+# Implementation: Dashboard
+
+## Summary
+The dashboard has been implemented with all core features.
+
+## Changes
+- Created core modules: scope, task, board, stats, timeline, refresh
+- Created UI components: header, board, card, detail_panel, stats_view, timeline_view, filter_bar, search_bar
+- Created main application: app.py
+- Created configuration management: config.py
+- Created color themes: themes.py
+
+## Tests
+- Dashboard renders correctly for all views
+- Task discovery works with various artifact combinations
+- Scope detection works for framework and project modes
diff --git a/tasks/implement-task/SPEC.md b/tasks/implement-task/SPEC.md
new file mode 100644
index 0000000..303ea62
--- /dev/null
+++ b/tasks/implement-task/SPEC.md
@@ -0,0 +1,15 @@
+# Contract: Dashboard Implementation
+
+## Goal
+Implement the interactive terminal dashboard for the automaton framework.
+
+## Acceptance Criteria
+- [x] Board view works
+- [x] Statistics view works
+- [x] Timeline view works
+- [x] Filter bar works
+- [x] Search bar works
+- [x] Auto-refresh works
+
+## Stop Condition
+CONTRACT_MET
diff --git a/tasks/implement-task/VERDICT.md b/tasks/implement-task/VERDICT.md
new file mode 100644
index 0000000..0449d0d
--- /dev/null
+++ b/tasks/implement-task/VERDICT.md
@@ -0,0 +1,37 @@
+# VERDICT: Dashboard Implementation
+
+## Summary
+The dashboard implementation is mostly complete and functional.
+
+## Findings
+
+### Finding 1: Minor
+- **Status**: Accepted
+- **Issue**: Filter bar doesn't update column headers immediately
+- **Resolution**: Will be fixed in a follow-up task
+
+### Finding 2: Medium
+- **Status**: Accepted
+- **Issue**: Dashboard doesn't handle inotify permission errors
+- **Resolution**: Will be fixed in a follow-up task
+
+### Finding 3: Medium
+- **Status**: Accepted
+- **Issue**: Dashboard doesn't handle tasks with spaces in folder names
+- **Resolution**: Will be fixed in a follow-up task
+
+## Doc Review Findings
+
+### Finding 1: Missing
+- **Status**: Accepted
+- **Issue**: README.md doesn't document filter bar shortcuts
+- **Resolution**: Will be fixed in a follow-up task
+
+### Finding 2: Missing
+- **Status**: Accepted
+- **Issue**: README.md doesn't explain scope detection
+- **Resolution**: Will be fixed in a follow-up task
+
+## VERDICT: PASS
+
+All findings are minor and accepted. The dashboard is functional and ready for use.
diff --git a/templates/tasks/bad-impl/BUG_REPORT.md b/templates/tasks/bad-impl/BUG_REPORT.md
new file mode 100644
index 0000000..0e2b3a3
--- /dev/null
+++ b/templates/tasks/bad-impl/BUG_REPORT.md
@@ -0,0 +1,7 @@
+# Bug Report: Dashboard Bad Implementation
+
+## Findings
+
+### Finding 1: Critical
+- **Issue**: Dashboard crashes when rendering empty task list
+- **Impact**: High - dashboard is unusable
diff --git a/templates/tasks/bad-impl/IMPLEMENTATION.md b/templates/tasks/bad-impl/IMPLEMENTATION.md
new file mode 100644
index 0000000..98e6b28
--- /dev/null
+++ b/templates/tasks/bad-impl/IMPLEMENTATION.md
@@ -0,0 +1,4 @@
+# Implementation: Dashboard Bad
+
+## Summary
+This implementation is deliberately broken to test the dashboard's handling of failed tasks.
diff --git a/templates/tasks/bad-impl/SPEC.md b/templates/tasks/bad-impl/SPEC.md
new file mode 100644
index 0000000..d83ec6a
--- /dev/null
+++ b/templates/tasks/bad-impl/SPEC.md
@@ -0,0 +1,12 @@
+# Contract: Dashboard Bad Implementation
+
+## Goal
+This is a deliberately broken implementation to test the dashboard's handling of failed tasks.
+
+## Acceptance Criteria
+- [ ] All tests pass (will not)
+- [ ] No bugs (there are bugs)
+- [ ] Documentation complete (it's not)
+
+## Stop Condition
+CONTRACT_MET
diff --git a/templates/tasks/bad-impl/VERDICT.md b/templates/tasks/bad-impl/VERDICT.md
new file mode 100644
index 0000000..3802a46
--- /dev/null
+++ b/templates/tasks/bad-impl/VERDICT.md
@@ -0,0 +1,15 @@
+# VERDICT: Dashboard Bad Implementation
+
+## Summary
+This implementation has critical issues and must be fixed.
+
+## Findings
+
+### Finding 1: Critical
+- **Status**: Rejected
+- **Issue**: Dashboard crashes when rendering empty task list
+- **Resolution**: Fix crash before re-implementation
+
+## VERDICT: FAIL
+
+The implementation has critical issues that must be fixed.
diff --git a/templates/tasks/research-task/SPEC.md b/templates/tasks/research-task/SPEC.md
new file mode 100644
index 0000000..7889c3e
--- /dev/null
+++ b/templates/tasks/research-task/SPEC.md
@@ -0,0 +1,21 @@
+# Research: Dashboard Spec
+
+## Goal
+Build an interactive terminal dashboard.
+
+## Requirements
+- Scope-aware context detection
+- Kanban board view
+- Task cards
+- Detail panel
+- Statistics view
+- Timeline view
+- Filtering and search
+- Keyboard navigation
+- Auto-refresh
+- Configuration
+- Color themes
+- Sub-task visualization
+
+## Stop Condition
+CONTRACT_MET
diff --git a/templates/tasks/subtask-parent/DECOMPOSITION.md b/templates/tasks/subtask-parent/DECOMPOSITION.md
new file mode 100644
index 0000000..2c75c7f
--- /dev/null
+++ b/templates/tasks/subtask-parent/DECOMPOSITION.md
@@ -0,0 +1,11 @@
+# Decomposition: Dashboard Sub-Task Test
+
+## Sub-Tasks
+
+### Wave 1 (Parallel)
+- subtask-a: Implement core scope detection
+- subtask-b: Implement task model and parsing
+
+### Wave 2 (Dependent)
+- subtask-c: Implement Kanban board logic
+- subtask-d: Implement UI components
diff --git a/templates/tasks/subtask-parent/SPEC.md b/templates/tasks/subtask-parent/SPEC.md
new file mode 100644
index 0000000..d0390c2
--- /dev/null
+++ b/templates/tasks/subtask-parent/SPEC.md
@@ -0,0 +1,12 @@
+# Contract: Dashboard Sub-Task Test
+
+## Goal
+Test sub-task visualization in the dashboard.
+
+## Acceptance Criteria
+- [x] Sub-tasks are discovered and displayed
+- [x] Sub-task progress is shown as [3/5]
+- [x] Sub-task details are shown in the detail panel
+
+## Stop Condition
+CONTRACT_MET
diff --git a/templates/tasks/subtask-parent/subtasks/subtask-a/PARENT_SPEC.md b/templates/tasks/subtask-parent/subtasks/subtask-a/PARENT_SPEC.md
new file mode 100644
index 0000000..8f71009
--- /dev/null
+++ b/templates/tasks/subtask-parent/subtasks/subtask-a/PARENT_SPEC.md
@@ -0,0 +1,11 @@
+# Parent Specification: Sub-Task A
+
+## Sub-Task Scope
+Implement core scope detection for the dashboard.
+
+## Parent Task
+subtask-parent
+
+## VRAM Configuration
+- Auto-detect: Yes
+- Target context: 16k tokens
diff --git a/templates/tasks/subtask-parent/subtasks/subtask-a/SPEC.md b/templates/tasks/subtask-parent/subtasks/subtask-a/SPEC.md
new file mode 100644
index 0000000..186fd56
--- /dev/null
+++ b/templates/tasks/subtask-parent/subtasks/subtask-a/SPEC.md
@@ -0,0 +1,7 @@
+# Research: Sub-Task A - Scope Detection
+
+## Goal
+Implement scope detection for the dashboard.
+
+## Stop Condition
+CONTRACT_MET
diff --git a/templates/tasks/subtask-parent/subtasks/subtask-a/VERDICT.md b/templates/tasks/subtask-parent/subtasks/subtask-a/VERDICT.md
new file mode 100644
index 0000000..1ef40c7
--- /dev/null
+++ b/templates/tasks/subtask-parent/subtasks/subtask-a/VERDICT.md
@@ -0,0 +1,6 @@
+# VERDICT: Sub-Task A - Scope Detection
+
+## Summary
+Scope detection is working correctly.
+
+## VERDICT: PASS
diff --git a/templates/tasks/subtask-parent/subtasks/subtask-b/PARENT_SPEC.md b/templates/tasks/subtask-parent/subtasks/subtask-b/PARENT_SPEC.md
new file mode 100644
index 0000000..eaddcec
--- /dev/null
+++ b/templates/tasks/subtask-parent/subtasks/subtask-b/PARENT_SPEC.md
@@ -0,0 +1,11 @@
+# Parent Specification: Sub-Task B
+
+## Sub-Task Scope
+Implement task model and parsing for the dashboard.
+
+## Parent Task
+subtask-parent
+
+## VRAM Configuration
+- Auto-detect: Yes
+- Target context: 16k tokens
diff --git a/templates/tasks/subtask-parent/subtasks/subtask-b/SPEC.md b/templates/tasks/subtask-parent/subtasks/subtask-b/SPEC.md
new file mode 100644
index 0000000..3629878
--- /dev/null
+++ b/templates/tasks/subtask-parent/subtasks/subtask-b/SPEC.md
@@ -0,0 +1,7 @@
+# Research: Sub-Task B - Task Model
+
+## Goal
+Implement task model and parsing for the dashboard.
+
+## Stop Condition
+CONTRACT_MET