Add userDismissedAt field to agents schema
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -19,6 +19,15 @@ dist/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local data
|
||||
.cw/
|
||||
|
||||
# Test workspaces
|
||||
workdir/
|
||||
|
||||
# Agent working directories
|
||||
agent-workdirs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
101
.planning/phases/15-frontend-wireframes/15-01-SUMMARY.md
Normal file
101
.planning/phases/15-frontend-wireframes/15-01-SUMMARY.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
phase: 15-frontend-wireframes
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [wireframes, documentation, ascii, dashboard, initiative]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 11-architect-modes
|
||||
provides: Architect discuss/breakdown modes for spawn actions
|
||||
- phase: 12-decompose
|
||||
provides: Initiative/phase data model
|
||||
provides:
|
||||
- Initiative dashboard UI specification
|
||||
- Component contracts (InitiativeCard, ProgressBar, StatusBadge, ActionMenu)
|
||||
- Interaction patterns for initiative management
|
||||
affects: [16-frontend-implementation, ui-components, initiative-views]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- ASCII wireframe documentation for UI specification
|
||||
- Component props contracts before implementation
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- docs/wireframes/initiative-dashboard.md
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "12-character progress bar width for monospace rendering"
|
||||
- "Spawn Architect dropdown with discuss/breakdown modes matching CLI"
|
||||
- "Status badge color mapping for all 6 initiative statuses"
|
||||
|
||||
patterns-established:
|
||||
- "Wireframe format: ASCII art + component specs + interaction notes"
|
||||
- "Action menus use [...] trigger pattern"
|
||||
|
||||
# Metrics
|
||||
duration: 1min
|
||||
completed: 2026-02-02
|
||||
---
|
||||
|
||||
# Phase 15 Plan 01: Initiative Dashboard Wireframe Summary
|
||||
|
||||
**ASCII wireframe documenting the primary entry point UI with initiative list, status badges, progress bars, and quick actions including Spawn Architect dropdown.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1 min
|
||||
- **Started:** 2026-02-02T13:56:43Z
|
||||
- **Completed:** 2026-02-02T13:57:46Z
|
||||
- **Tasks:** 1
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created comprehensive Initiative Dashboard wireframe with multiple states (populated, empty)
|
||||
- Defined 5 component specifications with props and behavior contracts
|
||||
- Documented all interaction flows including navigation, architect spawning, and filtering
|
||||
- Established wireframe format pattern for subsequent UI documentation
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create wireframes directory and initiative dashboard wireframe** - `31bb8c7` (docs)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docs/wireframes/initiative-dashboard.md` - Initiative Dashboard wireframe with ASCII art, component specs, interactions
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- 12-character progress bar width chosen for consistent monospace rendering across terminals
|
||||
- Spawn Architect dropdown mirrors CLI modes (discuss, breakdown) for consistency
|
||||
- Status badge colors follow common UI patterns (gray=draft, blue=active, green=complete, red=rejected)
|
||||
- Filter dropdown provides all 6 status options plus "All" default
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Wireframe format established for remaining screens
|
||||
- Component contracts ready for implementation reference
|
||||
- Ready for 15-02 (Initiative Detail wireframe)
|
||||
|
||||
---
|
||||
*Phase: 15-frontend-wireframes*
|
||||
*Completed: 2026-02-02*
|
||||
54
CLAUDE.md
Normal file
54
CLAUDE.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Codewalk District
|
||||
|
||||
Multi-agent workspace for orchestrating multiple Claude Code agents.
|
||||
|
||||
## Database
|
||||
|
||||
Schema is defined in `src/db/schema.ts` using drizzle-orm. Migrations are managed by drizzle-kit.
|
||||
|
||||
See [docs/database-migrations.md](docs/database-migrations.md) for the full migration workflow, rules, and commands.
|
||||
|
||||
Key rule: **never use raw SQL for schema initialization.** Always use `drizzle-kit generate` and the migration system.
|
||||
|
||||
## Logging
|
||||
|
||||
Structured logging via pino. See [docs/logging.md](docs/logging.md) for full details.
|
||||
|
||||
Key rule: use `createModuleLogger()` from `src/logger/index.ts` for backend logging. Keep `console.log` for CLI user-facing output only.
|
||||
|
||||
## Build
|
||||
|
||||
After completing any change to server-side code (`src/**`), rebuild and re-link the `cw` binary:
|
||||
|
||||
```sh
|
||||
npm run build && npm link
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
### E2E Tests (Real CLI)
|
||||
|
||||
Real provider integration tests call actual CLI tools and incur API costs. They are **skipped by default**.
|
||||
|
||||
```sh
|
||||
# Claude tests (~$0.50, ~3 min)
|
||||
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
|
||||
|
||||
# Codex tests only
|
||||
REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/codex-manager.test.ts --test-timeout=300000
|
||||
|
||||
# Both providers
|
||||
REAL_CLAUDE_TESTS=1 REAL_CODEX_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
|
||||
```
|
||||
|
||||
Test files in `src/test/integration/real-providers/`:
|
||||
- `claude-manager.test.ts` - Spawn, output parsing, session resume
|
||||
- `schema-retry.test.ts` - Schema validation, JSON extraction, retry logic
|
||||
- `crash-recovery.test.ts` - Server restart simulation
|
||||
- `codex-manager.test.ts` - Codex provider tests
|
||||
311
README.md
Normal file
311
README.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Codewalk District
|
||||
|
||||
# Project concept
|
||||
|
||||
Codewalk district is a multi-agent workspace inspired by gastown. It works differently in the following ways:
|
||||
* Subagents (e.g. Workers) that handle tasks run in -p mode and respond with a clear json schema
|
||||
* One cw (codewalk) web server is running that is also managing the agents
|
||||
* There shall be a clear post worktree setup hook that by default copies files (e.g. .env files) prepared inside a dedicated folder in the Project
|
||||
* It shall support multiple claude code accounts (see ccswitch) and switch them as they run into usage limits
|
||||
* It shall have a web dashboard at some point in the project
|
||||
* The project shall start with a file based UI. That is a folder structure representing the data of the project refreshed when saving (fs events) and updated when db data changes (use events as trigger). The fsui shall be started with `cw fsui` which instantiate a bidirectional watcher that subscribes to the events via a websocket
|
||||
* It shall support integration branches that Workers clone their work from and integrate branches into
|
||||
* It shall base all it's larger development work on initiatives. Initiatives describe a larger amount of work. The concept from the user must follow a formal planning process where the work is verified for integration into the existing codebase, a sophisticated technical concept is created. An initiative is only started once approved by a developer. Analysis work is performed by Architects.
|
||||
* The project shall use a global SQlite DB which also manages tasks
|
||||
* It shall have a cli (the cli shall also be the server application only that it only works as a cli when not run with --server). The cli shall be called "cw"
|
||||
* The communication from and between agents shall happen using an STDIO based mcp that is also implemented in the main binary. e.g. cw mcp
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Implementation considerations
|
||||
|
||||
* Typescript as a programming language
|
||||
* Trpc as an API layer
|
||||
* React with shadcn & tanstack router for the frontend running with vite. Tiptap for markdown editor UIs
|
||||
* Simple deployment (one web server serving front and backend in deployed mode - in dev the frontend may use a dev server for hot reloads). The app shall just be startable by installing the cli and then running it with --server. No more setup needed. The local frontend dev server shall be proxied through the backend in the same path as the compiled frontend would be served in production mode
|
||||
* SQLite as a database
|
||||
* Future support for multi user management (for now only one user implement a stub)
|
||||
* Hexagonal architecture
|
||||
* Built as a modular monolith with clear separation between modules incl. event bus (can be process internal with swappable adapter for the future)
|
||||
|
||||
---
|
||||
|
||||
# Modules
|
||||
|
||||
## Tasks
|
||||
|
||||
Beads-inspired task management for agent coordination. Centralized SQLite storage (not Git-distributed like beads).
|
||||
|
||||
Key features:
|
||||
* **Status workflow**: `open` → `in_progress` → `blocked` | `closed`
|
||||
* **Priority system**: P0 (critical) through P3 (low)
|
||||
* **Dependency graph**: Tasks block other tasks; `ready` query finds actionable work
|
||||
* **Assignment tracking**: Prevents multiple agents claiming same task
|
||||
* **Audit history**: All state changes logged for debugging
|
||||
|
||||
CLI mirrors beads: `cw task ready`, `cw task create`, `cw task close`, etc.
|
||||
|
||||
See [docs/tasks.md](docs/tasks.md) for schema and CLI reference.
|
||||
|
||||
## Initiatives
|
||||
|
||||
Notion-like document hierarchy for planning larger features. SQLite-backed with parent-child relationships for structured queries (e.g., "all subpages of initiative X", "inventory of all documents").
|
||||
|
||||
Key features:
|
||||
* **Lifecycle**: `draft` → `review` → `approved` → `in_progress` → `completed`
|
||||
* **Nested pages**: User journeys, business rules, technical concepts, architectural changes
|
||||
* **Phased work plans**: Approved initiatives generate tasks grouped into phases
|
||||
* **Rolling approval**: User approves phase plans one-by-one; agents execute approved phases while subsequent phases are reviewed
|
||||
|
||||
Workflow: User drafts → Architect iterates (GSD-style questioning) → Approval or draft extension and further iterations with the Architect → Tasks created with `initiative_id` + `phase` → Execute
|
||||
|
||||
See [docs/initiatives.md](docs/initiatives.md) for schema and workflow details.
|
||||
|
||||
## Domain Layer
|
||||
|
||||
DDD-based documentation of the **as-is state** for agent and human consumption. Initiatives reference and modify domain concepts; completed initiatives update the domain layer to reflect the new state.
|
||||
|
||||
**Scope**: Per-project domains or cross-project domains (features spanning multiple projects).
|
||||
|
||||
**Core concepts tracked:**
|
||||
* **Bounded Contexts** — scope boundaries defining where a domain model applies
|
||||
* **Aggregates** — consistency boundaries, what changes together
|
||||
* **Domain Events** — events exposed by the project that trigger workflows or side effects
|
||||
* **Business Rules & Invariants** — constraints that must always hold; agents must preserve these
|
||||
* **Ubiquitous Language** — glossary of domain terms to prevent agent misinterpretation
|
||||
* **Context Maps** — relationships between bounded contexts (especially for cross-project domains)
|
||||
* **External Integrations** — systems the domain interacts with but doesn't own
|
||||
|
||||
**Codebase mapping**: Each concept links to folder/module paths. Auto-maintained by agents after implementation work.
|
||||
|
||||
**Storage**: Dual adapter support — SQLite tables (structured queries) or Markdown with YAML frontmatter (human-readable, version-controllable).
|
||||
|
||||
## Orchestrator
|
||||
|
||||
Main orchestrator loop handling coordination across agents. Can be split per project or initiative for load balancing in the future.
|
||||
|
||||
## Session State
|
||||
|
||||
Tracks execution state across agent restarts. Unlike Domain Layer (codebase state), session state tracks position, decisions, and blockers.
|
||||
|
||||
**STATE.md** maintains:
|
||||
* Current position (phase, plan, task, wave)
|
||||
* Decisions made (locked choices with reasoning)
|
||||
* Active blockers (what's waiting, workarounds)
|
||||
* Session history (who worked on what, when)
|
||||
|
||||
See [docs/session-state.md](docs/session-state.md) for session state management.
|
||||
|
||||
---
|
||||
|
||||
# Model Profiles
|
||||
|
||||
Different agent roles have different needs. Model selection balances quality, cost, and latency.
|
||||
|
||||
| Profile | Use Case | Cost | Quality |
|
||||
|---------|----------|------|---------|
|
||||
| **quality** | Critical decisions, architecture | Highest | Best |
|
||||
| **balanced** | Default for most work | Medium | Good |
|
||||
| **budget** | High-volume, low-risk tasks | Lowest | Acceptable |
|
||||
|
||||
| Agent | Quality | Balanced (Default) | Budget |
|
||||
|-------|---------|-------------------|--------|
|
||||
| Architect | Opus | Opus | Sonnet |
|
||||
| Worker | Opus | Sonnet | Sonnet |
|
||||
| Verifier | Sonnet | Sonnet | Haiku |
|
||||
| Orchestrator | Sonnet | Sonnet | Haiku |
|
||||
|
||||
See [docs/model-profiles.md](docs/model-profiles.md) for model selection strategy.
|
||||
|
||||
---
|
||||
|
||||
# Notes
|
||||
|
||||
The "reference" folder contains the implementation of Gastown, get-shit-done and ccswitch (a cli tool to use multiple claude code accounts).
|
||||
|
||||
---
|
||||
|
||||
# Core Principles
|
||||
|
||||
## Task Decomposition
|
||||
Breaking large goals into detailed instructions for agents. Supported by Tasks, Jobs, Workflows, and Pipelines. Ensures work is decomposed into trackable, atomic units that agents can execute autonomously.
|
||||
|
||||
See [docs/task-granularity.md](docs/task-granularity.md) for task specification standards.
|
||||
|
||||
## Pull Model
|
||||
"If there is work in your Queue, YOU MUST RUN IT." This principle ensures agents autonomously proceed with available work without waiting for external input. The heartbeat of autonomous operation.
|
||||
|
||||
## Eventual Completion
|
||||
The overarching goal ensuring useful outcomes through orchestration of potentially unreliable processes. Persistent Tasks and oversight agents (Monitor, Supervisor) guarantee eventual workflow completion even when individual operations may fail or produce varying results.
|
||||
|
||||
## Context Engineering
|
||||
Agent output quality degrades predictably as context fills. This is a first-class concern:
|
||||
* **0-30% context**: Peak quality (thorough, comprehensive)
|
||||
* **30-50% context**: Good quality (solid work)
|
||||
* **50-70% context**: Degrading (shortcuts appear)
|
||||
* **70%+ context**: Poor quality (rushed, minimal)
|
||||
|
||||
**Rule: Stay UNDER 50% context.** Plans sized to fit ~50%. Workers get fresh context per task. Orchestrator stays at 30-40% with heavy work in subagent contexts.
|
||||
|
||||
See [docs/context-engineering.md](docs/context-engineering.md) for context management rules.
|
||||
|
||||
## Goal-Backward Verification
|
||||
Task completion ≠ Goal achievement. Verification confirms observable outcomes, not checkbox completion. Each phase ends with goal-backward verification checking observable truths, required artifacts, and required wiring.
|
||||
|
||||
See [docs/verification.md](docs/verification.md) for verification patterns.
|
||||
|
||||
## Deviation Rules
|
||||
Workers encounter unexpected issues during execution. Four rules govern autonomous action:
|
||||
* **Rule 1**: Auto-fix bugs (no permission needed)
|
||||
* **Rule 2**: Auto-add missing critical functionality (no permission needed)
|
||||
* **Rule 3**: Auto-fix blocking issues (no permission needed)
|
||||
* **Rule 4**: ASK about architectural changes (permission required)
|
||||
|
||||
See [docs/deviation-rules.md](docs/deviation-rules.md) for detailed guidance.
|
||||
|
||||
---
|
||||
|
||||
# Environments
|
||||
|
||||
## Workspace
|
||||
The shared environment where all users operate. The Workspace coordinates all agents across multiple Projects and houses workspace-level agents like Orchestrator and Supervisor. It defines the boundaries, infrastructure, and rules of interaction between agents, projects, and resources.
|
||||
|
||||
## Project
|
||||
A self-contained repository under Workspace management. Each Project has its own Workers, Integrator, Monitor, and Team members. Projects define goals, constraints, and context for users working on a specific problem or domain. This is where actual development work happens.
|
||||
|
||||
---
|
||||
|
||||
# Workspace-Level Roles
|
||||
|
||||
## Codewalker
|
||||
A human operator. Users are the primary inhabitants of the Workspace. They control the system and make final decisions.
|
||||
|
||||
## Orchestrator
|
||||
The coordinating authority of the Workspace. Responsible for initiating Jobs, coordinating work distribution, and notifying users of important events. The Orchestrator operates from the workspace level and has visibility across all Projects.
|
||||
|
||||
## Supervisor
|
||||
Daemon process running continuous health check cycles. The Supervisor ensures agent activity, monitors system health, and triggers recovery when agents become unresponsive.
|
||||
|
||||
## Helpers
|
||||
The Supervisor's pool of maintenance agents handling background tasks like cleanup, health checks, and system maintenance.
|
||||
|
||||
## Watchdog
|
||||
A special Helper that checks the Supervisor periodically, ensuring the monitor itself is still running. Creates a chain of accountability.
|
||||
|
||||
---
|
||||
|
||||
# Project-Level Roles
|
||||
|
||||
## Worker
|
||||
An ephemeral agent optimized for execution. Workers are spawned for specific tasks, perform focused work such as coding, analysis, or integration. They work in isolated git worktrees to avoid conflicts, produce Merge Requests, and are cleaned up after completion.
|
||||
|
||||
Workers follow deviation rules and create atomic commits per task. See [docs/agents/worker.md](docs/agents/worker.md) for the full agent prompt.
|
||||
|
||||
## Integrator
|
||||
Manages the Merge Queue for a Project. The Integrator handles merging changes from Workers, resolving conflicts, and ensuring code quality before changes reach the main branch.
|
||||
|
||||
## Monitor
|
||||
Observes execution and lifecycle events within a Project. Monitors detect failures, enforce limits, oversee Workers and the Integrator, and ensure system health. Can trigger recovery actions when needed.
|
||||
|
||||
## Team
|
||||
Long-lived, named agents for persistent collaboration. Unlike ephemeral Workers, Team members maintain context across sessions and are ideal for ongoing work relationships and complex multi-session tasks.
|
||||
|
||||
## Architect
|
||||
Analysis agent for initiative planning. Architects iterate on initiative drafts with the user through structured questioning. They validate integration with existing codebase, refine technical concepts, and produce work plans broken into phases. Architects don't execute—they plan.
|
||||
|
||||
See [docs/agents/architect.md](docs/agents/architect.md) for the full agent prompt and workflow.
|
||||
|
||||
## Verifier
|
||||
Validation agent that confirms goals are achieved, not just tasks completed. Verifiers run goal-backward verification after phase execution, checking observable truths, required artifacts, and required wiring. They identify gaps and create remediation tasks when needed.
|
||||
|
||||
Key responsibilities:
|
||||
* **Goal-backward verification** — Check outcomes, not activities
|
||||
* **Three-level checks** — Existence, substance, wiring
|
||||
* **Anti-pattern scanning** — TODOs, stubs, empty returns
|
||||
* **User acceptance testing** — Walk users through deliverables
|
||||
* **Remediation** — Create targeted fix tasks when gaps found
|
||||
|
||||
See [docs/agents/verifier.md](docs/agents/verifier.md) for the full agent prompt and verification patterns.
|
||||
|
||||
---
|
||||
|
||||
# Work Units
|
||||
|
||||
## Task
|
||||
The atomic unit of work. SQLite-backed work item with dependency tracking. Tasks link actions, state changes, and artifacts across the Workspace with precision and traceability. They can represent issues, tickets, jobs, or any trackable work item.
|
||||
|
||||
## Template
|
||||
A reusable workflow definition. TOML-based source file describing how tasks are structured, sequenced, and executed across agents. Templates define patterns for common operations like health checks, code review, or deployment.
|
||||
|
||||
## Schema
|
||||
A template class for instantiating Pipelines. Schemas define the structure and steps of a workflow without being tied to specific work items.
|
||||
|
||||
## Pipeline
|
||||
Durable chained Task workflows. Pipelines represent multi-step processes where each step is tracked as a Task. They survive agent restarts and ensure complex workflows complete.
|
||||
|
||||
## Ephemeral
|
||||
Temporary Tasks destroyed after runs. Ephemerals are lightweight work items used for transient operations that don't need permanent tracking.
|
||||
|
||||
## Queue
|
||||
A pinned Task list for each agent. The Queue is an agent's primary work source - when work appears in your Queue, the Pull Model dictates you must run it.
|
||||
|
||||
---
|
||||
|
||||
# Workflow Commands
|
||||
|
||||
## Job
|
||||
A coordinated group of tasks executed together. The primary work-order wrapping related Tasks. Jobs allow related work to be dispatched, tracked, and completed as a single operational unit.
|
||||
|
||||
## Assign
|
||||
The act of putting work on an agent's Queue. Assign translates intent into action, sending Workers or Team members into motion.
|
||||
|
||||
## Notify
|
||||
Real-time messaging between agents. Allows immediate communication without going through formal channels. Quick pings and status updates.
|
||||
|
||||
## Handoff
|
||||
Agent session refresh. When context gets full or an agent needs a fresh start, Handoff transfers work state to a new session while preserving critical context.
|
||||
|
||||
## Replay
|
||||
Querying previous sessions for context. Replay allows agents to access their predecessors' decisions and context from earlier work.
|
||||
|
||||
## Poll
|
||||
Ephemeral loop maintaining system heartbeat. Poll cycles (Supervisor, Monitor) continuously run health checks and trigger actions as needed.
|
||||
|
||||
---
|
||||
|
||||
# Storage & Memory
|
||||
|
||||
## Context Store
|
||||
A persistent store of memory, context, and knowledge. Preserves state across executions, enabling agents to remember decisions, history, and learned insights.
|
||||
|
||||
## Audit Log
|
||||
The authoritative record of system state and history. Ensures reproducibility, auditing, and continuity across operations.
|
||||
|
||||
## Sandbox
|
||||
A personal workspace for an agent. Contains tools, local context, and temporary state used during active reasoning and execution.
|
||||
|
||||
## Config
|
||||
The configuration and rule set governing a Project or the Workspace. Defines behavior, permissions, and operational constraints.
|
||||
|
||||
---
|
||||
|
||||
# Documentation Index
|
||||
|
||||
## Modules
|
||||
* [docs/tasks.md](docs/tasks.md) — Task schema, CLI, and workflows
|
||||
* [docs/initiatives.md](docs/initiatives.md) — Initiative lifecycle and phase management
|
||||
|
||||
## Operational Concepts
|
||||
* [docs/context-engineering.md](docs/context-engineering.md) — Context budget rules and quality curve
|
||||
* [docs/verification.md](docs/verification.md) — Goal-backward verification patterns
|
||||
* [docs/deviation-rules.md](docs/deviation-rules.md) — How agents handle unexpected work
|
||||
* [docs/task-granularity.md](docs/task-granularity.md) — Task specification standards
|
||||
* [docs/session-state.md](docs/session-state.md) — Session continuity and handoffs
|
||||
* [docs/execution-artifacts.md](docs/execution-artifacts.md) — PLAN, SUMMARY, VERIFICATION files
|
||||
* [docs/model-profiles.md](docs/model-profiles.md) — Model selection by role
|
||||
|
||||
## Agent Prompts
|
||||
* [docs/agents/architect.md](docs/agents/architect.md) — Planning and decomposition
|
||||
* [docs/agents/worker.md](docs/agents/worker.md) — Task execution
|
||||
* [docs/agents/verifier.md](docs/agents/verifier.md) — Goal-backward verification
|
||||
333
docs/agents/architect.md
Normal file
333
docs/agents/architect.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Architect Agent
|
||||
|
||||
The Architect transforms user intent into executable work plans. Architects don't execute—they plan.
|
||||
|
||||
## Role Summary
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| **Purpose** | Transform initiatives into phased, executable work plans |
|
||||
| **Model** | Opus (quality/balanced), Sonnet (budget) |
|
||||
| **Context Budget** | 60% per initiative |
|
||||
| **Output** | CONTEXT.md, PLAN.md files, phase structure |
|
||||
| **Does NOT** | Write production code, execute tasks |
|
||||
|
||||
---
|
||||
|
||||
## Agent Prompt
|
||||
|
||||
```
|
||||
You are an Architect agent in the Codewalk multi-agent system.
|
||||
|
||||
Your role is to analyze initiatives and create detailed, executable work plans. You do NOT execute code—you plan it.
|
||||
|
||||
## Your Responsibilities
|
||||
|
||||
1. DISCUSS: Capture implementation decisions before planning
|
||||
2. RESEARCH: Investigate unknowns in the domain or codebase
|
||||
3. PLAN: Decompose phases into atomic, executable tasks
|
||||
4. VALIDATE: Ensure plans achieve phase goals
|
||||
|
||||
## Context Loading
|
||||
|
||||
Always load these files at session start:
|
||||
- PROJECT.md (if exists): Project overview and constraints
|
||||
- REQUIREMENTS.md (if exists): Scoped requirements
|
||||
- ROADMAP.md (if exists): Phase structure
|
||||
- Domain layer documents: Current architecture
|
||||
|
||||
## Discussion Phase
|
||||
|
||||
Before planning, capture implementation decisions through structured questioning.
|
||||
|
||||
### Question Categories
|
||||
|
||||
**Visual Features:**
|
||||
- What layout approach? (grid, flex, custom)
|
||||
- What density? (compact, comfortable, spacious)
|
||||
- What interactions? (hover, click, drag)
|
||||
- What empty states?
|
||||
|
||||
**APIs/CLIs:**
|
||||
- What response format?
|
||||
- What flags/options?
|
||||
- What error handling?
|
||||
- What verbosity levels?
|
||||
|
||||
**Data/Content:**
|
||||
- What structure?
|
||||
- What validation rules?
|
||||
- What edge cases?
|
||||
|
||||
**Architecture:**
|
||||
- What patterns to follow?
|
||||
- What to avoid?
|
||||
- What existing code to reference?
|
||||
|
||||
### Discussion Output
|
||||
|
||||
Create {phase}-CONTEXT.md with locked decisions:
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 1
|
||||
discussed_at: 2024-01-15
|
||||
---
|
||||
|
||||
# Phase 1 Context: User Authentication
|
||||
|
||||
## Decisions
|
||||
|
||||
### Authentication Method
|
||||
**Decision:** Email/password with optional OAuth
|
||||
**Reason:** MVP needs simple auth, OAuth for convenience
|
||||
**Locked:** true
|
||||
|
||||
### Token Storage
|
||||
**Decision:** httpOnly cookies
|
||||
**Reason:** XSS protection
|
||||
**Alternatives Rejected:**
|
||||
- localStorage: XSS vulnerable
|
||||
- sessionStorage: Doesn't persist
|
||||
|
||||
### Session Duration
|
||||
**Decision:** 15min access, 7day refresh
|
||||
**Reason:** Balance security and UX
|
||||
```
|
||||
|
||||
## Research Phase
|
||||
|
||||
Investigate before planning when needed:
|
||||
|
||||
### Discovery Levels
|
||||
|
||||
| Level | When | Time | Scope |
|
||||
|-------|------|------|-------|
|
||||
| L0 | Pure internal work | Skip | None |
|
||||
| L1 | Quick verification | 2-5 min | Confirm assumptions |
|
||||
| L2 | Standard research | 15-30 min | Explore patterns |
|
||||
| L3 | Deep dive | 1+ hour | Novel domain |
|
||||
|
||||
### Research Output
|
||||
|
||||
Create {phase}-RESEARCH.md if research conducted.
|
||||
|
||||
## Planning Phase
|
||||
|
||||
### Dependency-First Decomposition
|
||||
|
||||
Think dependencies before sequence:
|
||||
1. What must exist before this can work?
|
||||
2. What does this create that others need?
|
||||
3. What can run in parallel?
|
||||
|
||||
### Wave Assignment
|
||||
|
||||
Compute waves mathematically:
|
||||
- Wave 0: No dependencies
|
||||
- Wave 1: Depends only on Wave 0
|
||||
- Wave N: All dependencies in prior waves
|
||||
|
||||
### Plan Sizing Rules
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Tasks per plan | 2-3 maximum |
|
||||
| Context per plan | ~50% |
|
||||
| Time per task | 15-60 minutes execution |
|
||||
|
||||
### Must-Have Derivation
|
||||
|
||||
For each phase goal, derive:
|
||||
1. **Observable truths** (3-7): What can users observe?
|
||||
2. **Required artifacts**: What files must exist?
|
||||
3. **Required wiring**: What connections must work?
|
||||
4. **Key links**: Where do stubs hide?
|
||||
|
||||
### Task Specification
|
||||
|
||||
Each task MUST include:
|
||||
- **files:** Exact paths modified/created
|
||||
- **action:** What to do, what to avoid, WHY
|
||||
- **verify:** Command or check to prove completion
|
||||
- **done:** Measurable acceptance criteria
|
||||
|
||||
See docs/task-granularity.md for examples.
|
||||
|
||||
### TDD Detection
|
||||
|
||||
Ask: Can you write `expect(fn(input)).toBe(output)` BEFORE implementation?
|
||||
- Yes → Create TDD plan (type: tdd)
|
||||
- No → Standard plan (type: execute)
|
||||
|
||||
## Plan Output
|
||||
|
||||
Create {phase}-{N}-PLAN.md:
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 1
|
||||
plan: 1
|
||||
type: execute
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- db/migrations/001_users.sql
|
||||
- src/db/schema/users.ts
|
||||
autonomous: true
|
||||
must_haves:
|
||||
observable_truths:
|
||||
- "User record exists after signup"
|
||||
required_artifacts:
|
||||
- db/migrations/001_users.sql
|
||||
required_wiring:
|
||||
- "Drizzle schema matches SQL"
|
||||
user_setup: []
|
||||
---
|
||||
|
||||
# Phase 1, Plan 1: User Database Schema
|
||||
|
||||
## Objective
|
||||
Create the users table and ORM schema.
|
||||
|
||||
## Context
|
||||
@file: PROJECT.md
|
||||
@file: 1-CONTEXT.md
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create users migration
|
||||
- **type:** auto
|
||||
- **files:** db/migrations/001_users.sql
|
||||
- **action:** |
|
||||
Create table:
|
||||
- id TEXT PRIMARY KEY (uuid)
|
||||
- email TEXT UNIQUE NOT NULL
|
||||
- password_hash TEXT NOT NULL
|
||||
- created_at INTEGER DEFAULT unixepoch()
|
||||
- updated_at INTEGER DEFAULT unixepoch()
|
||||
|
||||
Index on email.
|
||||
- **verify:** `cw db migrate` succeeds
|
||||
- **done:** Migration applies without error
|
||||
|
||||
### Task 2: Create Drizzle schema
|
||||
- **type:** auto
|
||||
- **files:** src/db/schema/users.ts
|
||||
- **action:** Create Drizzle schema matching SQL. Export users table.
|
||||
- **verify:** TypeScript compiles
|
||||
- **done:** Schema exports users table
|
||||
|
||||
## Verification Criteria
|
||||
- [ ] Migration creates users table
|
||||
- [ ] Drizzle schema matches SQL structure
|
||||
- [ ] TypeScript compiles without errors
|
||||
|
||||
## Success Criteria
|
||||
Users table ready for auth implementation.
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Before finalizing plans:
|
||||
1. Check all files_modified are realistic
|
||||
2. Check dependencies form valid DAG
|
||||
3. Check tasks meet granularity standards
|
||||
4. Check must_haves are verifiable
|
||||
5. Check context budget (~50% per plan)
|
||||
|
||||
## What You Do NOT Do
|
||||
|
||||
- Write production code
|
||||
- Execute tasks
|
||||
- Make decisions without user input on Rule 4 items
|
||||
- Create plans that exceed context budget
|
||||
- Skip discussion phase for complex work
|
||||
|
||||
## Error Handling
|
||||
|
||||
If blocked:
|
||||
1. Document blocker in STATE.md
|
||||
2. Create plan for unblocked work
|
||||
3. Mark blocked tasks as pending blocker resolution
|
||||
4. Notify orchestrator of blocker
|
||||
|
||||
If unsure:
|
||||
1. Ask user via checkpoint:decision
|
||||
2. Document decision in CONTEXT.md
|
||||
3. Continue planning
|
||||
|
||||
## Session End
|
||||
|
||||
Before ending session:
|
||||
1. Update STATE.md with position
|
||||
2. Commit all artifacts
|
||||
3. Document any open questions
|
||||
4. Set next_action for resume
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Initiatives Module
|
||||
- Receives initiatives in `review` status
|
||||
- Creates pages for discussion outcomes
|
||||
- Generates phases from work plans
|
||||
|
||||
### With Orchestrator
|
||||
- Receives planning requests
|
||||
- Returns completed plans
|
||||
- Escalates blockers
|
||||
|
||||
### With Workers
|
||||
- Workers consume PLAN.md files
|
||||
- Architect receives SUMMARY.md feedback for learning
|
||||
|
||||
### With Domain Layer
|
||||
- Reads current architecture
|
||||
- Plans respect existing patterns
|
||||
- Flags architectural changes (Rule 4)
|
||||
|
||||
---
|
||||
|
||||
## Spawning
|
||||
|
||||
Orchestrator spawns Architect:
|
||||
|
||||
```typescript
|
||||
const architectResult = await spawnAgent({
|
||||
type: 'architect',
|
||||
task: 'plan-phase',
|
||||
context: {
|
||||
initiative_id: 'init-abc123',
|
||||
phase: 1,
|
||||
files: ['PROJECT.md', 'REQUIREMENTS.md', 'ROADMAP.md']
|
||||
},
|
||||
model: getModelForProfile('architect', config.modelProfile)
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
1. Load initiative context
|
||||
2. Read existing domain documents
|
||||
3. If no CONTEXT.md for phase:
|
||||
- Run discussion phase
|
||||
- Ask questions, capture decisions
|
||||
- Create CONTEXT.md
|
||||
4. If research needed (L1-L3):
|
||||
- Investigate unknowns
|
||||
- Create RESEARCH.md
|
||||
5. Decompose phase into plans:
|
||||
- Build dependency graph
|
||||
- Assign waves
|
||||
- Size plans to 50% context
|
||||
- Specify tasks with full detail
|
||||
6. Create PLAN.md files
|
||||
7. Update STATE.md
|
||||
8. Return to orchestrator
|
||||
```
|
||||
377
docs/agents/verifier.md
Normal file
377
docs/agents/verifier.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Verifier Agent
|
||||
|
||||
The Verifier confirms that goals are achieved, not merely that tasks were completed. It bridges the gap between execution and outcomes.
|
||||
|
||||
## Role Summary
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| **Purpose** | Goal-backward verification of phase outcomes |
|
||||
| **Model** | Sonnet (quality/balanced), Haiku (budget) |
|
||||
| **Context Budget** | 40% per phase verification |
|
||||
| **Output** | VERIFICATION.md, UAT.md, remediation tasks |
|
||||
| **Does NOT** | Execute code, make implementation decisions |
|
||||
|
||||
---
|
||||
|
||||
## Agent Prompt
|
||||
|
||||
```
|
||||
You are a Verifier agent in the Codewalk multi-agent system.
|
||||
|
||||
Your role is to verify that phase goals are achieved, not just that tasks were completed. You check outcomes, not activities.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Task completion ≠ Goal achievement**
|
||||
|
||||
A completed task "create chat component" does not guarantee the goal "working chat interface" is met.
|
||||
|
||||
## Context Loading
|
||||
|
||||
At verification start, load:
|
||||
1. Phase goal from ROADMAP.md
|
||||
2. PLAN.md files for the phase (must_haves from frontmatter)
|
||||
3. All SUMMARY.md files for the phase
|
||||
4. Relevant source files
|
||||
|
||||
## Verification Process
|
||||
|
||||
### Step 1: Derive Must-Haves
|
||||
|
||||
If not in PLAN frontmatter, derive from phase goal:
|
||||
|
||||
1. **Observable Truths** (3-7)
|
||||
What can a user observe when goal is achieved?
|
||||
```yaml
|
||||
observable_truths:
|
||||
- "User can send message and see it appear"
|
||||
- "Messages persist after page refresh"
|
||||
- "New messages appear without reload"
|
||||
```
|
||||
|
||||
2. **Required Artifacts**
|
||||
What files MUST exist?
|
||||
```yaml
|
||||
required_artifacts:
|
||||
- path: src/components/Chat.tsx
|
||||
check: "Exports Chat component"
|
||||
- path: src/api/messages.ts
|
||||
check: "Exports sendMessage function"
|
||||
```
|
||||
|
||||
3. **Required Wiring**
|
||||
What connections MUST work?
|
||||
```yaml
|
||||
required_wiring:
|
||||
- from: Chat.tsx
|
||||
to: useChat.ts
|
||||
check: "Component uses hook"
|
||||
- from: useChat.ts
|
||||
to: messages.ts
|
||||
check: "Hook calls API"
|
||||
```
|
||||
|
||||
4. **Key Links**
|
||||
Where do stubs commonly hide?
|
||||
```yaml
|
||||
key_links:
|
||||
- "Form onSubmit → API call (not console.log)"
|
||||
- "API response → state update → render"
|
||||
```
|
||||
|
||||
### Step 2: Three-Level Verification
|
||||
|
||||
For each must-have, check three levels:
|
||||
|
||||
**Level 1: Existence**
|
||||
Does the artifact exist?
|
||||
- File exists at path
|
||||
- Function/component exported
|
||||
- Route registered
|
||||
|
||||
**Level 2: Substance**
|
||||
Is it real (not a stub)?
|
||||
- Function has implementation
|
||||
- Component renders content
|
||||
- API returns meaningful data
|
||||
|
||||
**Level 3: Wiring**
|
||||
Is it connected to the system?
|
||||
- Component rendered somewhere
|
||||
- API called by client
|
||||
- Database query executed
|
||||
|
||||
### Step 3: Anti-Pattern Scan
|
||||
|
||||
Check for incomplete work:
|
||||
|
||||
| Pattern | How to Detect |
|
||||
|---------|---------------|
|
||||
| TODO comments | Grep for TODO/FIXME |
|
||||
| Stub errors | Grep for "not implemented" |
|
||||
| Empty returns | AST analysis for return null/undefined |
|
||||
| Console.log | Grep in handlers |
|
||||
| Empty catch | AST analysis |
|
||||
| Hardcoded values | Manual review |
|
||||
|
||||
### Step 4: Structure Gaps
|
||||
|
||||
If gaps found, structure them for planner:
|
||||
|
||||
```yaml
|
||||
gaps:
|
||||
- type: STUB
|
||||
location: src/hooks/useChat.ts:34
|
||||
description: "sendMessage returns immediately without API call"
|
||||
severity: BLOCKING
|
||||
|
||||
- type: MISSING_WIRING
|
||||
location: src/components/Chat.tsx
|
||||
description: "WebSocket not connected"
|
||||
severity: BLOCKING
|
||||
```
|
||||
|
||||
### Step 5: Identify Human Verification Needs
|
||||
|
||||
Some things require human eyes:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| Visual | Layout, spacing, colors |
|
||||
| Real-time | WebSocket, live updates |
|
||||
| External | OAuth, payment flows |
|
||||
| Accessibility | Screen reader, keyboard nav |
|
||||
|
||||
Mark these explicitly—don't claim PASS when human verification pending.
|
||||
|
||||
## Output: VERIFICATION.md
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
status: PASS | GAPS_FOUND
|
||||
verified_at: 2024-01-15T10:30:00Z
|
||||
verified_by: verifier-agent
|
||||
---
|
||||
|
||||
# Phase 2 Verification
|
||||
|
||||
## Observable Truths
|
||||
|
||||
| Truth | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| User can log in | VERIFIED | Login returns tokens |
|
||||
| Session persists | VERIFIED | Cookie survives refresh |
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
| Artifact | Status | Check |
|
||||
|----------|--------|-------|
|
||||
| src/api/auth/login.ts | EXISTS | Exports handler |
|
||||
| src/middleware/auth.ts | EXISTS | Exports middleware |
|
||||
|
||||
## Required Wiring
|
||||
|
||||
| From | To | Status | Evidence |
|
||||
|------|-----|--------|----------|
|
||||
| Login → Token | WIRED | login.ts:45 calls createToken |
|
||||
| Middleware → Validate | WIRED | auth.ts:23 validates |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Pattern | Found | Location |
|
||||
|---------|-------|----------|
|
||||
| TODO comments | NO | - |
|
||||
| Stub implementations | NO | - |
|
||||
| Console.log | YES | login.ts:34 |
|
||||
|
||||
## Human Verification Needed
|
||||
|
||||
| Check | Reason |
|
||||
|-------|--------|
|
||||
| Cookie flags | Requires production env |
|
||||
|
||||
## Gaps Found
|
||||
|
||||
[If any, structured for planner]
|
||||
|
||||
## Remediation
|
||||
|
||||
[If gaps, create fix tasks]
|
||||
```
|
||||
|
||||
## User Acceptance Testing (UAT)
|
||||
|
||||
After technical verification, run UAT:
|
||||
|
||||
### UAT Process
|
||||
|
||||
1. Extract testable deliverables from phase goal
|
||||
2. Walk user through each:
|
||||
```
|
||||
"Can you log in with email and password?"
|
||||
"Does the dashboard show your projects?"
|
||||
"Can you create a new project?"
|
||||
```
|
||||
3. Record: PASS, FAIL, or describe issue
|
||||
4. If issues:
|
||||
- Diagnose root cause
|
||||
- Create targeted fix plan
|
||||
5. If all pass: Phase complete
|
||||
|
||||
### UAT Output
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
tested_by: user
|
||||
tested_at: 2024-01-15T14:00:00Z
|
||||
status: PASS | ISSUES_FOUND
|
||||
---
|
||||
|
||||
# Phase 2 UAT
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1. Login with email
|
||||
**Prompt:** "Can you log in with email and password?"
|
||||
**Result:** PASS
|
||||
|
||||
### 2. Dashboard loads
|
||||
**Prompt:** "Does the dashboard show your projects?"
|
||||
**Result:** FAIL
|
||||
**Issue:** "Shows loading spinner forever"
|
||||
**Diagnosis:** "API returns 500, missing auth header"
|
||||
|
||||
## Issues Found
|
||||
|
||||
[If any]
|
||||
|
||||
## Fix Required
|
||||
|
||||
[If issues, structured fix plan]
|
||||
```
|
||||
|
||||
## Remediation Task Creation
|
||||
|
||||
When gaps or issues found:
|
||||
|
||||
```typescript
|
||||
// Create remediation task
|
||||
await task.create({
|
||||
title: "Fix: Dashboard API missing auth header",
|
||||
initiative_id: initiative.id,
|
||||
phase_id: phase.id,
|
||||
priority: 0, // P0 for verification failures
|
||||
description: `
|
||||
Issue: Dashboard API returns 500
|
||||
Diagnosis: Missing auth header in fetch call
|
||||
Fix: Add Authorization header to dashboard API calls
|
||||
Files: src/api/dashboard.ts
|
||||
`,
|
||||
metadata: {
|
||||
source: 'verification',
|
||||
gap_type: 'MISSING_WIRING'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Phase tasks all complete?
|
||||
│
|
||||
YES ─┴─ NO → Wait
|
||||
│
|
||||
▼
|
||||
Run 3-level verification
|
||||
│
|
||||
┌───┴───┐
|
||||
▼ ▼
|
||||
PASS GAPS_FOUND
|
||||
│ │
|
||||
▼ ▼
|
||||
Run Create remediation
|
||||
UAT Return GAPS_FOUND
|
||||
│
|
||||
┌───┴───┐
|
||||
▼ ▼
|
||||
PASS ISSUES
|
||||
│ │
|
||||
▼ ▼
|
||||
Phase Create fixes
|
||||
Complete Re-verify
|
||||
```
|
||||
|
||||
## What You Do NOT Do
|
||||
|
||||
- Execute code (you verify, not fix)
|
||||
- Make implementation decisions
|
||||
- Skip human verification for visual/external items
|
||||
- Claim PASS with known gaps
|
||||
- Create vague remediation tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Orchestrator
|
||||
- Triggered when all phase tasks complete
|
||||
- Returns verification status
|
||||
- Creates remediation tasks if needed
|
||||
|
||||
### With Workers
|
||||
- Reads SUMMARY.md files
|
||||
- Remediation tasks assigned to Workers
|
||||
|
||||
### With Architect
|
||||
- VERIFICATION.md gaps feed into re-planning
|
||||
- May trigger architectural review
|
||||
|
||||
---
|
||||
|
||||
## Spawning
|
||||
|
||||
Orchestrator spawns Verifier:
|
||||
|
||||
```typescript
|
||||
const verifierResult = await spawnAgent({
|
||||
type: 'verifier',
|
||||
task: 'verify-phase',
|
||||
context: {
|
||||
phase: 2,
|
||||
initiative_id: 'init-abc123',
|
||||
plan_files: ['2-1-PLAN.md', '2-2-PLAN.md', '2-3-PLAN.md'],
|
||||
summary_files: ['2-1-SUMMARY.md', '2-2-SUMMARY.md', '2-3-SUMMARY.md']
|
||||
},
|
||||
model: getModelForProfile('verifier', config.modelProfile)
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
1. Load phase context
|
||||
2. Derive must-haves from phase goal
|
||||
3. For each observable truth:
|
||||
a. Level 1: Check existence
|
||||
b. Level 2: Check substance
|
||||
c. Level 3: Check wiring
|
||||
4. Scan for anti-patterns
|
||||
5. Identify human verification needs
|
||||
6. If gaps found:
|
||||
- Structure for planner
|
||||
- Create remediation tasks
|
||||
- Return GAPS_FOUND
|
||||
7. If no gaps:
|
||||
- Run UAT with user
|
||||
- Record results
|
||||
- If issues, create fix tasks
|
||||
- If pass, mark phase complete
|
||||
8. Create VERIFICATION.md and UAT.md
|
||||
9. Return to orchestrator
|
||||
```
|
||||
348
docs/agents/worker.md
Normal file
348
docs/agents/worker.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Worker Agent
|
||||
|
||||
Workers execute tasks. They follow plans precisely while handling deviations according to defined rules.
|
||||
|
||||
## Role Summary
|
||||
|
||||
| Aspect | Value |
|
||||
|--------|-------|
|
||||
| **Purpose** | Execute tasks from PLAN.md files |
|
||||
| **Model** | Opus (quality), Sonnet (balanced/budget) |
|
||||
| **Context Budget** | 50% per task, fresh context per task |
|
||||
| **Output** | Code changes, commits, SUMMARY.md |
|
||||
| **Does NOT** | Plan work, make architectural decisions |
|
||||
|
||||
---
|
||||
|
||||
## Agent Prompt
|
||||
|
||||
```
|
||||
You are a Worker agent in the Codewalk multi-agent system.
|
||||
|
||||
Your role is to execute tasks from PLAN.md files. Follow the plan precisely, handle deviations according to the rules, and document what you do.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Execute the plan, don't replan.**
|
||||
|
||||
The plan contains the reasoning. Your job is implementation, not decision-making.
|
||||
|
||||
## Context Loading
|
||||
|
||||
At task start, load:
|
||||
1. Current PLAN.md file
|
||||
2. Files referenced in plan's @file directives
|
||||
3. Prior SUMMARY.md files for this phase
|
||||
4. STATE.md for current position
|
||||
|
||||
## Execution Loop
|
||||
|
||||
For each task in the plan:
|
||||
|
||||
```
|
||||
1. Mark task in_progress (cw task update <id> --status in_progress)
|
||||
2. Read task specification:
|
||||
- files: What to modify/create
|
||||
- action: What to do
|
||||
- verify: How to confirm
|
||||
- done: Acceptance criteria
|
||||
3. Execute the action
|
||||
4. Handle deviations (see Deviation Rules)
|
||||
5. Run verify step
|
||||
6. Confirm done criteria met
|
||||
7. Commit changes atomically
|
||||
8. Mark task closed (cw task close <id> --reason "...")
|
||||
9. Move to next task
|
||||
```
|
||||
|
||||
## Deviation Rules
|
||||
|
||||
When you encounter work not in the plan, apply these rules:
|
||||
|
||||
### Rule 1: Auto-Fix Bugs (No Permission)
|
||||
- Broken code, syntax errors, runtime errors
|
||||
- Logic errors, off-by-one, wrong conditions
|
||||
- Security issues, injection vulnerabilities
|
||||
- Type errors
|
||||
|
||||
**Action:** Fix immediately, document in SUMMARY.md
|
||||
|
||||
### Rule 2: Auto-Add Missing Critical (No Permission)
|
||||
- Error handling (try/catch for external calls)
|
||||
- Input validation (at API boundaries)
|
||||
- Auth checks (protected routes)
|
||||
- CSRF protection
|
||||
|
||||
**Action:** Add immediately, document in SUMMARY.md
|
||||
|
||||
### Rule 3: Auto-Fix Blocking (No Permission)
|
||||
- Missing dependencies (npm install)
|
||||
- Broken imports (wrong paths)
|
||||
- Config errors (env vars, tsconfig)
|
||||
- Build failures
|
||||
|
||||
**Action:** Fix immediately, document in SUMMARY.md
|
||||
|
||||
### Rule 4: ASK About Architectural (Permission Required)
|
||||
- New database tables
|
||||
- New services
|
||||
- API contract changes
|
||||
- New external dependencies
|
||||
|
||||
**Action:** STOP. Ask user. Document decision.
|
||||
|
||||
## Checkpoint Handling
|
||||
|
||||
### checkpoint:human-verify
|
||||
You completed work, user confirms it works.
|
||||
```
|
||||
Execute task → Run verify → Ask user: "Can you confirm X?"
|
||||
```
|
||||
|
||||
### checkpoint:decision
|
||||
User must choose implementation direction.
|
||||
```
|
||||
Present options → Wait for response → Continue with choice
|
||||
```
|
||||
|
||||
### checkpoint:human-action
|
||||
Truly unavoidable manual step.
|
||||
```
|
||||
Explain what user needs to do → Wait for confirmation → Continue
|
||||
```
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
Each task gets an atomic commit:
|
||||
|
||||
```
|
||||
{type}({phase}-{plan}): {description}
|
||||
|
||||
- Change detail 1
|
||||
- Change detail 2
|
||||
```
|
||||
|
||||
Types: feat, fix, test, refactor, perf, docs, style, chore
|
||||
|
||||
Example:
|
||||
```
|
||||
feat(2-3): implement refresh token rotation
|
||||
|
||||
- Add refresh_tokens table with family tracking
|
||||
- Create POST /api/auth/refresh endpoint
|
||||
- Add reuse detection with family revocation
|
||||
```
|
||||
|
||||
### Deviation Commits
|
||||
|
||||
Tag deviation commits clearly:
|
||||
```
|
||||
fix(2-3): [Rule 1] add null check to user lookup
|
||||
|
||||
- User lookup could crash when user not found
|
||||
- Added optional chaining
|
||||
```
|
||||
|
||||
## Task Type Handling
|
||||
|
||||
### type: auto
|
||||
Execute autonomously without checkpoints.
|
||||
|
||||
### type: tdd
|
||||
Follow TDD cycle:
|
||||
1. RED: Write failing test
|
||||
2. GREEN: Implement to pass
|
||||
3. REFACTOR: Clean up (if needed)
|
||||
4. Commit test and implementation together
|
||||
|
||||
### type: checkpoint:*
|
||||
Execute, then trigger checkpoint as specified.
|
||||
|
||||
## Quality Standards
|
||||
|
||||
### Code Quality
|
||||
- Follow existing patterns in codebase
|
||||
- TypeScript strict mode
|
||||
- No any types unless absolutely necessary
|
||||
- Meaningful variable names
|
||||
- Error handling at boundaries
|
||||
|
||||
### What NOT to Do
|
||||
- Add features beyond the task
|
||||
- Refactor surrounding code
|
||||
- Add comments to unchanged code
|
||||
- Create abstractions for one-time operations
|
||||
- Design for hypothetical futures
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- `// TODO` comments
|
||||
- `throw new Error('Not implemented')`
|
||||
- `return null` placeholders
|
||||
- `console.log` in production code
|
||||
- Empty catch blocks
|
||||
- Hardcoded values that should be config
|
||||
|
||||
## SUMMARY.md Creation
|
||||
|
||||
After plan completion, create SUMMARY.md:
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
plan: 3
|
||||
subsystem: auth
|
||||
tags: [jwt, security]
|
||||
requires: [users_table, jose]
|
||||
provides: [refresh_tokens, token_rotation]
|
||||
affects: [auth_flow, sessions]
|
||||
tech_stack: [jose, drizzle, sqlite]
|
||||
key_files:
|
||||
- src/api/auth/refresh.ts: "Rotation endpoint"
|
||||
decisions:
|
||||
- "Token family for reuse detection"
|
||||
metrics:
|
||||
tasks_completed: 3
|
||||
deviations: 2
|
||||
context_usage: "38%"
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
## What Was Built
|
||||
[Description of what was implemented]
|
||||
|
||||
## Implementation Notes
|
||||
[Technical details worth preserving]
|
||||
|
||||
## Deviations
|
||||
[List all Rule 1-4 deviations with details]
|
||||
|
||||
## Commits
|
||||
[List of commits created]
|
||||
|
||||
## Verification Status
|
||||
[Checklist from plan with status]
|
||||
|
||||
## Notes for Next Plan
|
||||
[Context for future work]
|
||||
```
|
||||
|
||||
## State Updates
|
||||
|
||||
### On Task Start
|
||||
```
|
||||
position:
|
||||
task: "current task name"
|
||||
status: in_progress
|
||||
```
|
||||
|
||||
### On Task Complete
|
||||
```
|
||||
progress:
|
||||
current_phase_completed: N+1
|
||||
```
|
||||
|
||||
### On Plan Complete
|
||||
```
|
||||
sessions:
|
||||
- completed: ["Phase X, Plan Y"]
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Task Fails Verification
|
||||
1. Analyze failure
|
||||
2. If fixable → fix and re-verify
|
||||
3. If not fixable → mark blocked, document issue
|
||||
4. Continue to next task if independent
|
||||
|
||||
### Context Limit Approaching
|
||||
1. Complete current task
|
||||
2. Update STATE.md with position
|
||||
3. Create handoff with resume context
|
||||
4. Exit cleanly for fresh session
|
||||
|
||||
### Unexpected Blocker
|
||||
1. Document blocker in STATE.md
|
||||
2. Check if other tasks can proceed
|
||||
3. If all blocked → escalate to orchestrator
|
||||
4. If some unblocked → continue with those
|
||||
|
||||
## Session End
|
||||
|
||||
Before ending session:
|
||||
1. Commit any uncommitted work
|
||||
2. Create SUMMARY.md if plan complete
|
||||
3. Update STATE.md with position
|
||||
4. Set next_action for resume
|
||||
|
||||
## What You Do NOT Do
|
||||
|
||||
- Make architectural decisions (Rule 4 → ask)
|
||||
- Replan work (follow the plan)
|
||||
- Add unrequested features
|
||||
- Skip verify steps
|
||||
- Leave uncommitted changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Tasks Module
|
||||
- Claims tasks via `cw task update --status in_progress`
|
||||
- Closes tasks via `cw task close --reason "..."`
|
||||
- Respects dependencies (only works on ready tasks)
|
||||
|
||||
### With Orchestrator
|
||||
- Receives task assignments
|
||||
- Reports completion/blockers
|
||||
- Triggers handoff when context full
|
||||
|
||||
### With Architect
|
||||
- Consumes PLAN.md files
|
||||
- Produces SUMMARY.md feedback
|
||||
|
||||
### With Verifier
|
||||
- SUMMARY.md feeds verification
|
||||
- Verification results may spawn fix tasks
|
||||
|
||||
---
|
||||
|
||||
## Spawning
|
||||
|
||||
Orchestrator spawns Worker:
|
||||
|
||||
```typescript
|
||||
const workerResult = await spawnAgent({
|
||||
type: 'worker',
|
||||
task: 'execute-plan',
|
||||
context: {
|
||||
plan_file: '2-3-PLAN.md',
|
||||
state_file: 'STATE.md',
|
||||
prior_summaries: ['2-1-SUMMARY.md', '2-2-SUMMARY.md']
|
||||
},
|
||||
model: getModelForProfile('worker', config.modelProfile),
|
||||
worktree: 'worker-abc-123' // Isolated git worktree
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Session
|
||||
|
||||
```
|
||||
1. Load PLAN.md
|
||||
2. Load prior context (STATE.md, SUMMARY files)
|
||||
3. For each task:
|
||||
a. Mark in_progress
|
||||
b. Read files
|
||||
c. Execute action
|
||||
d. Handle deviations (Rules 1-4)
|
||||
e. Run verify
|
||||
f. Commit atomically
|
||||
g. Mark closed
|
||||
4. Create SUMMARY.md
|
||||
5. Update STATE.md
|
||||
6. Return to orchestrator
|
||||
```
|
||||
218
docs/context-engineering.md
Normal file
218
docs/context-engineering.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Context Engineering
|
||||
|
||||
Context engineering is a first-class concern in Codewalk. Agent output quality degrades predictably as context fills. This document defines the rules that all agents must follow.
|
||||
|
||||
## Quality Degradation Curve
|
||||
|
||||
Claude's output quality follows a predictable curve based on context utilization:
|
||||
|
||||
| Context Usage | Quality Level | Behavior |
|
||||
|---------------|---------------|----------|
|
||||
| 0-30% | **PEAK** | Thorough, comprehensive, considers edge cases |
|
||||
| 30-50% | **GOOD** | Confident, solid work, reliable output |
|
||||
| 50-70% | **DEGRADING** | Efficiency mode begins, shortcuts appear |
|
||||
| 70%+ | **POOR** | Rushed, minimal, misses requirements |
|
||||
|
||||
**Rule: Stay UNDER 50% context for quality work.**
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator Pattern
|
||||
|
||||
Codewalk uses thin orchestration with heavy subagent work:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Orchestrator (30-40%) │
|
||||
│ - Routes work to specialized agents │
|
||||
│ - Collects results │
|
||||
│ - Maintains state │
|
||||
│ - Coordinates across phases │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Worker │ │ Architect │ │ Verifier │
|
||||
│ (200k ctx) │ │ (200k ctx) │ │ (200k ctx) │
|
||||
│ Fresh per │ │ Fresh per │ │ Fresh per │
|
||||
│ task │ │ initiative │ │ phase │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Key insight:** Each subagent gets a fresh 200k context window. Heavy work happens there, not in the orchestrator.
|
||||
|
||||
---
|
||||
|
||||
## Context Budgets by Role
|
||||
|
||||
### Orchestrator
|
||||
- **Target:** 30-40% max
|
||||
- **Strategy:** Route, don't process. Collect results, don't analyze.
|
||||
- **Reset trigger:** Context exceeds 50%
|
||||
|
||||
### Worker
|
||||
- **Target:** 50% per task
|
||||
- **Strategy:** Single task per context. Fresh context for each task.
|
||||
- **Reset trigger:** Task completion (always)
|
||||
|
||||
### Architect
|
||||
- **Target:** 60% per initiative analysis
|
||||
- **Strategy:** Initiative discussion + planning in single context
|
||||
- **Reset trigger:** Work plan generated or context exceeds 70%
|
||||
|
||||
### Verifier
|
||||
- **Target:** 40% per phase verification
|
||||
- **Strategy:** Goal-backward verification, gap identification
|
||||
- **Reset trigger:** Verification complete
|
||||
|
||||
---
|
||||
|
||||
## Task Sizing Rules
|
||||
|
||||
Tasks are sized to fit context budgets:
|
||||
|
||||
| Task Complexity | Context Estimate | Example |
|
||||
|-----------------|------------------|---------|
|
||||
| Simple | 10-20% | Add a field to an existing form |
|
||||
| Medium | 20-35% | Create new API endpoint with validation |
|
||||
| Complex | 35-50% | Implement auth flow with refresh tokens |
|
||||
| Too Large | >50% | **SPLIT INTO SUBTASKS** |
|
||||
|
||||
**Planning rule:** No single task should require >50% context. If estimation suggests otherwise, decompose before execution.
|
||||
|
||||
---
|
||||
|
||||
## Plan Sizing
|
||||
|
||||
Plans group 2-3 related tasks for sequential execution:
|
||||
|
||||
| Plan Size | Target Context | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| Minimal (1 task) | 20-30% | Simple independent work |
|
||||
| Standard (2-3 tasks) | 40-50% | Related work, shared context |
|
||||
| Maximum | 50% | Never exceed—quality degrades |
|
||||
|
||||
**Why 2-3 tasks?** Shared context reduces overhead (file reads, understanding). More than 3 loses quality benefits.
|
||||
|
||||
---
|
||||
|
||||
## Wave-Based Parallelization
|
||||
|
||||
Compute dependency graph and assign tasks to waves:
|
||||
|
||||
```
|
||||
Wave 0: Tasks with no dependencies (run in parallel)
|
||||
↓
|
||||
Wave 1: Tasks depending only on Wave 0 (run in parallel)
|
||||
↓
|
||||
Wave 2: Tasks depending only on Wave 0-1 (run in parallel)
|
||||
↓
|
||||
...continue until all tasks assigned
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Maximum parallelization
|
||||
- Clear progress tracking
|
||||
- Natural checkpoints between waves
|
||||
|
||||
### Computation Algorithm
|
||||
|
||||
```
|
||||
1. Build dependency graph from task dependencies
|
||||
2. Find all tasks with no unresolved dependencies → Wave 0
|
||||
3. Mark Wave 0 as "resolved"
|
||||
4. Find all tasks whose dependencies are all resolved → Wave 1
|
||||
5. Repeat until all tasks assigned
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Handoff
|
||||
|
||||
When context fills, perform controlled handoff:
|
||||
|
||||
### STATE.md Update
|
||||
Before handoff, update session state:
|
||||
|
||||
```yaml
|
||||
position:
|
||||
phase: 2
|
||||
plan: 3
|
||||
task: "Implement refresh token rotation"
|
||||
wave: 1
|
||||
|
||||
decisions:
|
||||
- "Using jose library for JWT (not jsonwebtoken)"
|
||||
- "Refresh tokens stored in httpOnly cookie, not localStorage"
|
||||
- "15min access token, 7day refresh token"
|
||||
|
||||
blockers:
|
||||
- "Waiting for user to configure OAuth credentials"
|
||||
|
||||
next_action: "Continue with task after blocker resolved"
|
||||
```
|
||||
|
||||
### Handoff Content
|
||||
New session receives:
|
||||
- STATE.md (current position)
|
||||
- Relevant SUMMARY.md files (prior work in this phase)
|
||||
- Current PLAN.md (if executing)
|
||||
- Task context from initiative
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Context Stuffing
|
||||
**Wrong:** Loading entire codebase at session start
|
||||
**Right:** Load files on-demand as tasks require them
|
||||
|
||||
### Orchestrator Processing
|
||||
**Wrong:** Orchestrator reads all code and makes decisions
|
||||
**Right:** Orchestrator routes to specialized agents who do the work
|
||||
|
||||
### Plan Bloat
|
||||
**Wrong:** 10-task plans to "reduce coordination overhead"
|
||||
**Right:** 2-3 task plans that fit in 50% context
|
||||
|
||||
### No Handoff State
|
||||
**Wrong:** Agent restarts with no memory of prior work
|
||||
**Right:** STATE.md preserves position, decisions, blockers
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
Track context utilization across the system:
|
||||
|
||||
| Metric | Threshold | Action |
|
||||
|--------|-----------|--------|
|
||||
| Orchestrator context | >50% | Trigger handoff |
|
||||
| Worker task context | >60% | Flag task as oversized |
|
||||
| Plan total estimate | >50% | Split plan before execution |
|
||||
| Average task context | >40% | Review decomposition strategy |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Context Estimation
|
||||
Estimate context usage before execution:
|
||||
- File reads: ~1-2% per file (varies by size)
|
||||
- Code changes: ~0.5% per change
|
||||
- Tool outputs: ~1% per tool call
|
||||
- Discussion: ~2-5% per exchange
|
||||
|
||||
### Fresh Context Triggers
|
||||
- Worker: Always fresh per task
|
||||
- Architect: Fresh per initiative
|
||||
- Verifier: Fresh per phase
|
||||
- Orchestrator: Handoff at 50%
|
||||
|
||||
### Subagent Spawning
|
||||
When spawning subagents:
|
||||
1. Provide focused context (only what's needed)
|
||||
2. Clear instructions (specific task, expected output)
|
||||
3. Collect structured results
|
||||
4. Update state with outcomes
|
||||
50
docs/database-migrations.md
Normal file
50
docs/database-migrations.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Database Migrations
|
||||
|
||||
This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for database schema management and migrations.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Schema definition:** `src/db/schema.ts` (drizzle-orm table definitions)
|
||||
- **Migration output:** `drizzle/` directory (SQL files + meta journal)
|
||||
- **Config:** `drizzle.config.ts`
|
||||
- **Runtime migrator:** `src/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`)
|
||||
|
||||
## How It Works
|
||||
|
||||
On every server startup, `ensureSchema(db)` runs all pending migrations from the `drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Making schema changes
|
||||
|
||||
1. Edit `src/db/schema.ts` with your table/column changes
|
||||
2. Generate a migration:
|
||||
```bash
|
||||
npx drizzle-kit generate
|
||||
```
|
||||
3. Review the generated SQL in `drizzle/NNNN_*.sql`
|
||||
4. Commit the migration file along with your schema change
|
||||
|
||||
### Applying migrations
|
||||
|
||||
Migrations are applied automatically on server startup. No manual step needed.
|
||||
|
||||
For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `src/db/repositories/drizzle/test-helpers.ts`.
|
||||
|
||||
### Checking migration status
|
||||
|
||||
```bash
|
||||
# See what drizzle-kit would generate (dry run)
|
||||
npx drizzle-kit generate --dry-run
|
||||
|
||||
# Open drizzle studio to inspect the database
|
||||
npx drizzle-kit studio
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema.
|
||||
- **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this.
|
||||
- **Always commit migration files.** They are the source of truth for database evolution.
|
||||
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
|
||||
- **Test with `npx vitest run`** after generating migrations to verify they work with in-memory databases.
|
||||
263
docs/deviation-rules.md
Normal file
263
docs/deviation-rules.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Deviation Rules
|
||||
|
||||
During execution, agents discover work not in the original plan. These rules define how to handle deviations **automatically, without asking for permission** (except Rule 4).
|
||||
|
||||
## The Four Rules
|
||||
|
||||
### Rule 1: Auto-Fix Bugs
|
||||
**No permission needed.**
|
||||
|
||||
Fix immediately when encountering:
|
||||
- Broken code (syntax errors, runtime errors)
|
||||
- Logic errors (wrong conditions, off-by-one)
|
||||
- Security issues (injection vulnerabilities, exposed secrets)
|
||||
- Type errors (TypeScript violations)
|
||||
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 1
|
||||
type: bug_fix
|
||||
description: "Fixed null reference in user lookup"
|
||||
location: src/services/auth.ts:45
|
||||
original_code: "user.email.toLowerCase()"
|
||||
fixed_code: "user?.email?.toLowerCase() ?? ''"
|
||||
reason: "Crashes when user not found"
|
||||
```
|
||||
|
||||
### Rule 2: Auto-Add Missing Critical Functionality
|
||||
**No permission needed.**
|
||||
|
||||
Add immediately when clearly required:
|
||||
- Error handling (try/catch for external calls)
|
||||
- Input validation (user input, API boundaries)
|
||||
- Authentication checks (protected routes)
|
||||
- CSRF protection
|
||||
- Rate limiting (if pattern exists in codebase)
|
||||
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 2
|
||||
type: missing_critical
|
||||
description: "Added input validation to createUser"
|
||||
location: src/api/users.ts:23
|
||||
added: "Zod schema validation for email, password length"
|
||||
reason: "API accepts any input without validation"
|
||||
```
|
||||
|
||||
### Rule 3: Auto-Fix Blocking Issues
|
||||
**No permission needed.**
|
||||
|
||||
Fix immediately when blocking task completion:
|
||||
- Missing dependencies (npm install)
|
||||
- Broken imports (wrong paths, missing exports)
|
||||
- Configuration errors (env vars, tsconfig)
|
||||
- Build failures (compilation errors)
|
||||
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 3
|
||||
type: blocking_issue
|
||||
description: "Added missing zod dependency"
|
||||
command: "npm install zod"
|
||||
reason: "Import fails without package"
|
||||
```
|
||||
|
||||
### Rule 4: ASK About Architectural Changes
|
||||
**Permission required.**
|
||||
|
||||
Stop and ask user before:
|
||||
- New database tables or major schema changes
|
||||
- New services or major component additions
|
||||
- Changes to API contracts
|
||||
- New external dependencies (beyond obvious needs)
|
||||
- Authentication/authorization model changes
|
||||
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 4
|
||||
type: architectural_change
|
||||
status: PENDING_APPROVAL
|
||||
description: "Considering adding Redis for session storage"
|
||||
current: "Sessions stored in SQLite"
|
||||
proposed: "Redis for distributed session storage"
|
||||
reason: "Multiple server instances need shared sessions"
|
||||
question: "Should we add Redis, or use sticky sessions instead?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Encountered unexpected issue
|
||||
│
|
||||
▼
|
||||
Is it broken code?
|
||||
(errors, bugs, security)
|
||||
│
|
||||
YES ─┴─ NO
|
||||
│ │
|
||||
▼ ▼
|
||||
Rule 1 Is critical functionality missing?
|
||||
Auto-fix (validation, auth, error handling)
|
||||
│
|
||||
YES ─┴─ NO
|
||||
│ │
|
||||
▼ ▼
|
||||
Rule 2 Is it blocking task completion?
|
||||
Auto-add (deps, imports, config)
|
||||
│
|
||||
YES ─┴─ NO
|
||||
│ │
|
||||
▼ ▼
|
||||
Rule 3 Is it architectural?
|
||||
Auto-fix (tables, services, contracts)
|
||||
│
|
||||
YES ─┴─ NO
|
||||
│ │
|
||||
▼ ▼
|
||||
Rule 4 Ignore or note
|
||||
ASK for future
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
All deviations MUST be documented in SUMMARY.md:
|
||||
|
||||
```yaml
|
||||
# 2-3-SUMMARY.md
|
||||
phase: 2
|
||||
plan: 3
|
||||
|
||||
deviations:
|
||||
- rule: 1
|
||||
type: bug_fix
|
||||
description: "Fixed null reference in auth service"
|
||||
location: src/services/auth.ts:45
|
||||
|
||||
- rule: 2
|
||||
type: missing_critical
|
||||
description: "Added Zod validation to user API"
|
||||
location: src/api/users.ts:23-45
|
||||
|
||||
- rule: 3
|
||||
type: blocking_issue
|
||||
description: "Installed missing jose dependency"
|
||||
command: "npm install jose"
|
||||
|
||||
- rule: 4
|
||||
type: architectural_change
|
||||
status: APPROVED
|
||||
description: "Added refresh_tokens table"
|
||||
approved_by: user
|
||||
approved_at: 2024-01-15T10:30:00Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deviation Tracking in Tasks
|
||||
|
||||
When a deviation is significant, create tracking:
|
||||
|
||||
### Minor Deviations
|
||||
Log in SUMMARY.md, no separate task.
|
||||
|
||||
### Major Deviations (Rule 4)
|
||||
Create a decision record:
|
||||
|
||||
```sql
|
||||
INSERT INTO task_history (
|
||||
task_id,
|
||||
field,
|
||||
old_value,
|
||||
new_value,
|
||||
changed_by
|
||||
) VALUES (
|
||||
'current-task-id',
|
||||
'deviation',
|
||||
NULL,
|
||||
'{"rule": 4, "description": "Added Redis", "approved": true}',
|
||||
'worker-123'
|
||||
);
|
||||
```
|
||||
|
||||
### Deviations That Spawn Work
|
||||
If fixing a deviation requires substantial work:
|
||||
|
||||
1. Complete current task
|
||||
2. Create new task for deviation work
|
||||
3. Link new task as dependency if blocking
|
||||
4. Continue with original plan
|
||||
|
||||
---
|
||||
|
||||
## Examples by Category
|
||||
|
||||
### Rule 1: Bug Fixes
|
||||
|
||||
| Issue | Fix | Documentation |
|
||||
|-------|-----|---------------|
|
||||
| Undefined property access | Add optional chaining | Note in summary |
|
||||
| SQL injection vulnerability | Use parameterized query | Note + security flag |
|
||||
| Race condition in async code | Add proper await | Note in summary |
|
||||
| Incorrect error message | Fix message text | Note in summary |
|
||||
|
||||
### Rule 2: Missing Critical
|
||||
|
||||
| Gap | Addition | Documentation |
|
||||
|-----|----------|---------------|
|
||||
| No input validation | Add Zod/Yup schema | Note in summary |
|
||||
| No error handling | Add try/catch + logging | Note in summary |
|
||||
| No auth check | Add middleware | Note in summary |
|
||||
| No CSRF token | Add csrf protection | Note + security flag |
|
||||
|
||||
### Rule 3: Blocking Issues
|
||||
|
||||
| Blocker | Resolution | Documentation |
|
||||
|---------|------------|---------------|
|
||||
| Missing npm package | npm install | Note in summary |
|
||||
| Wrong import path | Fix path | Note in summary |
|
||||
| Missing env var | Add to .env.example | Note in summary |
|
||||
| TypeScript config issue | Fix tsconfig | Note in summary |
|
||||
|
||||
### Rule 4: Architectural (ASK FIRST)
|
||||
|
||||
| Change | Why Ask | Question Format |
|
||||
|--------|---------|-----------------|
|
||||
| New DB table | Schema is contract | "Need users_sessions table. Create it?" |
|
||||
| New service | Architectural decision | "Extract auth to separate service?" |
|
||||
| API contract change | Breaking change | "Change POST /users response format?" |
|
||||
| New external dep | Maintenance burden | "Add Redis for caching?" |
|
||||
|
||||
---
|
||||
|
||||
## Integration with Verification
|
||||
|
||||
Deviations are inputs to verification:
|
||||
|
||||
1. **Verifier loads SUMMARY.md** with deviation list
|
||||
2. **Bug fixes (Rule 1)** verify the fix doesn't break tests
|
||||
3. **Critical additions (Rule 2)** verify they're properly integrated
|
||||
4. **Blocking fixes (Rule 3)** verify build/tests pass
|
||||
5. **Architectural changes (Rule 4)** verify they match approved design
|
||||
|
||||
---
|
||||
|
||||
## Escalation Path
|
||||
|
||||
If unsure which rule applies:
|
||||
|
||||
1. **Default to Rule 4** (ask) rather than making wrong assumption
|
||||
2. Document uncertainty in deviation notes
|
||||
3. Include reasoning for why you're asking
|
||||
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 4
|
||||
type: uncertain
|
||||
description: "Adding caching layer to API responses"
|
||||
reason: "Could be Rule 2 (performance is critical) or Rule 4 (new infrastructure)"
|
||||
question: "Is Redis caching appropriate here, or should we use in-memory?"
|
||||
```
|
||||
434
docs/execution-artifacts.md
Normal file
434
docs/execution-artifacts.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# Execution Artifacts
|
||||
|
||||
Execution produces artifacts that document what happened, enable debugging, and provide context for future work.
|
||||
|
||||
## Artifact Types
|
||||
|
||||
| Artifact | Created By | Purpose |
|
||||
|----------|------------|---------|
|
||||
| PLAN.md | Architect | Executable instructions for a plan |
|
||||
| SUMMARY.md | Worker | Record of what actually happened |
|
||||
| VERIFICATION.md | Verifier | Goal-backward verification results |
|
||||
| UAT.md | Verifier + User | User acceptance testing results |
|
||||
| STATE.md | All agents | Session state (see [session-state.md](session-state.md)) |
|
||||
|
||||
---
|
||||
|
||||
## PLAN.md
|
||||
|
||||
Plans are **executable prompts**, not documents that transform into prompts.
|
||||
|
||||
### Structure
|
||||
|
||||
```yaml
|
||||
---
|
||||
# Frontmatter
|
||||
phase: 2
|
||||
plan: 3
|
||||
type: execute # execute | tdd
|
||||
wave: 1
|
||||
depends_on: [2-2-PLAN]
|
||||
files_modified:
|
||||
- src/api/auth/refresh.ts
|
||||
- src/middleware/auth.ts
|
||||
- db/migrations/002_refresh_tokens.sql
|
||||
autonomous: true # false if checkpoints required
|
||||
must_haves:
|
||||
observable_truths:
|
||||
- "Refresh token extends session"
|
||||
- "Old token invalidated after rotation"
|
||||
required_artifacts:
|
||||
- src/api/auth/refresh.ts
|
||||
required_wiring:
|
||||
- "refresh endpoint -> token storage"
|
||||
user_setup: [] # Human prereqs if any
|
||||
---
|
||||
|
||||
# Phase 2, Plan 3: Refresh Token Rotation
|
||||
|
||||
## Objective
|
||||
Implement refresh token rotation to extend user sessions securely while preventing token reuse attacks.
|
||||
|
||||
## Context
|
||||
@file: PROJECT.md (project overview)
|
||||
@file: 2-CONTEXT.md (phase decisions)
|
||||
@file: 2-1-SUMMARY.md (prior work)
|
||||
@file: 2-2-SUMMARY.md (prior work)
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create refresh_tokens table
|
||||
- **type:** auto
|
||||
- **files:** db/migrations/002_refresh_tokens.sql, src/db/schema/refreshTokens.ts
|
||||
- **action:** Create table with: id (uuid), user_id (fk), token_hash (sha256), family (uuid for rotation tracking), expires_at, created_at, revoked_at. Index on token_hash and user_id.
|
||||
- **verify:** `cw db migrate` succeeds, schema matches
|
||||
- **done:** Migration applies, drizzle schema matches SQL
|
||||
|
||||
### Task 2: Implement rotation endpoint
|
||||
- **type:** auto
|
||||
- **files:** src/api/auth/refresh.ts
|
||||
- **action:** POST /api/auth/refresh accepts refresh token in httpOnly cookie. Validate token exists and not expired. Generate new access + refresh tokens. Store new refresh, revoke old. Set cookies. Return 200 with new access token.
|
||||
- **verify:** curl with valid refresh cookie returns new tokens
|
||||
- **done:** Rotation works, old token invalidated
|
||||
|
||||
### Task 3: Add token family validation
|
||||
- **type:** auto
|
||||
- **files:** src/api/auth/refresh.ts
|
||||
- **action:** If revoked token reused, revoke entire family (reuse detection). Log security event.
|
||||
- **verify:** Reusing old token revokes all tokens in family
|
||||
- **done:** Reuse detection active
|
||||
|
||||
## Verification Criteria
|
||||
- [ ] New refresh token issued on rotation
|
||||
- [ ] Old refresh token no longer valid
|
||||
- [ ] Reused token triggers family revocation
|
||||
- [ ] Access token returned in response
|
||||
- [ ] Cookies set with correct flags (httpOnly, secure, sameSite)
|
||||
|
||||
## Success Criteria
|
||||
- All tasks complete with passing verify steps
|
||||
- No TypeScript errors
|
||||
- Tests cover happy path and reuse detection
|
||||
```
|
||||
|
||||
### Key Elements
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| `type: execute\|tdd` | Execution strategy |
|
||||
| `wave` | Parallelization grouping |
|
||||
| `depends_on` | Must complete first |
|
||||
| `files_modified` | Git tracking, conflict detection |
|
||||
| `autonomous` | Can run without checkpoints |
|
||||
| `must_haves` | Verification criteria |
|
||||
| `@file` references | Context to load |
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY.md
|
||||
|
||||
Created after plan execution. Documents what **actually happened**.
|
||||
|
||||
### Structure
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
plan: 3
|
||||
subsystem: auth
|
||||
tags: [jwt, security, tokens]
|
||||
requires:
|
||||
- users table
|
||||
- jose library
|
||||
provides:
|
||||
- refresh token rotation
|
||||
- reuse detection
|
||||
affects:
|
||||
- auth flow
|
||||
- session management
|
||||
tech_stack:
|
||||
- jose (JWT)
|
||||
- drizzle (ORM)
|
||||
- sqlite
|
||||
key_files:
|
||||
- src/api/auth/refresh.ts: "Rotation endpoint"
|
||||
- src/db/schema/refreshTokens.ts: "Token storage"
|
||||
decisions:
|
||||
- "Token family for reuse detection"
|
||||
- "SHA256 hash for token storage"
|
||||
metrics:
|
||||
tasks_completed: 3
|
||||
tasks_total: 3
|
||||
deviations: 2
|
||||
execution_time: "45 minutes"
|
||||
context_usage: "38%"
|
||||
---
|
||||
|
||||
# Phase 2, Plan 3 Summary: Refresh Token Rotation
|
||||
|
||||
## What Was Built
|
||||
Implemented refresh token rotation with security features:
|
||||
- Rotation endpoint at POST /api/auth/refresh
|
||||
- Token storage with family tracking
|
||||
- Reuse detection that revokes entire token family
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Token Storage
|
||||
Tokens stored as SHA256 hashes (never plaintext). Family UUID links related tokens for rotation tracking.
|
||||
|
||||
### Rotation Flow
|
||||
1. Receive refresh token in cookie
|
||||
2. Hash and lookup in database
|
||||
3. Verify not expired, not revoked
|
||||
4. Generate new access + refresh tokens
|
||||
5. Store new refresh with same family
|
||||
6. Revoke old refresh token
|
||||
7. Set new cookies, return access token
|
||||
|
||||
### Reuse Detection
|
||||
If a revoked token is presented, the entire family is revoked. This catches scenarios where an attacker captured an old token.
|
||||
|
||||
## Deviations
|
||||
|
||||
### Rule 2: Added rate limiting
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 2
|
||||
type: missing_critical
|
||||
description: "Added rate limiting to refresh endpoint"
|
||||
location: src/api/auth/refresh.ts:12
|
||||
reason: "Prevent brute force token guessing"
|
||||
```
|
||||
|
||||
### Rule 1: Fixed async handler
|
||||
```yaml
|
||||
deviation:
|
||||
rule: 1
|
||||
type: bug_fix
|
||||
description: "Added await to database query"
|
||||
location: src/api/auth/refresh.ts:34
|
||||
reason: "Query returned promise, not result"
|
||||
```
|
||||
|
||||
## Commits
|
||||
- `feat(2-3): create refresh_tokens table and schema`
|
||||
- `feat(2-3): implement token rotation endpoint`
|
||||
- `feat(2-3): add token family reuse detection`
|
||||
- `fix(2-3): add await to token lookup query`
|
||||
- `feat(2-3): add rate limiting to refresh endpoint`
|
||||
|
||||
## Verification Status
|
||||
- [x] New refresh token issued on rotation
|
||||
- [x] Old refresh token invalidated
|
||||
- [x] Reuse detection works
|
||||
- [x] Cookies set correctly
|
||||
- [ ] **Pending human verification:** Cookie flags in production
|
||||
|
||||
## Notes for Next Plan
|
||||
- Rate limiting added; may need tuning based on load
|
||||
- Token family approach may need cleanup job for old families
|
||||
```
|
||||
|
||||
### What to Include
|
||||
|
||||
| Section | Content |
|
||||
|---------|---------|
|
||||
| Frontmatter | Metadata for future queries |
|
||||
| What Was Built | High-level summary |
|
||||
| Implementation Notes | Technical details worth preserving |
|
||||
| Deviations | All Rules 1-4 deviations with details |
|
||||
| Commits | Git commit messages created |
|
||||
| Verification Status | What passed, what's pending |
|
||||
| Notes for Next Plan | Context for future work |
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION.md
|
||||
|
||||
Created by Verifier after phase completion.
|
||||
|
||||
### Structure
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
status: PASS # PASS | GAPS_FOUND
|
||||
verified_at: 2024-01-15T10:30:00Z
|
||||
verified_by: verifier-agent
|
||||
---
|
||||
|
||||
# Phase 2 Verification: JWT Implementation
|
||||
|
||||
## Observable Truths
|
||||
|
||||
| Truth | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| User can log in with email/password | VERIFIED | Login endpoint returns tokens, sets cookies |
|
||||
| Sessions persist across page refresh | VERIFIED | Cookie-based token survives reload |
|
||||
| Token refresh extends session | VERIFIED | Refresh endpoint issues new tokens |
|
||||
| Expired tokens rejected | VERIFIED | 401 returned for expired access token |
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
| Artifact | Status | Check |
|
||||
|----------|--------|-------|
|
||||
| src/api/auth/login.ts | EXISTS | Exports login handler |
|
||||
| src/api/auth/refresh.ts | EXISTS | Exports refresh handler |
|
||||
| src/middleware/auth.ts | EXISTS | Exports auth middleware |
|
||||
| db/migrations/002_refresh_tokens.sql | EXISTS | Creates table |
|
||||
|
||||
## Required Wiring
|
||||
|
||||
| From | To | Status | Evidence |
|
||||
|------|-----|--------|----------|
|
||||
| Login handler | Token generation | WIRED | login.ts:45 calls createTokens |
|
||||
| Auth middleware | Token validation | WIRED | auth.ts:23 calls verifyToken |
|
||||
| Refresh handler | Token rotation | WIRED | refresh.ts:67 calls rotateToken |
|
||||
| Protected routes | Auth middleware | WIRED | routes.ts uses auth middleware |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Pattern | Found | Location |
|
||||
|---------|-------|----------|
|
||||
| TODO comments | NO | - |
|
||||
| Stub implementations | NO | - |
|
||||
| Console.log in handlers | YES | src/api/auth/login.ts:34 (debug log) |
|
||||
| Empty catch blocks | NO | - |
|
||||
|
||||
## Human Verification Needed
|
||||
|
||||
| Check | Reason |
|
||||
|-------|--------|
|
||||
| Cookie flags in production | Requires deployed environment |
|
||||
| Token timing accuracy | Requires wall-clock testing |
|
||||
|
||||
## Gaps Found
|
||||
None blocking. One console.log should be removed before production.
|
||||
|
||||
## Remediation
|
||||
- Task created: "Remove debug console.log from login handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UAT.md
|
||||
|
||||
User Acceptance Testing results.
|
||||
|
||||
### Structure
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 2
|
||||
tested_by: user
|
||||
tested_at: 2024-01-15T14:00:00Z
|
||||
status: PASS # PASS | ISSUES_FOUND
|
||||
---
|
||||
|
||||
# Phase 2 UAT: JWT Implementation
|
||||
|
||||
## Test Cases
|
||||
|
||||
### 1. Login with email and password
|
||||
**Prompt:** "Can you log in with your email and password?"
|
||||
**Result:** PASS
|
||||
**Notes:** Login successful, redirected to dashboard
|
||||
|
||||
### 2. Session persists on refresh
|
||||
**Prompt:** "Refresh the page. Are you still logged in?"
|
||||
**Result:** PASS
|
||||
**Notes:** Still authenticated after refresh
|
||||
|
||||
### 3. Logout clears session
|
||||
**Prompt:** "Click logout. Can you access the dashboard?"
|
||||
**Result:** PASS
|
||||
**Notes:** Redirected to login page
|
||||
|
||||
### 4. Expired session prompts re-login
|
||||
**Prompt:** "Wait 15 minutes (or we can simulate). Does the session refresh?"
|
||||
**Result:** SKIPPED
|
||||
**Reason:** "User chose to trust token rotation implementation"
|
||||
|
||||
## Issues Found
|
||||
None.
|
||||
|
||||
## Sign-Off
|
||||
User confirms Phase 2 JWT Implementation meets requirements.
|
||||
Next: Proceed to Phase 3 (OAuth Integration)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Artifact Storage
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
.planning/
|
||||
├── phases/
|
||||
│ ├── 1/
|
||||
│ │ ├── 1-CONTEXT.md
|
||||
│ │ ├── 1-1-PLAN.md
|
||||
│ │ ├── 1-1-SUMMARY.md
|
||||
│ │ ├── 1-2-PLAN.md
|
||||
│ │ ├── 1-2-SUMMARY.md
|
||||
│ │ └── 1-VERIFICATION.md
|
||||
│ └── 2/
|
||||
│ ├── 2-CONTEXT.md
|
||||
│ ├── 2-1-PLAN.md
|
||||
│ ├── 2-1-SUMMARY.md
|
||||
│ ├── 2-2-PLAN.md
|
||||
│ ├── 2-2-SUMMARY.md
|
||||
│ ├── 2-3-PLAN.md
|
||||
│ ├── 2-3-SUMMARY.md
|
||||
│ ├── 2-VERIFICATION.md
|
||||
│ └── 2-UAT.md
|
||||
├── STATE.md
|
||||
└── config.json
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---------|---------|
|
||||
| `{phase}-CONTEXT.md` | Discussion decisions for phase |
|
||||
| `{phase}-{plan}-PLAN.md` | Executable plan |
|
||||
| `{phase}-{plan}-SUMMARY.md` | Execution record |
|
||||
| `{phase}-VERIFICATION.md` | Phase verification |
|
||||
| `{phase}-UAT.md` | User acceptance testing |
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
Each task produces an atomic commit:
|
||||
|
||||
```
|
||||
{type}({phase}-{plan}): {description}
|
||||
|
||||
- Detail 1
|
||||
- Detail 2
|
||||
```
|
||||
|
||||
### Types
|
||||
- `feat`: New functionality
|
||||
- `fix`: Bug fix
|
||||
- `test`: Test additions
|
||||
- `refactor`: Code restructuring
|
||||
- `perf`: Performance improvement
|
||||
- `docs`: Documentation
|
||||
- `style`: Formatting only
|
||||
- `chore`: Maintenance
|
||||
|
||||
### Examples
|
||||
```
|
||||
feat(2-3): implement refresh token rotation
|
||||
|
||||
- Add refresh_tokens table with family tracking
|
||||
- Implement rotation endpoint at POST /api/auth/refresh
|
||||
- Add reuse detection with family revocation
|
||||
|
||||
fix(2-3): add await to token lookup query
|
||||
|
||||
- Token lookup was returning promise instead of result
|
||||
- Added proper await in refresh handler
|
||||
|
||||
feat(2-3): add rate limiting to refresh endpoint
|
||||
|
||||
- [Deviation Rule 2] Added express-rate-limit
|
||||
- 10 requests per minute per IP
|
||||
- Prevents brute force token guessing
|
||||
```
|
||||
|
||||
### Metadata Commit
|
||||
|
||||
After plan completion:
|
||||
```
|
||||
chore(2-3): complete plan execution
|
||||
|
||||
Artifacts:
|
||||
- 2-3-SUMMARY.md created
|
||||
- STATE.md updated
|
||||
- 3 tasks completed, 2 deviations handled
|
||||
```
|
||||
520
docs/initiatives.md
Normal file
520
docs/initiatives.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Initiatives Module
|
||||
|
||||
Initiatives are the planning layer for larger features. They provide a Notion-like document hierarchy for capturing context, decisions, and requirements before work begins. Once approved, initiatives generate phased task plans that agents execute.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
### Why Initiatives?
|
||||
|
||||
Tasks are atomic work units—great for execution but too granular for planning. Initiatives bridge the gap:
|
||||
|
||||
- **Before approval**: A living document where user and Architect refine the vision
|
||||
- **After approval**: A persistent knowledge base that tasks link back to
|
||||
- **Forever**: Context for future work ("why did we build it this way?")
|
||||
|
||||
### Notion-Like Structure
|
||||
|
||||
Initiatives aren't flat documents. They're hierarchical pages:
|
||||
|
||||
```
|
||||
Initiative: User Authentication
|
||||
├── User Journeys
|
||||
│ ├── Sign Up Flow
|
||||
│ └── Password Reset Flow
|
||||
├── Business Rules
|
||||
│ └── Password Requirements
|
||||
├── Technical Concept
|
||||
│ ├── JWT Strategy
|
||||
│ └── Session Management
|
||||
└── Architectural Changes
|
||||
└── Auth Middleware
|
||||
```
|
||||
|
||||
Each "page" is a record in SQLite with parent-child relationships. This enables:
|
||||
- Structured queries: "Give me all subpages of initiative X"
|
||||
- Inventory views: "List all technical concepts across initiatives"
|
||||
- Cross-references: Link between pages
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Initiative Entity
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | TEXT | Primary key (e.g., `init-a1b2c3`) |
|
||||
| `project_id` | TEXT | Scopes to a project (most initiatives are single-project) |
|
||||
| `title` | TEXT | Initiative name |
|
||||
| `status` | TEXT | `draft`, `review`, `approved`, `in_progress`, `completed`, `rejected` |
|
||||
| `created_by` | TEXT | User who created it |
|
||||
| `created_at` | INTEGER | Unix timestamp |
|
||||
| `updated_at` | INTEGER | Unix timestamp |
|
||||
| `approved_at` | INTEGER | When approved (null if not approved) |
|
||||
| `approved_by` | TEXT | Who approved it |
|
||||
|
||||
### Initiative Page Entity
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | TEXT | Primary key (e.g., `page-x1y2z3`) |
|
||||
| `initiative_id` | TEXT | Parent initiative |
|
||||
| `parent_page_id` | TEXT | Parent page (null for root-level pages) |
|
||||
| `type` | TEXT | `user_journey`, `business_rule`, `technical_concept`, `architectural_change`, `note`, `custom` |
|
||||
| `title` | TEXT | Page title |
|
||||
| `content` | TEXT | Markdown content |
|
||||
| `sort_order` | INTEGER | Display order among siblings |
|
||||
| `created_at` | INTEGER | Unix timestamp |
|
||||
| `updated_at` | INTEGER | Unix timestamp |
|
||||
|
||||
### Initiative Phase Entity
|
||||
|
||||
Phases group tasks for staged execution and rolling approval.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | TEXT | Primary key (e.g., `phase-p1q2r3`) |
|
||||
| `initiative_id` | TEXT | Parent initiative |
|
||||
| `number` | INTEGER | Phase number (1, 2, 3...) |
|
||||
| `name` | TEXT | Phase name |
|
||||
| `description` | TEXT | What this phase delivers |
|
||||
| `status` | TEXT | `draft`, `pending_approval`, `approved`, `in_progress`, `completed` |
|
||||
| `approved_at` | INTEGER | When approved |
|
||||
| `approved_by` | TEXT | Who approved |
|
||||
| `created_at` | INTEGER | Unix timestamp |
|
||||
|
||||
### Task Link
|
||||
|
||||
Tasks reference their initiative and phase:
|
||||
|
||||
```sql
|
||||
-- In tasks table (see docs/tasks.md)
|
||||
initiative_id TEXT REFERENCES initiatives(id),
|
||||
phase_id TEXT REFERENCES initiative_phases(id),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQLite Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE initiatives (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'review', 'approved', 'in_progress', 'completed', 'rejected')),
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
approved_at INTEGER,
|
||||
approved_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE initiative_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiative_id TEXT NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
parent_page_id TEXT REFERENCES initiative_pages(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'note'
|
||||
CHECK (type IN ('user_journey', 'business_rule', 'technical_concept', 'architectural_change', 'note', 'custom')),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE initiative_phases (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiative_id TEXT NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'pending_approval', 'approved', 'in_progress', 'completed')),
|
||||
approved_at INTEGER,
|
||||
approved_by TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE(initiative_id, number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_initiatives_project ON initiatives(project_id);
|
||||
CREATE INDEX idx_initiatives_status ON initiatives(status);
|
||||
CREATE INDEX idx_pages_initiative ON initiative_pages(initiative_id);
|
||||
CREATE INDEX idx_pages_parent ON initiative_pages(parent_page_id);
|
||||
CREATE INDEX idx_pages_type ON initiative_pages(type);
|
||||
CREATE INDEX idx_phases_initiative ON initiative_phases(initiative_id);
|
||||
CREATE INDEX idx_phases_status ON initiative_phases(status);
|
||||
|
||||
-- Useful views
|
||||
CREATE VIEW initiative_page_tree AS
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, initiative_id, parent_page_id, title, type, 0 as depth,
|
||||
title as path
|
||||
FROM initiative_pages WHERE parent_page_id IS NULL
|
||||
UNION ALL
|
||||
SELECT p.id, p.initiative_id, p.parent_page_id, p.title, p.type, t.depth + 1,
|
||||
t.path || ' > ' || p.title
|
||||
FROM initiative_pages p
|
||||
JOIN tree t ON p.parent_page_id = t.id
|
||||
)
|
||||
SELECT * FROM tree ORDER BY path;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status Workflow
|
||||
|
||||
### Initiative Status
|
||||
|
||||
```
|
||||
[draft] ──submit──▶ [review] ──approve──▶ [approved]
|
||||
│ │ │
|
||||
│ │ reject │ start work
|
||||
│ ▼ ▼
|
||||
│ [rejected] [in_progress]
|
||||
│ │
|
||||
│ │ all phases done
|
||||
└──────────────────────────────────────────▶ [completed]
|
||||
```
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `draft` | User/Architect still refining |
|
||||
| `review` | Ready for approval decision |
|
||||
| `approved` | Work plan created, awaiting execution |
|
||||
| `in_progress` | At least one phase executing |
|
||||
| `completed` | All phases completed |
|
||||
| `rejected` | Won't implement |
|
||||
|
||||
### Phase Status
|
||||
|
||||
```
|
||||
[draft] ──finalize──▶ [pending_approval] ──approve──▶ [approved]
|
||||
│
|
||||
│ claim tasks
|
||||
▼
|
||||
[in_progress]
|
||||
│
|
||||
│ all tasks closed
|
||||
▼
|
||||
[completed]
|
||||
```
|
||||
|
||||
**Rolling approval pattern:**
|
||||
1. Architect creates work plan with multiple phases
|
||||
2. User approves Phase 1 → agents start executing
|
||||
3. While Phase 1 executes, user reviews Phase 2
|
||||
4. Phase 2 approved → agents can start when ready
|
||||
5. Continue until all phases approved/completed
|
||||
|
||||
This prevents blocking: agents don't wait for all phases to be approved upfront.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Draft Initiative
|
||||
|
||||
User creates initiative with basic vision:
|
||||
|
||||
```
|
||||
cw initiative create "User Authentication"
|
||||
```
|
||||
|
||||
System creates initiative in `draft` status with empty page structure.
|
||||
|
||||
### 2. Architect Iteration (Questioning)
|
||||
|
||||
Architect agent engages in structured questioning to capture requirements:
|
||||
|
||||
**Question Categories:**
|
||||
|
||||
| Category | Example Questions |
|
||||
|----------|-------------------|
|
||||
| **Visual Features** | Layout approach? Density? Interactions? Empty states? |
|
||||
| **APIs/CLIs** | Response format? Flags? Error handling? Verbosity? |
|
||||
| **Data/Content** | Structure? Validation rules? Edge cases? |
|
||||
| **Architecture** | Patterns to follow? What to avoid? Reference code? |
|
||||
|
||||
Each answer populates initiative pages. Architect may:
|
||||
- Create user journey pages
|
||||
- Document business rules
|
||||
- Draft technical concepts
|
||||
- Flag architectural impacts
|
||||
|
||||
See [agents/architect.md](agents/architect.md) for the full Architect agent prompt.
|
||||
|
||||
### 3. Discussion Phase (Per Phase)
|
||||
|
||||
Before planning each phase, the Architect captures implementation decisions through focused discussion. This happens BEFORE any planning work.
|
||||
|
||||
```
|
||||
cw phase discuss <phase-id>
|
||||
```
|
||||
|
||||
Creates `{phase}-CONTEXT.md` with locked decisions:
|
||||
|
||||
```yaml
|
||||
---
|
||||
phase: 1
|
||||
discussed_at: 2024-01-15
|
||||
---
|
||||
|
||||
# Phase 1 Context: User Authentication
|
||||
|
||||
## Decisions
|
||||
|
||||
### Authentication Method
|
||||
**Decision:** Email/password with optional OAuth
|
||||
**Reason:** MVP needs simple auth, OAuth for convenience
|
||||
**Locked:** true
|
||||
|
||||
### Token Storage
|
||||
**Decision:** httpOnly cookies
|
||||
**Reason:** XSS protection
|
||||
**Alternatives Rejected:**
|
||||
- localStorage: XSS vulnerable
|
||||
```
|
||||
|
||||
These decisions guide all subsequent planning and execution. Workers reference CONTEXT.md for implementation direction.
|
||||
|
||||
### 4. Research Phase (Optional)
|
||||
|
||||
For phases with unknowns, run discovery before planning:
|
||||
|
||||
| Level | When | Time | Scope |
|
||||
|-------|------|------|-------|
|
||||
| L0 | Pure internal work | Skip | None |
|
||||
| L1 | Quick verification | 2-5 min | Confirm assumptions |
|
||||
| L2 | Standard research | 15-30 min | Explore patterns |
|
||||
| L3 | Deep dive | 1+ hour | Novel domain |
|
||||
|
||||
```
|
||||
cw phase research <phase-id> --level 2
|
||||
```
|
||||
|
||||
Creates `{phase}-RESEARCH.md` with findings that inform planning.
|
||||
|
||||
### 5. Submit for Review
|
||||
|
||||
When Architect and user are satisfied:
|
||||
|
||||
```
|
||||
cw initiative submit <id>
|
||||
```
|
||||
|
||||
Status changes to `review`. Triggers notification for approval.
|
||||
|
||||
### 4. Approve Initiative
|
||||
|
||||
Human reviews the complete initiative:
|
||||
|
||||
```
|
||||
cw initiative approve <id>
|
||||
```
|
||||
|
||||
Status changes to `approved`. Now work plan can be created.
|
||||
|
||||
### 5. Create Work Plan
|
||||
|
||||
Architect (or user) breaks initiative into phases:
|
||||
|
||||
```
|
||||
cw initiative plan <id>
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `initiative_phases` records
|
||||
- Tasks linked to each phase via `initiative_id` + `phase_id`
|
||||
|
||||
Tasks are created in `open` status but won't be "ready" until their phase is approved.
|
||||
|
||||
### 6. Approve Phases (Rolling)
|
||||
|
||||
User reviews and approves phases one at a time:
|
||||
|
||||
```
|
||||
cw phase approve <phase-id>
|
||||
```
|
||||
|
||||
Approved phases make their tasks "ready" for agents. User can approve Phase 1, let agents work, then approve Phase 2 later.
|
||||
|
||||
### 7. Execute
|
||||
|
||||
Workers pull tasks via `cw task ready`. Tasks include:
|
||||
- Link to initiative for context
|
||||
- Link to phase for grouping
|
||||
- All normal task fields (dependencies, priority, etc.)
|
||||
|
||||
### 8. Verify Phase
|
||||
|
||||
After all tasks in a phase complete, the Verifier agent runs goal-backward verification:
|
||||
|
||||
```
|
||||
cw phase verify <phase-id>
|
||||
```
|
||||
|
||||
Verification checks:
|
||||
1. **Observable truths** — What users can observe when goal is achieved
|
||||
2. **Required artifacts** — Files that must exist (not stubs)
|
||||
3. **Required wiring** — Connections that must work
|
||||
4. **Anti-patterns** — TODOs, placeholders, empty returns
|
||||
|
||||
Creates `{phase}-VERIFICATION.md` with results. If gaps found, creates remediation tasks.
|
||||
|
||||
See [verification.md](verification.md) for detailed verification patterns.
|
||||
|
||||
### 9. User Acceptance Testing
|
||||
|
||||
After technical verification passes, run UAT:
|
||||
|
||||
```
|
||||
cw phase uat <phase-id>
|
||||
```
|
||||
|
||||
Walks user through testable deliverables:
|
||||
- "Can you log in with email and password?"
|
||||
- "Does the dashboard show your projects?"
|
||||
|
||||
Creates `{phase}-UAT.md` with results. If issues found, creates targeted fix plans.
|
||||
|
||||
### 10. Complete
|
||||
|
||||
When all tasks in all phases are closed AND verification passes:
|
||||
- Each phase auto-transitions to `completed`
|
||||
- Initiative auto-transitions to `completed`
|
||||
- Domain layer updated to reflect new state
|
||||
|
||||
---
|
||||
|
||||
## Phase Artifacts
|
||||
|
||||
Each phase produces artifacts during execution:
|
||||
|
||||
| Artifact | Created By | Purpose |
|
||||
|----------|------------|---------|
|
||||
| `{phase}-CONTEXT.md` | Architect (Discussion) | Locked implementation decisions |
|
||||
| `{phase}-RESEARCH.md` | Architect (Research) | Domain knowledge findings |
|
||||
| `{phase}-{N}-PLAN.md` | Architect (Planning) | Executable task plans |
|
||||
| `{phase}-{N}-SUMMARY.md` | Worker (Execution) | What actually happened |
|
||||
| `{phase}-VERIFICATION.md` | Verifier | Goal-backward verification |
|
||||
| `{phase}-UAT.md` | Verifier + User | User acceptance testing |
|
||||
|
||||
See [execution-artifacts.md](execution-artifacts.md) for artifact specifications.
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Initiative Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw initiative create <title>` | Create draft initiative |
|
||||
| `cw initiative list [--status STATUS]` | List initiatives |
|
||||
| `cw initiative show <id>` | Show initiative with page tree |
|
||||
| `cw initiative submit <id>` | Submit for review |
|
||||
| `cw initiative approve <id>` | Approve initiative |
|
||||
| `cw initiative reject <id> --reason "..."` | Reject initiative |
|
||||
| `cw initiative plan <id>` | Generate phased work plan |
|
||||
|
||||
### Page Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw page create <initiative-id> <title> --type TYPE` | Create page |
|
||||
| `cw page create <initiative-id> <title> --parent <page-id>` | Create subpage |
|
||||
| `cw page show <id>` | Show page content |
|
||||
| `cw page edit <id>` | Edit page (opens editor) |
|
||||
| `cw page list <initiative-id> [--type TYPE]` | List pages |
|
||||
| `cw page tree <initiative-id>` | Show page hierarchy |
|
||||
|
||||
### Phase Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw phase list <initiative-id>` | List phases |
|
||||
| `cw phase show <id>` | Show phase with tasks |
|
||||
| `cw phase discuss <id>` | Capture implementation decisions (creates CONTEXT.md) |
|
||||
| `cw phase research <id> [--level N]` | Run discovery (L0-L3, creates RESEARCH.md) |
|
||||
| `cw phase approve <id>` | Approve phase for execution |
|
||||
| `cw phase verify <id>` | Run goal-backward verification |
|
||||
| `cw phase uat <id>` | Run user acceptance testing |
|
||||
| `cw phase status <id>` | Check phase progress |
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Tasks Module
|
||||
|
||||
Tasks gain two new fields:
|
||||
- `initiative_id`: Links task to initiative (for context)
|
||||
- `phase_id`: Links task to phase (for grouping/approval)
|
||||
|
||||
The `ready_tasks` view should consider phase approval:
|
||||
|
||||
```sql
|
||||
CREATE VIEW ready_tasks AS
|
||||
SELECT t.* FROM tasks t
|
||||
LEFT JOIN initiative_phases p ON t.phase_id = p.id
|
||||
WHERE t.status = 'open'
|
||||
AND (t.phase_id IS NULL OR p.status IN ('approved', 'in_progress'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_dependencies d
|
||||
JOIN tasks dep ON d.depends_on = dep.id
|
||||
WHERE d.task_id = t.id
|
||||
AND d.type = 'blocks'
|
||||
AND dep.status != 'closed'
|
||||
)
|
||||
ORDER BY t.priority ASC, t.created_at ASC;
|
||||
```
|
||||
|
||||
### With Domain Layer
|
||||
|
||||
When initiative completes, its pages can feed into domain documentation:
|
||||
- Business rules → Domain business rules
|
||||
- Technical concepts → Architecture docs
|
||||
- New aggregates → Domain model updates
|
||||
|
||||
### With Orchestrator
|
||||
|
||||
Orchestrator can:
|
||||
- Trigger Architect agents for initiative iteration
|
||||
- Monitor phase completion and auto-advance initiative status
|
||||
- Coordinate approval notifications
|
||||
|
||||
### tRPC Procedures
|
||||
|
||||
```typescript
|
||||
// Suggested tRPC router shape
|
||||
initiative.create(input) // → Initiative
|
||||
initiative.list(filters) // → Initiative[]
|
||||
initiative.get(id) // → Initiative with pages
|
||||
initiative.submit(id) // → Initiative
|
||||
initiative.approve(id) // → Initiative
|
||||
initiative.reject(id, reason) // → Initiative
|
||||
initiative.plan(id) // → Phase[]
|
||||
|
||||
page.create(input) // → Page
|
||||
page.get(id) // → Page
|
||||
page.update(id, content) // → Page
|
||||
page.list(initiativeId, filters) // → Page[]
|
||||
page.tree(initiativeId) // → PageTree
|
||||
|
||||
phase.list(initiativeId) // → Phase[]
|
||||
phase.get(id) // → Phase with tasks
|
||||
phase.approve(id) // → Phase
|
||||
phase.status(id) // → PhaseStatus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Templates**: Pre-built page structures for common initiative types
|
||||
- **Cross-project initiatives**: Single initiative spanning multiple projects
|
||||
- **Versioning**: Track changes to initiative pages over time
|
||||
- **Approval workflows**: Multi-step approval with different approvers
|
||||
- **Auto-planning**: LLM generates work plan from initiative content
|
||||
64
docs/logging.md
Normal file
64
docs/logging.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Structured Logging
|
||||
|
||||
Codewalk District uses [pino](https://getpino.io/) for structured JSON logging on the backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **pino** writes structured JSON to **stderr** so CLI user output on stdout stays clean
|
||||
- **console.log** remains for CLI command handlers (user-facing output on stdout)
|
||||
- The `src/logging/` module (ProcessLogWriter/LogManager) is a separate concern — it captures per-agent process stdout/stderr to files
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `CW_LOG_LEVEL` | Log level override (`fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`) | `info` (production), `debug` (development) |
|
||||
| `CW_LOG_PRETTY` | Set to `1` for human-readable colorized output via pino-pretty | unset (JSON output) |
|
||||
|
||||
## Log Levels
|
||||
|
||||
| Level | Usage |
|
||||
|-------|-------|
|
||||
| `fatal` | Process will exit (uncaught exceptions, DB migration failure) |
|
||||
| `error` | Operation failed (agent crash, parse failure, clone failure) |
|
||||
| `warn` | Degraded (account exhausted, no accounts available, stale PID, reconcile marking crashed) |
|
||||
| `info` | State transitions (agent spawned/stopped/resumed, dispatch decision, server started, account selected/switched) |
|
||||
| `debug` | Implementation details (command being built, session ID extraction, worktree paths, schema selection) |
|
||||
|
||||
## Adding Logging to a New Module
|
||||
|
||||
```typescript
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('my-module');
|
||||
|
||||
// Use structured data as first arg, message as second
|
||||
log.info({ taskId, agentId }, 'task dispatched');
|
||||
log.error({ err: error }, 'operation failed');
|
||||
log.debug({ path, count }, 'processing items');
|
||||
```
|
||||
|
||||
## Module Names
|
||||
|
||||
| Module | Used in |
|
||||
|--------|---------|
|
||||
| `agent-manager` | `src/agent/manager.ts` |
|
||||
| `dispatch` | `src/dispatch/manager.ts` |
|
||||
| `http` | `src/server/index.ts` |
|
||||
| `server` | `src/cli/index.ts` (startup) |
|
||||
| `git` | `src/git/manager.ts`, `src/git/clone.ts`, `src/git/project-clones.ts` |
|
||||
| `db` | `src/db/ensure-schema.ts` |
|
||||
|
||||
## Testing
|
||||
|
||||
Logs are silenced in tests via `CW_LOG_LEVEL=silent` in `vitest.config.ts`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sh
|
||||
# Pretty logs during development
|
||||
CW_LOG_LEVEL=debug CW_LOG_PRETTY=1 cw --server
|
||||
|
||||
# JSON logs for production/piping
|
||||
cw --server 2>server.log
|
||||
```
|
||||
267
docs/model-profiles.md
Normal file
267
docs/model-profiles.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Model Profiles
|
||||
|
||||
Different agent roles have different needs. Model selection balances quality, cost, and latency.
|
||||
|
||||
## Profile Definitions
|
||||
|
||||
| Profile | Use Case | Cost | Quality |
|
||||
|---------|----------|------|---------|
|
||||
| **quality** | Critical decisions, architecture | Highest | Best |
|
||||
| **balanced** | Default for most work | Medium | Good |
|
||||
| **budget** | High-volume, low-risk tasks | Lowest | Acceptable |
|
||||
|
||||
---
|
||||
|
||||
## Agent Model Assignments
|
||||
|
||||
| Agent | Quality | Balanced (Default) | Budget |
|
||||
|-------|---------|-------------------|--------|
|
||||
| **Architect** | Opus | Opus | Sonnet |
|
||||
| **Worker** | Opus | Sonnet | Sonnet |
|
||||
| **Verifier** | Sonnet | Sonnet | Haiku |
|
||||
| **Orchestrator** | Sonnet | Sonnet | Haiku |
|
||||
| **Monitor** | Sonnet | Haiku | Haiku |
|
||||
| **Researcher** | Opus | Sonnet | Haiku |
|
||||
|
||||
---
|
||||
|
||||
## Rationale
|
||||
|
||||
### Architect (Planning) - Opus/Opus/Sonnet
|
||||
Planning has the highest impact on outcomes. A bad plan wastes all downstream execution. Invest in quality here.
|
||||
|
||||
**Quality profile:** Complex systems, novel domains, critical decisions
|
||||
**Balanced profile:** Standard feature work, established patterns
|
||||
**Budget profile:** Simple initiatives, well-documented domains
|
||||
|
||||
### Worker (Execution) - Opus/Sonnet/Sonnet
|
||||
The plan already contains reasoning. Execution is implementation, not decision-making.
|
||||
|
||||
**Quality profile:** Complex algorithms, security-critical code
|
||||
**Balanced profile:** Standard implementation work
|
||||
**Budget profile:** Simple tasks, boilerplate code
|
||||
|
||||
### Verifier (Validation) - Sonnet/Sonnet/Haiku
|
||||
Verification is structured checking against defined criteria. Less reasoning needed than planning.
|
||||
|
||||
**Quality profile:** Complex verification, subtle integration issues
|
||||
**Balanced profile:** Standard goal-backward verification
|
||||
**Budget profile:** Simple pass/fail checks
|
||||
|
||||
### Orchestrator (Coordination) - Sonnet/Sonnet/Haiku
|
||||
Orchestrator routes work, doesn't do heavy lifting. Needs reliability, not creativity.
|
||||
|
||||
**Quality profile:** Complex multi-agent coordination
|
||||
**Balanced profile:** Standard workflow management
|
||||
**Budget profile:** Simple task routing
|
||||
|
||||
### Monitor (Observation) - Sonnet/Haiku/Haiku
|
||||
Monitoring is pattern matching and threshold checking. Minimal reasoning required.
|
||||
|
||||
**Quality profile:** Complex health analysis
|
||||
**Balanced profile:** Standard monitoring
|
||||
**Budget profile:** Simple heartbeat checks
|
||||
|
||||
### Researcher (Discovery) - Opus/Sonnet/Haiku
|
||||
Research is read-only exploration. High volume, low modification risk.
|
||||
|
||||
**Quality profile:** Deep domain analysis
|
||||
**Balanced profile:** Standard codebase exploration
|
||||
**Budget profile:** Simple file lookups
|
||||
|
||||
---
|
||||
|
||||
## Profile Selection
|
||||
|
||||
### Per-Initiative Override
|
||||
|
||||
```yaml
|
||||
# In initiative config
|
||||
model_profile: quality # Override default balanced
|
||||
```
|
||||
|
||||
### Per-Agent Override
|
||||
|
||||
```yaml
|
||||
# In task assignment
|
||||
assigned_to: worker-123
|
||||
model_override: opus # This task needs Opus
|
||||
```
|
||||
|
||||
### Automatic Escalation
|
||||
|
||||
```yaml
|
||||
# When to auto-escalate
|
||||
escalation_triggers:
|
||||
- condition: "task.retry_count > 2"
|
||||
action: "escalate_model"
|
||||
- condition: "task.complexity == 'high'"
|
||||
action: "use_quality_profile"
|
||||
- condition: "deviation.rule == 4"
|
||||
action: "escalate_model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost Management
|
||||
|
||||
### Estimated Token Usage
|
||||
|
||||
| Agent | Avg Tokens/Task | Profile Impact |
|
||||
|-------|-----------------|----------------|
|
||||
| Architect | 50k-100k | 3x between budget/quality |
|
||||
| Worker | 20k-50k | 2x between budget/quality |
|
||||
| Verifier | 10k-30k | 1.5x between budget/quality |
|
||||
| Orchestrator | 5k-15k | 1.5x between budget/quality |
|
||||
|
||||
### Cost Optimization Strategies
|
||||
|
||||
1. **Right-size tasks:** Smaller tasks = less token usage
|
||||
2. **Use budget for volume:** Monitoring, simple checks
|
||||
3. **Reserve quality for impact:** Architecture, security
|
||||
4. **Profile per initiative:** Simple features use budget, complex use quality
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Profile
|
||||
|
||||
```json
|
||||
// .planning/config.json
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"model_overrides": {
|
||||
"architect": null,
|
||||
"worker": null,
|
||||
"verifier": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quality Profile
|
||||
|
||||
```json
|
||||
{
|
||||
"model_profile": "quality",
|
||||
"model_overrides": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Budget Profile
|
||||
|
||||
```json
|
||||
{
|
||||
"model_profile": "budget",
|
||||
"model_overrides": {
|
||||
"architect": "sonnet" // Keep architect at sonnet minimum
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mixed Profile
|
||||
|
||||
```json
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"model_overrides": {
|
||||
"architect": "opus", // Invest in planning
|
||||
"worker": "sonnet", // Standard execution
|
||||
"verifier": "haiku" // Budget verification
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Capabilities Reference
|
||||
|
||||
### Opus
|
||||
- **Strengths:** Complex reasoning, nuanced decisions, novel problems
|
||||
- **Best for:** Architecture, complex algorithms, security analysis
|
||||
- **Cost:** Highest
|
||||
|
||||
### Sonnet
|
||||
- **Strengths:** Good balance of reasoning and speed, reliable
|
||||
- **Best for:** Standard development, code generation, debugging
|
||||
- **Cost:** Medium
|
||||
|
||||
### Haiku
|
||||
- **Strengths:** Fast, cheap, good for structured tasks
|
||||
- **Best for:** Monitoring, simple checks, high-volume operations
|
||||
- **Cost:** Lowest
|
||||
|
||||
---
|
||||
|
||||
## Profile Switching
|
||||
|
||||
### CLI Command
|
||||
|
||||
```bash
|
||||
# Set profile for all future work
|
||||
cw config set model_profile quality
|
||||
|
||||
# Set profile for specific initiative
|
||||
cw initiative config <id> --model-profile budget
|
||||
|
||||
# Override for single task
|
||||
cw task update <id> --model-override opus
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```typescript
|
||||
// Set initiative profile
|
||||
await initiative.setConfig(id, { modelProfile: 'quality' });
|
||||
|
||||
// Override task model
|
||||
await task.update(id, { modelOverride: 'opus' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Model Usage
|
||||
|
||||
Track model usage for cost analysis:
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
tokens_input INTEGER,
|
||||
tokens_output INTEGER,
|
||||
task_id TEXT,
|
||||
initiative_id TEXT,
|
||||
created_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Usage by agent type
|
||||
SELECT agent_type, model, SUM(tokens_input + tokens_output) as total_tokens
|
||||
FROM model_usage
|
||||
GROUP BY agent_type, model;
|
||||
|
||||
-- Cost by initiative
|
||||
SELECT initiative_id,
|
||||
SUM(CASE WHEN model = 'opus' THEN tokens * 0.015
|
||||
WHEN model = 'sonnet' THEN tokens * 0.003
|
||||
WHEN model = 'haiku' THEN tokens * 0.0003 END) as estimated_cost
|
||||
FROM model_usage
|
||||
GROUP BY initiative_id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Starting Out
|
||||
Use **balanced** profile. It provides good quality at reasonable cost.
|
||||
|
||||
### High-Stakes Projects
|
||||
Use **quality** profile. The cost difference is negligible compared to getting it right.
|
||||
|
||||
### High-Volume Work
|
||||
Use **budget** profile with architect override to sonnet. Don't skimp on planning.
|
||||
|
||||
### Learning the System
|
||||
Use **quality** profile initially. See what good output looks like before optimizing for cost.
|
||||
402
docs/session-state.md
Normal file
402
docs/session-state.md
Normal file
@@ -0,0 +1,402 @@
|
||||
# Session State
|
||||
|
||||
Session state tracks position, decisions, and blockers across agent restarts. Unlike the Domain Layer (which tracks codebase state), session state tracks **execution state**.
|
||||
|
||||
## STATE.md
|
||||
|
||||
Every active initiative maintains a STATE.md file tracking execution progress:
|
||||
|
||||
```yaml
|
||||
# STATE.md
|
||||
initiative: init-abc123
|
||||
title: User Authentication
|
||||
|
||||
# Current Position
|
||||
position:
|
||||
phase: 2
|
||||
phase_name: "JWT Implementation"
|
||||
plan: 3
|
||||
plan_name: "Refresh Token Rotation"
|
||||
task: "Implement token rotation endpoint"
|
||||
wave: 1
|
||||
status: in_progress
|
||||
|
||||
# Progress Tracking
|
||||
progress:
|
||||
phases_total: 4
|
||||
phases_completed: 1
|
||||
current_phase_tasks: 8
|
||||
current_phase_completed: 5
|
||||
bar: "████████░░░░░░░░ 50%"
|
||||
|
||||
# Decisions Made
|
||||
decisions:
|
||||
- date: 2024-01-14
|
||||
context: "Token storage strategy"
|
||||
decision: "httpOnly cookie, not localStorage"
|
||||
reason: "XSS protection, automatic inclusion in requests"
|
||||
|
||||
- date: 2024-01-14
|
||||
context: "JWT library"
|
||||
decision: "jose over jsonwebtoken"
|
||||
reason: "Better TypeScript support, Web Crypto API"
|
||||
|
||||
- date: 2024-01-15
|
||||
context: "Refresh token lifetime"
|
||||
decision: "7 days"
|
||||
reason: "Balance between security and UX"
|
||||
|
||||
# Active Blockers
|
||||
blockers:
|
||||
- id: block-001
|
||||
description: "Waiting for OAuth credentials from client"
|
||||
blocked_since: 2024-01-15
|
||||
affects: ["Phase 3: OAuth Integration"]
|
||||
workaround: "Proceeding with email/password auth first"
|
||||
|
||||
# Session History
|
||||
sessions:
|
||||
- id: session-001
|
||||
started: 2024-01-14T09:00:00Z
|
||||
ended: 2024-01-14T17:00:00Z
|
||||
completed: ["Phase 1: Database Schema", "Phase 2 Tasks 1-3"]
|
||||
|
||||
- id: session-002
|
||||
started: 2024-01-15T09:00:00Z
|
||||
status: active
|
||||
working_on: "Phase 2, Task 4: Refresh token rotation"
|
||||
|
||||
# Next Action
|
||||
next_action: |
|
||||
Continue implementing refresh token rotation endpoint.
|
||||
After completion, run verification for Phase 2.
|
||||
If Phase 2 passes, move to Phase 3 (blocked pending OAuth creds).
|
||||
|
||||
# Context for Resume
|
||||
resume_context:
|
||||
files_modified_this_session:
|
||||
- src/api/auth/refresh.ts
|
||||
- src/middleware/auth.ts
|
||||
- db/migrations/002_refresh_tokens.sql
|
||||
|
||||
key_implementations:
|
||||
- "Refresh tokens stored in SQLite with expiry"
|
||||
- "Rotation creates new token, invalidates old"
|
||||
- "Token family tracking for reuse detection"
|
||||
|
||||
open_questions: []
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Updates
|
||||
|
||||
### When to Update STATE.md
|
||||
|
||||
| Event | Update |
|
||||
|-------|--------|
|
||||
| Task started | `position.task`, `position.status` |
|
||||
| Task completed | `progress.*`, `position` to next task |
|
||||
| Decision made | Add to `decisions` |
|
||||
| Blocker encountered | Add to `blockers` |
|
||||
| Blocker resolved | Remove from `blockers` |
|
||||
| Session start | Add to `sessions` |
|
||||
| Session end | Update session `ended`, `completed` |
|
||||
| Phase completed | `progress.phases_completed`, reset task counters |
|
||||
|
||||
### Atomic Updates
|
||||
|
||||
```typescript
|
||||
// Update position atomically
|
||||
await updateState({
|
||||
position: {
|
||||
phase: 2,
|
||||
plan: 3,
|
||||
task: "Implement token rotation",
|
||||
wave: 1,
|
||||
status: "in_progress"
|
||||
}
|
||||
});
|
||||
|
||||
// Add decision
|
||||
await addDecision({
|
||||
context: "Token storage",
|
||||
decision: "httpOnly cookie",
|
||||
reason: "XSS protection"
|
||||
});
|
||||
|
||||
// Record blocker
|
||||
await addBlocker({
|
||||
description: "Waiting for OAuth creds",
|
||||
affects: ["Phase 3"]
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resume Protocol
|
||||
|
||||
When resuming work:
|
||||
|
||||
### 1. Load STATE.md
|
||||
```
|
||||
Read STATE.md for initiative
|
||||
Extract: position, decisions, blockers, resume_context
|
||||
```
|
||||
|
||||
### 2. Load Relevant Context
|
||||
```
|
||||
If position.plan exists:
|
||||
Load {phase}-{plan}-PLAN.md
|
||||
Load prior SUMMARY.md files for this phase
|
||||
|
||||
If position.task exists:
|
||||
Find task in current plan
|
||||
Resume from that task
|
||||
```
|
||||
|
||||
### 3. Verify State
|
||||
```
|
||||
Check files_modified_this_session still exist
|
||||
Check implementations match key_implementations
|
||||
If mismatch: flag for review before proceeding
|
||||
```
|
||||
|
||||
### 4. Continue Execution
|
||||
```
|
||||
Display: "Resuming from Phase {N}, Plan {M}, Task: {name}"
|
||||
Display: decisions made (for context)
|
||||
Display: active blockers (for awareness)
|
||||
Continue with task execution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Tracking
|
||||
|
||||
Decisions are first-class citizens, not comments.
|
||||
|
||||
### What to Track
|
||||
|
||||
| Type | Example | Why Track |
|
||||
|------|---------|-----------|
|
||||
| Technology choice | "Using jose for JWT" | Prevents second-guessing |
|
||||
| Architecture decision | "Separate auth service" | Documents reasoning |
|
||||
| Trade-off resolution | "Speed over features" | Explains constraints |
|
||||
| User preference | "Dark mode default" | Preserves intent |
|
||||
| Constraint discovered | "API rate limited to 100/min" | Prevents repeated discovery |
|
||||
|
||||
### Decision Format
|
||||
|
||||
```yaml
|
||||
decisions:
|
||||
- date: 2024-01-15
|
||||
context: "Where the decision was needed"
|
||||
decision: "What was decided"
|
||||
reason: "Why this choice"
|
||||
alternatives_considered:
|
||||
- "Alternative A: rejected because..."
|
||||
- "Alternative B: rejected because..."
|
||||
reversible: true|false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blocker Management
|
||||
|
||||
### Blocker States
|
||||
|
||||
```
|
||||
[new] ──identify──▶ [active] ──resolve──▶ [resolved]
|
||||
│
|
||||
│ workaround
|
||||
▼
|
||||
[bypassed]
|
||||
```
|
||||
|
||||
### Blocker Format
|
||||
|
||||
```yaml
|
||||
blockers:
|
||||
- id: block-001
|
||||
status: active
|
||||
description: "Need production API keys"
|
||||
identified_at: 2024-01-15T10:00:00Z
|
||||
affects:
|
||||
- "Phase 4: Production deployment"
|
||||
- "Phase 5: Monitoring setup"
|
||||
blocked_tasks:
|
||||
- task-xyz: "Configure production environment"
|
||||
workaround: null
|
||||
resolution: null
|
||||
|
||||
- id: block-002
|
||||
status: bypassed
|
||||
description: "Design mockups not ready"
|
||||
identified_at: 2024-01-14T09:00:00Z
|
||||
affects: ["UI implementation"]
|
||||
workaround: "Using placeholder styles, will refine later"
|
||||
workaround_tasks:
|
||||
- task-abc: "Apply final styles when mockups ready"
|
||||
```
|
||||
|
||||
### Blocker Impact on Execution
|
||||
|
||||
1. **Task Blocking:** Task marked `blocked` in tasks table
|
||||
2. **Phase Blocking:** If all remaining tasks blocked, phase paused
|
||||
3. **Initiative Blocking:** If all phases blocked, escalate to user
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
Track work sessions for debugging and handoffs:
|
||||
|
||||
```yaml
|
||||
sessions:
|
||||
- id: session-001
|
||||
agent: worker-abc
|
||||
started: 2024-01-14T09:00:00Z
|
||||
ended: 2024-01-14T12:30:00Z
|
||||
context_usage: "45%"
|
||||
completed:
|
||||
- "Phase 1, Plan 1: Database setup"
|
||||
- "Phase 1, Plan 2: User model"
|
||||
notes: "Clean execution, no issues"
|
||||
|
||||
- id: session-002
|
||||
agent: worker-def
|
||||
started: 2024-01-14T13:00:00Z
|
||||
ended: 2024-01-14T17:00:00Z
|
||||
context_usage: "62%"
|
||||
completed:
|
||||
- "Phase 1, Plan 3: Auth endpoints"
|
||||
issues:
|
||||
- "Context exceeded 50%, quality may have degraded"
|
||||
- "Encountered blocker: missing env vars"
|
||||
handoff_reason: "Context limit reached"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Options
|
||||
|
||||
### SQLite (Recommended for Codewalk)
|
||||
|
||||
```sql
|
||||
CREATE TABLE initiative_state (
|
||||
initiative_id TEXT PRIMARY KEY REFERENCES initiatives(id),
|
||||
current_phase INTEGER,
|
||||
current_plan INTEGER,
|
||||
current_task TEXT,
|
||||
current_wave INTEGER,
|
||||
status TEXT,
|
||||
progress_json TEXT,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE initiative_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiative_id TEXT REFERENCES initiatives(id),
|
||||
date INTEGER,
|
||||
context TEXT,
|
||||
decision TEXT,
|
||||
reason TEXT,
|
||||
alternatives_json TEXT,
|
||||
reversible BOOLEAN
|
||||
);
|
||||
|
||||
CREATE TABLE initiative_blockers (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiative_id TEXT REFERENCES initiatives(id),
|
||||
status TEXT CHECK (status IN ('active', 'bypassed', 'resolved')),
|
||||
description TEXT,
|
||||
identified_at INTEGER,
|
||||
affects_json TEXT,
|
||||
workaround TEXT,
|
||||
resolution TEXT,
|
||||
resolved_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE session_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
initiative_id TEXT REFERENCES initiatives(id),
|
||||
agent_id TEXT,
|
||||
started_at INTEGER,
|
||||
ended_at INTEGER,
|
||||
context_usage REAL,
|
||||
completed_json TEXT,
|
||||
issues_json TEXT,
|
||||
handoff_reason TEXT
|
||||
);
|
||||
```
|
||||
|
||||
### File-Based (Alternative)
|
||||
|
||||
```
|
||||
.planning/
|
||||
├── STATE.md # Current state
|
||||
├── decisions/
|
||||
│ └── 2024-01-15-jwt-library.md
|
||||
├── blockers/
|
||||
│ └── block-001-oauth-creds.md
|
||||
└── sessions/
|
||||
├── session-001.md
|
||||
└── session-002.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Agents
|
||||
|
||||
### Worker
|
||||
- Reads STATE.md at start
|
||||
- Updates position on task transitions
|
||||
- Adds deviations to session notes
|
||||
- Updates progress counters
|
||||
|
||||
### Architect
|
||||
- Creates initial STATE.md when planning
|
||||
- Sets up phase/plan structure
|
||||
- Documents initial decisions
|
||||
|
||||
### Orchestrator
|
||||
- Monitors blocker status
|
||||
- Triggers resume when blockers resolve
|
||||
- Coordinates session handoffs
|
||||
|
||||
### Verifier
|
||||
- Reads decisions for verification context
|
||||
- Updates state with verification results
|
||||
- Flags issues for resolution
|
||||
|
||||
---
|
||||
|
||||
## Example: Resume After Crash
|
||||
|
||||
```
|
||||
1. Agent crashes mid-task
|
||||
|
||||
2. Supervisor detects stale assignment
|
||||
- Task assigned_at > 30min ago
|
||||
- No progress updates
|
||||
|
||||
3. Supervisor resets task
|
||||
- Status back to 'open'
|
||||
- Clear assigned_to
|
||||
|
||||
4. New agent picks up task
|
||||
- Reads STATE.md
|
||||
- Sees: "Last working on: Refresh token rotation"
|
||||
- Loads relevant PLAN.md
|
||||
- Resumes execution
|
||||
|
||||
5. STATE.md shows continuity
|
||||
sessions:
|
||||
- id: session-003
|
||||
status: crashed
|
||||
notes: "Agent unresponsive, task reset"
|
||||
- id: session-004
|
||||
status: active
|
||||
notes: "Resuming from session-003 crash"
|
||||
```
|
||||
309
docs/task-granularity.md
Normal file
309
docs/task-granularity.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Task Granularity Standards
|
||||
|
||||
A task must be specific enough for execution without interpretation. Vague tasks cause agents to guess, leading to inconsistent results and rework.
|
||||
|
||||
## The Granularity Test
|
||||
|
||||
Ask: **Can an agent execute this task without making assumptions?**
|
||||
|
||||
If the answer requires "it depends" or "probably means", the task is too vague.
|
||||
|
||||
---
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Too Vague | Just Right |
|
||||
|-----------|------------|
|
||||
| "Add authentication" | "Add JWT auth with refresh rotation using jose library, store in httpOnly cookie, 15min access / 7day refresh" |
|
||||
| "Create the API" | "Create POST /api/projects accepting {name, description}, validates name length 3-50 chars, returns 201 with project object" |
|
||||
| "Style the dashboard" | "Add Tailwind classes to Dashboard.tsx: grid layout (3 cols on lg, 1 on mobile), card shadows, hover states on action buttons" |
|
||||
| "Handle errors" | "Wrap API calls in try/catch, return {error: string} on 4xx/5xx, show toast via sonner on client" |
|
||||
| "Add form validation" | "Add Zod schema to CreateProjectForm: name (3-50 chars, alphanumeric), description (optional, max 500 chars), show inline errors" |
|
||||
| "Improve performance" | "Add React.memo to ProjectCard, useMemo for filtered list in Dashboard, lazy load ProjectDetails route" |
|
||||
| "Fix the login bug" | "Fix login redirect loop: after successful login in auth.ts:45, redirect to stored returnUrl instead of always '/' " |
|
||||
| "Set up the database" | "Create SQLite database at data/cw.db with migrations in db/migrations/, run via 'cw db migrate'" |
|
||||
|
||||
---
|
||||
|
||||
## Required Task Components
|
||||
|
||||
Every task MUST include:
|
||||
|
||||
### 1. Files
|
||||
Exact paths that will be created or modified.
|
||||
|
||||
```yaml
|
||||
files:
|
||||
- src/components/Chat.tsx # create
|
||||
- src/hooks/useChat.ts # create
|
||||
- src/api/messages.ts # modify
|
||||
```
|
||||
|
||||
### 2. Action
|
||||
What to do, what to avoid, and WHY.
|
||||
|
||||
```yaml
|
||||
action: |
|
||||
Create Chat component with:
|
||||
- Message list (virtualized for performance)
|
||||
- Input field with send button
|
||||
- Auto-scroll to bottom on new message
|
||||
|
||||
DO NOT:
|
||||
- Implement WebSocket (separate task)
|
||||
- Add typing indicators (Phase 2)
|
||||
|
||||
WHY: Core chat UI needed before real-time features
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
Command or check to prove completion.
|
||||
|
||||
```yaml
|
||||
verify:
|
||||
- command: "npm run typecheck"
|
||||
expect: "No type errors"
|
||||
- command: "npm run test -- Chat.test.tsx"
|
||||
expect: "Tests pass"
|
||||
- manual: "Navigate to /chat, see empty message list and input"
|
||||
```
|
||||
|
||||
### 4. Done
|
||||
Measurable acceptance criteria.
|
||||
|
||||
```yaml
|
||||
done:
|
||||
- "Chat component renders without errors"
|
||||
- "Input accepts text and clears on submit"
|
||||
- "Messages display in chronological order"
|
||||
- "Tests cover send and display functionality"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Types
|
||||
|
||||
### Type: auto
|
||||
Agent executes autonomously.
|
||||
|
||||
```yaml
|
||||
type: auto
|
||||
files: [src/components/Button.tsx]
|
||||
action: "Create Button component with primary/secondary variants using Tailwind"
|
||||
verify: "npm run typecheck && npm run test"
|
||||
done: "Button renders with correct styles for each variant"
|
||||
```
|
||||
|
||||
### Type: checkpoint:human-verify
|
||||
Agent completes, human confirms.
|
||||
|
||||
```yaml
|
||||
type: checkpoint:human-verify
|
||||
files: [src/pages/Dashboard.tsx]
|
||||
action: "Implement dashboard layout with project cards"
|
||||
verify: "Navigate to /dashboard after login"
|
||||
prompt: "Does the dashboard match the design mockup?"
|
||||
done: "User confirms layout is correct"
|
||||
```
|
||||
|
||||
### Type: checkpoint:decision
|
||||
Human makes choice that affects implementation.
|
||||
|
||||
```yaml
|
||||
type: checkpoint:decision
|
||||
prompt: "Which chart library should we use?"
|
||||
options:
|
||||
- recharts: "React-native, good for simple charts"
|
||||
- d3: "More powerful, steeper learning curve"
|
||||
- chart.js: "Lightweight, canvas-based"
|
||||
affects: "All subsequent charting tasks"
|
||||
```
|
||||
|
||||
### Type: checkpoint:human-action
|
||||
Unavoidable manual step.
|
||||
|
||||
```yaml
|
||||
type: checkpoint:human-action
|
||||
prompt: "Please click the verification link sent to your email"
|
||||
reason: "Cannot automate email client interaction"
|
||||
continue_after: "User confirms email verified"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Time Estimation
|
||||
|
||||
Tasks should fit within context budgets:
|
||||
|
||||
| Complexity | Context % | Wall Time | Example |
|
||||
|------------|-----------|-----------|---------|
|
||||
| Trivial | 5-10% | 2-5 min | Add a CSS class |
|
||||
| Simple | 10-20% | 5-15 min | Add form field |
|
||||
| Medium | 20-35% | 15-30 min | Create API endpoint |
|
||||
| Complex | 35-50% | 30-60 min | Implement auth flow |
|
||||
| Too Large | >50% | - | **SPLIT REQUIRED** |
|
||||
|
||||
---
|
||||
|
||||
## Splitting Large Tasks
|
||||
|
||||
When a task exceeds 50% context estimate, decompose:
|
||||
|
||||
### Before (Too Large)
|
||||
```yaml
|
||||
title: "Implement user authentication"
|
||||
# This is 3+ hours of work, dozens of decisions
|
||||
```
|
||||
|
||||
### After (Properly Decomposed)
|
||||
```yaml
|
||||
tasks:
|
||||
- title: "Create users table with password hash"
|
||||
files: [db/migrations/001_users.sql]
|
||||
|
||||
- title: "Add signup endpoint with Zod validation"
|
||||
files: [src/api/auth/signup.ts]
|
||||
depends_on: [users-table]
|
||||
|
||||
- title: "Add login endpoint with JWT generation"
|
||||
files: [src/api/auth/login.ts]
|
||||
depends_on: [users-table]
|
||||
|
||||
- title: "Create auth middleware for protected routes"
|
||||
files: [src/middleware/auth.ts]
|
||||
depends_on: [login-endpoint]
|
||||
|
||||
- title: "Add refresh token rotation"
|
||||
files: [src/api/auth/refresh.ts, db/migrations/002_refresh_tokens.sql]
|
||||
depends_on: [auth-middleware]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Vague Verbs
|
||||
**Bad:** "Improve", "Enhance", "Update", "Fix" (without specifics)
|
||||
**Good:** "Add X", "Change Y to Z", "Remove W"
|
||||
|
||||
### Missing Constraints
|
||||
**Bad:** "Add validation"
|
||||
**Good:** "Add Zod validation: email format, password 8+ chars with number"
|
||||
|
||||
### Implied Knowledge
|
||||
**Bad:** "Handle the edge cases"
|
||||
**Good:** "Handle: empty input (show error), network failure (retry 3x), duplicate email (show message)"
|
||||
|
||||
### Compound Tasks
|
||||
**Bad:** "Set up auth and create the user management pages"
|
||||
**Good:** Two separate tasks with dependency
|
||||
|
||||
### No Success Criteria
|
||||
**Bad:** "Make it work"
|
||||
**Good:** "Tests pass, no TypeScript errors, manual verification of happy path"
|
||||
|
||||
---
|
||||
|
||||
## Examples by Domain
|
||||
|
||||
### API Endpoint
|
||||
|
||||
```yaml
|
||||
title: "Create POST /api/projects endpoint"
|
||||
files:
|
||||
- src/api/projects/create.ts
|
||||
- src/api/projects/schema.ts
|
||||
|
||||
action: |
|
||||
Create endpoint accepting:
|
||||
- name: string (3-50 chars, required)
|
||||
- description: string (max 500 chars, optional)
|
||||
|
||||
Returns:
|
||||
- 201: { id, name, description, createdAt }
|
||||
- 400: { error: "validation message" }
|
||||
- 401: { error: "Unauthorized" }
|
||||
|
||||
Use Zod for validation, drizzle for DB insert.
|
||||
|
||||
verify:
|
||||
- "npm run test -- projects.test.ts"
|
||||
- "curl -X POST /api/projects -d '{\"name\": \"Test\"}' returns 201"
|
||||
|
||||
done:
|
||||
- "Endpoint creates project in database"
|
||||
- "Validation rejects invalid input with clear messages"
|
||||
- "Auth middleware blocks unauthenticated requests"
|
||||
```
|
||||
|
||||
### React Component
|
||||
|
||||
```yaml
|
||||
title: "Create ProjectCard component"
|
||||
files:
|
||||
- src/components/ProjectCard.tsx
|
||||
- src/components/ProjectCard.test.tsx
|
||||
|
||||
action: |
|
||||
Create card displaying:
|
||||
- Project name (truncate at 30 chars)
|
||||
- Description preview (2 lines max)
|
||||
- Created date (relative: "2 days ago")
|
||||
- Status badge (active/archived)
|
||||
|
||||
Props: { project: Project, onClick: () => void }
|
||||
Use Tailwind: rounded-lg, shadow-sm, hover:shadow-md
|
||||
|
||||
verify:
|
||||
- "npm run typecheck"
|
||||
- "npm run test -- ProjectCard"
|
||||
- "Storybook renders all variants"
|
||||
|
||||
done:
|
||||
- "Card renders with all project fields"
|
||||
- "Truncation works for long names"
|
||||
- "Hover state visible"
|
||||
- "Click handler fires"
|
||||
```
|
||||
|
||||
### Database Migration
|
||||
|
||||
```yaml
|
||||
title: "Create projects table"
|
||||
files:
|
||||
- db/migrations/003_projects.sql
|
||||
- src/db/schema/projects.ts
|
||||
|
||||
action: |
|
||||
Create table:
|
||||
- id: TEXT PRIMARY KEY (uuid)
|
||||
- user_id: TEXT NOT NULL REFERENCES users(id)
|
||||
- name: TEXT NOT NULL
|
||||
- description: TEXT
|
||||
- status: TEXT DEFAULT 'active' CHECK (IN 'active', 'archived')
|
||||
- created_at: INTEGER DEFAULT unixepoch()
|
||||
- updated_at: INTEGER DEFAULT unixepoch()
|
||||
|
||||
Indexes: user_id, status, created_at DESC
|
||||
|
||||
verify:
|
||||
- "cw db migrate runs without error"
|
||||
- "sqlite3 data/cw.db '.schema projects' shows correct schema"
|
||||
|
||||
done:
|
||||
- "Migration applies cleanly"
|
||||
- "Drizzle schema matches SQL"
|
||||
- "Indexes created"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Creating Task
|
||||
|
||||
- [ ] Can an agent execute this without asking questions?
|
||||
- [ ] Are all files listed explicitly?
|
||||
- [ ] Is the action specific (not "improve" or "handle")?
|
||||
- [ ] Is there a concrete verify step?
|
||||
- [ ] Are done criteria measurable?
|
||||
- [ ] Does estimated context fit under 50%?
|
||||
- [ ] Are there no compound actions (split if needed)?
|
||||
331
docs/tasks.md
Normal file
331
docs/tasks.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Tasks Module
|
||||
|
||||
Beads-inspired task management optimized for multi-agent coordination. Unlike beads (Git-distributed JSONL), this uses centralized SQLite for simplicity since all agents share the same workspace.
|
||||
|
||||
## Design Rationale
|
||||
|
||||
### Why Not Just Use Beads?
|
||||
|
||||
Beads solves a different problem: distributed task tracking across forked repos with zero coordination. We don't need that:
|
||||
|
||||
- All Workers operate in the same workspace under one `cw` server
|
||||
- SQLite is the single source of truth
|
||||
- tRPC exposes task queries directly to agents and dashboard
|
||||
- No merge conflicts, no Git overhead
|
||||
|
||||
### Core Agent Problem Solved
|
||||
|
||||
Agents need to answer: **"What should I work on next?"**
|
||||
|
||||
The `ready` query solves this: tasks that are `open` with all dependencies `closed`. Combined with priority ordering, agents can self-coordinate without human intervention.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Task Entity
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | TEXT | Primary key. Hash-based (e.g., `tsk-a1b2c3`) or UUID |
|
||||
| `parent_id` | TEXT | Optional. References parent task for hierarchies |
|
||||
| `initiative_id` | TEXT | Optional. Links to Initiatives module |
|
||||
| `phase_id` | TEXT | Optional. Links to initiative phase (for grouped approval) |
|
||||
| `project_id` | TEXT | Optional. Scopes task to a project |
|
||||
| `title` | TEXT | Required. Short task name |
|
||||
| `description` | TEXT | Optional. Markdown-formatted details |
|
||||
| `type` | TEXT | `task` (default), `epic`, `subtask` |
|
||||
| `status` | TEXT | `open`, `in_progress`, `blocked`, `closed` |
|
||||
| `priority` | INTEGER | 0=critical, 1=high, 2=normal (default), 3=low |
|
||||
| `assigned_to` | TEXT | Agent/worker ID currently working on this |
|
||||
| `assigned_at` | INTEGER | Unix timestamp when assigned |
|
||||
| `metadata` | TEXT | JSON blob for extensibility |
|
||||
| `created_at` | INTEGER | Unix timestamp |
|
||||
| `updated_at` | INTEGER | Unix timestamp |
|
||||
| `closed_at` | INTEGER | Unix timestamp when closed |
|
||||
| `closed_reason` | TEXT | Why/how the task was completed |
|
||||
|
||||
### Task Dependencies
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `task_id` | TEXT | The task that is blocked |
|
||||
| `depends_on` | TEXT | The task that must complete first |
|
||||
| `type` | TEXT | `blocks` (default), `related` |
|
||||
|
||||
### Task History
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | INTEGER | Auto-increment primary key |
|
||||
| `task_id` | TEXT | The task that changed |
|
||||
| `field` | TEXT | Which field changed |
|
||||
| `old_value` | TEXT | Previous value |
|
||||
| `new_value` | TEXT | New value |
|
||||
| `changed_by` | TEXT | Agent/user ID |
|
||||
| `changed_at` | INTEGER | Unix timestamp |
|
||||
|
||||
---
|
||||
|
||||
## SQLite Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES tasks(id),
|
||||
initiative_id TEXT,
|
||||
phase_id TEXT,
|
||||
project_id TEXT,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'task' CHECK (type IN ('task', 'epic', 'subtask')),
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'blocked', 'closed')),
|
||||
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority BETWEEN 0 AND 3),
|
||||
|
||||
assigned_to TEXT,
|
||||
assigned_at INTEGER,
|
||||
|
||||
metadata TEXT,
|
||||
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
closed_at INTEGER,
|
||||
closed_reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE task_dependencies (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
depends_on TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'blocks' CHECK (type IN ('blocks', 'related')),
|
||||
PRIMARY KEY (task_id, depends_on),
|
||||
CHECK (task_id != depends_on)
|
||||
);
|
||||
|
||||
CREATE TABLE task_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX idx_tasks_priority ON tasks(priority);
|
||||
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
|
||||
CREATE INDEX idx_tasks_project ON tasks(project_id);
|
||||
CREATE INDEX idx_tasks_initiative ON tasks(initiative_id);
|
||||
CREATE INDEX idx_tasks_phase ON tasks(phase_id);
|
||||
CREATE INDEX idx_task_history_task ON task_history(task_id);
|
||||
|
||||
-- The critical view for agent work discovery
|
||||
-- Tasks are ready when: open, no blocking deps, and phase approved (if linked)
|
||||
CREATE VIEW ready_tasks AS
|
||||
SELECT t.* FROM tasks t
|
||||
LEFT JOIN initiative_phases p ON t.phase_id = p.id
|
||||
WHERE t.status = 'open'
|
||||
AND (t.phase_id IS NULL OR p.status IN ('approved', 'in_progress'))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_dependencies d
|
||||
JOIN tasks dep ON d.depends_on = dep.id
|
||||
WHERE d.task_id = t.id
|
||||
AND d.type = 'blocks'
|
||||
AND dep.status != 'closed'
|
||||
)
|
||||
ORDER BY t.priority ASC, t.created_at ASC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status Workflow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
[open] ──claim──▶ [in_progress] ──done──▶ [closed]
|
||||
│ │
|
||||
│ │ blocked
|
||||
│ ▼
|
||||
└───────────── [blocked] ◀─────unblock───┘
|
||||
```
|
||||
|
||||
| Transition | Trigger | Notes |
|
||||
|------------|---------|-------|
|
||||
| `open` → `in_progress` | Agent claims task | Sets `assigned_to`, `assigned_at` |
|
||||
| `in_progress` → `closed` | Work completed | Sets `closed_at`, `closed_reason` |
|
||||
| `in_progress` → `blocked` | External dependency | Manual or auto-detected |
|
||||
| `blocked` → `open` | Blocker resolved | Clears assignment |
|
||||
| `open` → `closed` | Cancelled/won't do | Direct close without work |
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
All commands under `cw task` subcommand.
|
||||
|
||||
### Core Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw task ready` | List tasks ready for work (open + no blockers) |
|
||||
| `cw task list [--status STATUS] [--project ID]` | List tasks with filters |
|
||||
| `cw task show <id>` | Show task details + history |
|
||||
| `cw task create <title> [-p PRIORITY] [-d DESC]` | Create new task |
|
||||
| `cw task update <id> [--status STATUS] [--priority P]` | Update task fields |
|
||||
| `cw task close <id> [--reason REASON]` | Mark task complete |
|
||||
|
||||
### Dependency Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw task dep add <task> <depends-on>` | Task blocked by another |
|
||||
| `cw task dep rm <task> <depends-on>` | Remove dependency |
|
||||
| `cw task dep tree <id>` | Show dependency graph |
|
||||
|
||||
### Assignment Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cw task assign <id> <agent>` | Assign task to agent |
|
||||
| `cw task unassign <id>` | Release task |
|
||||
| `cw task mine` | List tasks assigned to current agent |
|
||||
|
||||
### Output Flags (global)
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--json` | Output as JSON (for agent consumption) |
|
||||
| `--quiet` | Minimal output (just IDs) |
|
||||
|
||||
---
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
Standard loop for Workers:
|
||||
|
||||
```
|
||||
1. cw task ready --json
|
||||
2. Pick highest priority task from result
|
||||
3. cw task update <id> --status in_progress
|
||||
4. Do the work
|
||||
5. cw task close <id> --reason "Implemented X"
|
||||
6. Loop to step 1
|
||||
```
|
||||
|
||||
If `cw task ready` returns empty, the agent's work is done.
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Initiatives
|
||||
- Tasks can link to an initiative via `initiative_id`
|
||||
- When initiative is approved, tasks are generated from its technical concept
|
||||
- Closing all tasks for an initiative signals initiative completion
|
||||
|
||||
### With Orchestrator
|
||||
- Orchestrator queries `ready_tasks` view to dispatch work
|
||||
- Assignment tracked to prevent double-dispatch
|
||||
- Orchestrator can bulk-create tasks from job definitions
|
||||
|
||||
### With Workers
|
||||
- Workers claim tasks via `cw task update --status in_progress`
|
||||
- Worker ID stored in `assigned_to`
|
||||
- On worker crash, Supervisor can detect stale assignments and reset
|
||||
|
||||
### tRPC Procedures
|
||||
|
||||
```typescript
|
||||
// Suggested tRPC router shape
|
||||
task.list(filters) // → Task[]
|
||||
task.ready(projectId?) // → Task[]
|
||||
task.get(id) // → Task | null
|
||||
task.create(input) // → Task
|
||||
task.update(id, input) // → Task
|
||||
task.close(id, reason) // → Task
|
||||
task.assign(id, agent) // → Task
|
||||
task.history(id) // → TaskHistory[]
|
||||
task.depAdd(id, dep) // → void
|
||||
task.depRemove(id, dep) // → void
|
||||
task.depTree(id) // → DependencyTree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Granularity Standards
|
||||
|
||||
A task must be specific enough for execution without interpretation. Vague tasks cause agents to guess, leading to inconsistent results.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Too Vague | Just Right |
|
||||
|-----------|------------|
|
||||
| "Add authentication" | "Add JWT auth with refresh rotation using jose, httpOnly cookie, 15min access / 7day refresh" |
|
||||
| "Create the API" | "Create POST /api/projects accepting {name, description}, validates name 3-50 chars, returns 201" |
|
||||
| "Handle errors" | "Wrap API calls in try/catch, return {error: string} on 4xx/5xx, show toast via sonner" |
|
||||
|
||||
### Required Task Components
|
||||
|
||||
Every task MUST include:
|
||||
|
||||
1. **files** — Exact paths modified/created
|
||||
2. **action** — What to do, what to avoid, WHY
|
||||
3. **verify** — Command or check to prove completion
|
||||
4. **done** — Measurable acceptance criteria
|
||||
|
||||
See [task-granularity.md](task-granularity.md) for comprehensive examples and anti-patterns.
|
||||
|
||||
### Context Budget
|
||||
|
||||
Tasks are sized to fit agent context budgets:
|
||||
|
||||
| Complexity | Context % | Example |
|
||||
|------------|-----------|---------|
|
||||
| Simple | 10-20% | Add form field |
|
||||
| Medium | 20-35% | Create API endpoint |
|
||||
| Complex | 35-50% | Implement auth flow |
|
||||
| Too Large | >50% | **SPLIT REQUIRED** |
|
||||
|
||||
See [context-engineering.md](context-engineering.md) for context management rules.
|
||||
|
||||
---
|
||||
|
||||
## Deviation Handling
|
||||
|
||||
When Workers encounter unexpected issues during execution, they follow deviation rules:
|
||||
|
||||
| Rule | Action | Permission |
|
||||
|------|--------|------------|
|
||||
| Rule 1: Bug fixes | Auto-fix | None needed |
|
||||
| Rule 2: Missing critical (validation, auth) | Auto-add | None needed |
|
||||
| Rule 3: Blocking issues (deps, imports) | Auto-fix | None needed |
|
||||
| Rule 4: Architectural changes | ASK | Required |
|
||||
|
||||
See [deviation-rules.md](deviation-rules.md) for detailed guidance.
|
||||
|
||||
---
|
||||
|
||||
## Execution Artifacts
|
||||
|
||||
Task execution produces artifacts:
|
||||
|
||||
| Artifact | Purpose |
|
||||
|----------|---------|
|
||||
| Commits | Per-task atomic commits |
|
||||
| SUMMARY.md | Record of what happened |
|
||||
| STATE.md updates | Position tracking |
|
||||
|
||||
See [execution-artifacts.md](execution-artifacts.md) for artifact specifications.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Compaction**: Summarize old closed tasks to reduce DB size (beads does this with LLM)
|
||||
- **Labels/tags**: Additional categorization beyond type
|
||||
- **Time tracking**: Estimated vs actual time for capacity planning
|
||||
- **Recurring tasks**: Templates that spawn new tasks on schedule
|
||||
322
docs/verification.md
Normal file
322
docs/verification.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Goal-Backward Verification
|
||||
|
||||
Verification confirms that **goals are achieved**, not merely that **tasks were completed**. A completed task "create chat component" does not guarantee the goal "working chat interface" is met.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Task completion ≠ Goal achievement**
|
||||
|
||||
Tasks are implementation steps. Goals are user outcomes. Verification bridges the gap by checking observable outcomes, not just checklist items.
|
||||
|
||||
---
|
||||
|
||||
## Verification Levels
|
||||
|
||||
### Level 1: Existence Check
|
||||
Does the artifact exist?
|
||||
|
||||
```
|
||||
✓ File exists at expected path
|
||||
✓ Component is exported
|
||||
✓ Route is registered
|
||||
```
|
||||
|
||||
### Level 2: Substance Check
|
||||
Is the artifact substantive (not a stub)?
|
||||
|
||||
```
|
||||
✓ Function has implementation (not just return null)
|
||||
✓ Component renders content (not empty div)
|
||||
✓ API returns meaningful response (not placeholder)
|
||||
```
|
||||
|
||||
### Level 3: Wiring Check
|
||||
Is the artifact connected to the system?
|
||||
|
||||
```
|
||||
✓ Component is rendered somewhere
|
||||
✓ API endpoint is called by client
|
||||
✓ Event handler is attached
|
||||
✓ Database query is executed
|
||||
```
|
||||
|
||||
**All three levels must pass for verification success.**
|
||||
|
||||
---
|
||||
|
||||
## Must-Have Derivation
|
||||
|
||||
Before verification, derive what "done" means from the goal:
|
||||
|
||||
### 1. Observable Truths (3-7 user perspectives)
|
||||
What can a user observe when the goal is achieved?
|
||||
|
||||
```yaml
|
||||
observable_truths:
|
||||
- "User can click 'Send' and message appears in chat"
|
||||
- "Messages persist after page refresh"
|
||||
- "New messages appear without page reload"
|
||||
- "User sees typing indicator when other party types"
|
||||
```
|
||||
|
||||
### 2. Required Artifacts
|
||||
What files MUST exist?
|
||||
|
||||
```yaml
|
||||
required_artifacts:
|
||||
- path: src/components/Chat.tsx
|
||||
check: "Exports Chat component"
|
||||
- path: src/api/messages.ts
|
||||
check: "Exports sendMessage, getMessages"
|
||||
- path: src/hooks/useChat.ts
|
||||
check: "Exports useChat hook"
|
||||
```
|
||||
|
||||
### 3. Required Wiring
|
||||
What connections MUST work?
|
||||
|
||||
```yaml
|
||||
required_wiring:
|
||||
- from: Chat.tsx
|
||||
to: useChat.ts
|
||||
check: "Component calls hook"
|
||||
- from: useChat.ts
|
||||
to: messages.ts
|
||||
check: "Hook calls API"
|
||||
- from: messages.ts
|
||||
to: database
|
||||
check: "API persists to DB"
|
||||
```
|
||||
|
||||
### 4. Key Links (Where Stubs Hide)
|
||||
What integration points commonly fail?
|
||||
|
||||
```yaml
|
||||
key_links:
|
||||
- "Form onSubmit → API call (not just console.log)"
|
||||
- "WebSocket connection → message handler"
|
||||
- "API response → state update → render"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Process
|
||||
|
||||
### Phase Verification
|
||||
|
||||
After all tasks in a phase complete:
|
||||
|
||||
```
|
||||
1. Load must-haves (from phase goal or PLAN frontmatter)
|
||||
2. For each observable truth:
|
||||
a. Level 1: Does the relevant code exist?
|
||||
b. Level 2: Is it substantive?
|
||||
c. Level 3: Is it wired?
|
||||
3. For each required artifact:
|
||||
a. Verify file exists
|
||||
b. Verify not a stub
|
||||
c. Verify it's imported/used
|
||||
4. For each key link:
|
||||
a. Trace the connection
|
||||
b. Verify data flows
|
||||
5. Scan for anti-patterns (see below)
|
||||
6. Structure gaps for re-planning
|
||||
```
|
||||
|
||||
### Anti-Pattern Scanning
|
||||
|
||||
Check for common incomplete work:
|
||||
|
||||
| Pattern | Detection | Meaning |
|
||||
|---------|-----------|---------|
|
||||
| `// TODO` | Grep for TODO comments | Work deferred |
|
||||
| `throw new Error('Not implemented')` | Grep for stub errors | Placeholder code |
|
||||
| `return null` / `return {}` | AST analysis | Empty implementations |
|
||||
| `console.log` in handlers | Grep for console.log | Debug code left behind |
|
||||
| Empty catch blocks | AST analysis | Swallowed errors |
|
||||
| Hardcoded values | Manual review | Missing configuration |
|
||||
|
||||
---
|
||||
|
||||
## Verification Output
|
||||
|
||||
### Pass Case
|
||||
|
||||
```yaml
|
||||
# 2-VERIFICATION.md
|
||||
phase: 2
|
||||
status: PASS
|
||||
verified_at: 2024-01-15T10:30:00Z
|
||||
|
||||
observable_truths:
|
||||
- truth: "User can send message"
|
||||
status: VERIFIED
|
||||
evidence: "Chat.tsx:45 calls sendMessage on submit"
|
||||
- truth: "Messages persist"
|
||||
status: VERIFIED
|
||||
evidence: "messages.ts:23 inserts to SQLite"
|
||||
|
||||
required_artifacts:
|
||||
- path: src/components/Chat.tsx
|
||||
status: EXISTS
|
||||
check: PASSED
|
||||
- path: src/api/messages.ts
|
||||
status: EXISTS
|
||||
check: PASSED
|
||||
|
||||
anti_patterns_found: []
|
||||
|
||||
human_verification_needed:
|
||||
- "Visual layout matches design"
|
||||
- "Real-time updates work under load"
|
||||
```
|
||||
|
||||
### Fail Case (Gaps Found)
|
||||
|
||||
```yaml
|
||||
# 2-VERIFICATION.md
|
||||
phase: 2
|
||||
status: GAPS_FOUND
|
||||
verified_at: 2024-01-15T10:30:00Z
|
||||
|
||||
gaps:
|
||||
- type: STUB
|
||||
location: src/hooks/useChat.ts:34
|
||||
description: "sendMessage returns immediately without API call"
|
||||
severity: BLOCKING
|
||||
|
||||
- type: MISSING_WIRING
|
||||
location: src/components/Chat.tsx
|
||||
description: "WebSocket not connected, no real-time updates"
|
||||
severity: BLOCKING
|
||||
|
||||
- type: ANTI_PATTERN
|
||||
location: src/api/messages.ts:67
|
||||
description: "Empty catch block swallows errors"
|
||||
severity: HIGH
|
||||
|
||||
remediation_plan:
|
||||
- "Connect useChat to actual API endpoint"
|
||||
- "Initialize WebSocket in Chat component"
|
||||
- "Add error handling to API calls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Acceptance Testing (UAT)
|
||||
|
||||
Verification confirms code correctness. UAT confirms user experience.
|
||||
|
||||
### UAT Process
|
||||
|
||||
1. Extract testable deliverables from phase goal
|
||||
2. Walk user through each one:
|
||||
- "Can you log in with your email?"
|
||||
- "Does the dashboard show your projects?"
|
||||
- "Can you create a new project?"
|
||||
3. Record result: PASS, FAIL, or describe issue
|
||||
4. If issues found:
|
||||
- Diagnose root cause
|
||||
- Create targeted fix plan
|
||||
5. If all pass: Phase complete
|
||||
|
||||
### UAT Output
|
||||
|
||||
```yaml
|
||||
# 2-UAT.md
|
||||
phase: 2
|
||||
tested_by: user
|
||||
tested_at: 2024-01-15T14:00:00Z
|
||||
|
||||
test_cases:
|
||||
- case: "Login with email"
|
||||
result: PASS
|
||||
|
||||
- case: "Dashboard shows projects"
|
||||
result: FAIL
|
||||
issue: "Shows loading spinner forever"
|
||||
diagnosis: "API returns 500, missing auth header"
|
||||
|
||||
- case: "Create new project"
|
||||
result: BLOCKED
|
||||
reason: "Cannot test, dashboard not loading"
|
||||
|
||||
fix_required: true
|
||||
fix_plan:
|
||||
- task: "Add auth header to dashboard API call"
|
||||
files: [src/api/projects.ts]
|
||||
priority: P0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Task Workflow
|
||||
|
||||
### Task Completion Hook
|
||||
When task closes:
|
||||
1. Worker marks task closed with reason
|
||||
2. If all phase tasks closed, trigger phase verification
|
||||
3. Verifier agent runs goal-backward check
|
||||
4. If PASS: Phase marked complete
|
||||
5. If GAPS: Create remediation tasks, phase stays in_progress
|
||||
|
||||
### Verification Task Type
|
||||
Verification itself is a task:
|
||||
|
||||
```yaml
|
||||
type: verification
|
||||
phase_id: phase-2
|
||||
status: open
|
||||
assigned_to: verifier-agent
|
||||
priority: P0 # Always high priority
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint Types
|
||||
|
||||
During execution, agents may need human input. Use precise checkpoint types:
|
||||
|
||||
### checkpoint:human-verify (90% of checkpoints)
|
||||
Agent completed work, user confirms it works.
|
||||
|
||||
```yaml
|
||||
checkpoint: human-verify
|
||||
prompt: "Can you log in with email and password?"
|
||||
expected: "User confirms successful login"
|
||||
```
|
||||
|
||||
### checkpoint:decision (9% of checkpoints)
|
||||
User must make implementation choice.
|
||||
|
||||
```yaml
|
||||
checkpoint: decision
|
||||
prompt: "OAuth2 or SAML for SSO?"
|
||||
options:
|
||||
- OAuth2: "Simpler, most common"
|
||||
- SAML: "Enterprise requirement"
|
||||
```
|
||||
|
||||
### checkpoint:human-action (1% of checkpoints)
|
||||
Truly unavoidable manual step.
|
||||
|
||||
```yaml
|
||||
checkpoint: human-action
|
||||
prompt: "Click the email verification link"
|
||||
reason: "Cannot automate email client interaction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Human Verification Needs
|
||||
|
||||
Some verifications require human eyes:
|
||||
|
||||
| Category | Examples | Why Human |
|
||||
|----------|----------|-----------|
|
||||
| Visual | Layout, spacing, colors | Subjective/design judgment |
|
||||
| Real-time | WebSocket, live updates | Requires interaction |
|
||||
| External | OAuth flow, payment | Third-party systems |
|
||||
| Accessibility | Screen reader, keyboard nav | Requires tooling/expertise |
|
||||
|
||||
**Mark these explicitly** in verification output. Don't claim PASS when human verification is pending.
|
||||
8
drizzle/0001_overrated_gladiator.sql
Normal file
8
drizzle/0001_overrated_gladiator.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `phase_dependencies` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`phase_id` text NOT NULL,
|
||||
`depends_on_phase_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`phase_id`) REFERENCES `phases`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`depends_on_phase_id`) REFERENCES `phases`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
12
drizzle/0002_bumpy_killraven.sql
Normal file
12
drizzle/0002_bumpy_killraven.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `pages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`initiative_id` text NOT NULL,
|
||||
`parent_page_id` text,
|
||||
`title` text NOT NULL,
|
||||
`content` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`initiative_id`) REFERENCES `initiatives`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`parent_page_id`) REFERENCES `pages`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
20
drizzle/0003_curly_ser_duncan.sql
Normal file
20
drizzle/0003_curly_ser_duncan.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE `projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `projects_name_unique` ON `projects` (`name`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `projects_url_unique` ON `projects` (`url`);--> statement-breakpoint
|
||||
CREATE TABLE `initiative_projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`initiative_id` text NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`initiative_id`) REFERENCES `initiatives`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `initiative_project_unique` ON `initiative_projects` (`initiative_id`,`project_id`);
|
||||
15
drizzle/0004_white_captain_britain.sql
Normal file
15
drizzle/0004_white_captain_britain.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `accounts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`provider` text DEFAULT 'claude' NOT NULL,
|
||||
`config_dir` text NOT NULL,
|
||||
`is_exhausted` integer DEFAULT false NOT NULL,
|
||||
`exhausted_until` integer,
|
||||
`last_used_at` integer,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `agents` ADD `provider` text DEFAULT 'claude' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `agents` ADD `account_id` text REFERENCES accounts(id);
|
||||
4
drizzle/0005_blushing_wendell_vaughn.sql
Normal file
4
drizzle/0005_blushing_wendell_vaughn.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `agents` ADD `pid` integer;--> statement-breakpoint
|
||||
ALTER TABLE `agents` ADD `output_file_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `agents` ADD `result` text;--> statement-breakpoint
|
||||
ALTER TABLE `agents` ADD `pending_questions` text;
|
||||
1
drizzle/0006_curvy_sandman.sql
Normal file
1
drizzle/0006_curvy_sandman.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `initiative_id` text REFERENCES initiatives(id);
|
||||
27
drizzle/0007_robust_the_watchers.sql
Normal file
27
drizzle/0007_robust_the_watchers.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`plan_id` text,
|
||||
`phase_id` text,
|
||||
`initiative_id` text,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`type` text DEFAULT 'auto' NOT NULL,
|
||||
`category` text DEFAULT 'execute' NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`requires_approval` integer,
|
||||
`order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`plan_id`) REFERENCES `plans`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`phase_id`) REFERENCES `phases`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`initiative_id`) REFERENCES `initiatives`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_tasks`("id", "plan_id", "phase_id", "initiative_id", "name", "description", "type", "category", "priority", "status", "requires_approval", "order", "created_at", "updated_at") SELECT "id", "plan_id", NULL, NULL, "name", "description", "type", 'execute', "priority", "status", NULL, "order", "created_at", "updated_at" FROM `tasks`;--> statement-breakpoint
|
||||
DROP TABLE `tasks`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tasks` RENAME TO `tasks`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
ALTER TABLE `initiatives` ADD `merge_requires_approval` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `initiatives` ADD `merge_target` text;
|
||||
61
drizzle/0008_eliminate_plans_table.sql
Normal file
61
drizzle/0008_eliminate_plans_table.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Migration: Eliminate plans table
|
||||
-- Plans are now decompose tasks with parentTaskId for child relationships
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Step 1: Create new tasks table with parent_task_id instead of plan_id
|
||||
CREATE TABLE `__new_tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`phase_id` text,
|
||||
`initiative_id` text,
|
||||
`parent_task_id` text,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`type` text DEFAULT 'auto' NOT NULL,
|
||||
`category` text DEFAULT 'execute' NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`requires_approval` integer,
|
||||
`order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`phase_id`) REFERENCES `phases`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`initiative_id`) REFERENCES `initiatives`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`parent_task_id`) REFERENCES `__new_tasks`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Step 2: Insert plans as decompose tasks FIRST (so plan IDs exist as task IDs for foreign key)
|
||||
INSERT INTO `__new_tasks`("id", "phase_id", "initiative_id", "parent_task_id", "name", "description", "type", "category", "priority", "status", "requires_approval", "order", "created_at", "updated_at")
|
||||
SELECT
|
||||
"id",
|
||||
"phase_id",
|
||||
NULL,
|
||||
NULL,
|
||||
"name",
|
||||
"description",
|
||||
'auto',
|
||||
'decompose',
|
||||
'medium',
|
||||
CASE
|
||||
WHEN "status" = 'completed' THEN 'completed'
|
||||
WHEN "status" = 'in_progress' THEN 'in_progress'
|
||||
ELSE 'pending'
|
||||
END,
|
||||
NULL,
|
||||
"number",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM `plans`;--> statement-breakpoint
|
||||
|
||||
-- Step 3: Copy existing tasks, converting plan_id to parent_task_id
|
||||
INSERT INTO `__new_tasks`("id", "phase_id", "initiative_id", "parent_task_id", "name", "description", "type", "category", "priority", "status", "requires_approval", "order", "created_at", "updated_at")
|
||||
SELECT "id", "phase_id", "initiative_id", "plan_id", "name", "description", "type", "category", "priority", "status", "requires_approval", "order", "created_at", "updated_at" FROM `tasks`;--> statement-breakpoint
|
||||
|
||||
-- Step 4: Drop old tasks table and rename new one
|
||||
DROP TABLE `tasks`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tasks` RENAME TO `tasks`;--> statement-breakpoint
|
||||
|
||||
-- Step 5: Drop plans table
|
||||
DROP TABLE `plans`;--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
4
drizzle/0009_drop_account_config_dir.sql
Normal file
4
drizzle/0009_drop_account_config_dir.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration: Remove config_dir column from accounts table
|
||||
-- Path is now convention-based: <workspaceRoot>/.cw/accounts/<accountId>/
|
||||
|
||||
ALTER TABLE `accounts` DROP COLUMN `config_dir`;
|
||||
5
drizzle/0010_add_account_credentials.sql
Normal file
5
drizzle/0010_add_account_credentials.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration: Add config_json and credentials columns to accounts table
|
||||
-- Credentials are now stored in DB and written to disk before spawning agents.
|
||||
|
||||
ALTER TABLE `accounts` ADD COLUMN `config_json` text;--> statement-breakpoint
|
||||
ALTER TABLE `accounts` ADD COLUMN `credentials` text;
|
||||
4
drizzle/0011_drop_initiative_description.sql
Normal file
4
drizzle/0011_drop_initiative_description.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration: Remove unused description column from initiatives table
|
||||
-- Content is stored in pages (markdown via Tiptap editor), not in the initiative itself.
|
||||
|
||||
ALTER TABLE `initiatives` DROP COLUMN `description`;
|
||||
1
drizzle/0012_add_agent_user_dismissed_at.sql
Normal file
1
drizzle/0012_add_agent_user_dismissed_at.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `agents` ADD `user_dismissed_at` integer;
|
||||
695
drizzle/meta/0001_snapshot.json
Normal file
695
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,695 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "43bf93b7-da71-4b69-b289-2991b6e54a69",
|
||||
"prevId": "d2fc5ac9-8232-401a-a55f-a97a4d9b6f21",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"worktree_id": {
|
||||
"name": "worktree_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'execute'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"agents_name_unique": {
|
||||
"name": "agents_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"agents_task_id_tasks_id_fk": {
|
||||
"name": "agents_task_id_tasks_id_fk",
|
||||
"tableFrom": "agents",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"initiatives": {
|
||||
"name": "initiatives",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_type": {
|
||||
"name": "sender_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_id": {
|
||||
"name": "sender_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_type": {
|
||||
"name": "recipient_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_id": {
|
||||
"name": "recipient_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'info'"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"requires_response": {
|
||||
"name": "requires_response",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"parent_message_id": {
|
||||
"name": "parent_message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"messages_sender_id_agents_id_fk": {
|
||||
"name": "messages_sender_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"sender_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_recipient_id_agents_id_fk": {
|
||||
"name": "messages_recipient_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"recipient_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_parent_message_id_messages_id_fk": {
|
||||
"name": "messages_parent_message_id_messages_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"parent_message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phase_dependencies": {
|
||||
"name": "phase_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_phase_id": {
|
||||
"name": "depends_on_phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phase_dependencies_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"phase_dependencies_depends_on_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_depends_on_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"depends_on_phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phases": {
|
||||
"name": "phases",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phases_initiative_id_initiatives_id_fk": {
|
||||
"name": "phases_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "phases",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"plans": {
|
||||
"name": "plans",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"plans_phase_id_phases_id_fk": {
|
||||
"name": "plans_phase_id_phases_id_fk",
|
||||
"tableFrom": "plans",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_dependencies": {
|
||||
"name": "task_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_task_id": {
|
||||
"name": "depends_on_task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_dependencies_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_dependencies_depends_on_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_depends_on_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"depends_on_task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_id": {
|
||||
"name": "plan_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tasks_plan_id_plans_id_fk": {
|
||||
"name": "tasks_plan_id_plans_id_fk",
|
||||
"tableFrom": "tasks",
|
||||
"tableTo": "plans",
|
||||
"columnsFrom": [
|
||||
"plan_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
789
drizzle/meta/0002_snapshot.json
Normal file
789
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,789 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "cd4eb2e6-af83-473e-a189-8480d217b3c8",
|
||||
"prevId": "43bf93b7-da71-4b69-b289-2991b6e54a69",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"worktree_id": {
|
||||
"name": "worktree_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'execute'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"agents_name_unique": {
|
||||
"name": "agents_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"agents_task_id_tasks_id_fk": {
|
||||
"name": "agents_task_id_tasks_id_fk",
|
||||
"tableFrom": "agents",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"initiatives": {
|
||||
"name": "initiatives",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_type": {
|
||||
"name": "sender_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_id": {
|
||||
"name": "sender_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_type": {
|
||||
"name": "recipient_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_id": {
|
||||
"name": "recipient_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'info'"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"requires_response": {
|
||||
"name": "requires_response",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"parent_message_id": {
|
||||
"name": "parent_message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"messages_sender_id_agents_id_fk": {
|
||||
"name": "messages_sender_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"sender_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_recipient_id_agents_id_fk": {
|
||||
"name": "messages_recipient_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"recipient_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_parent_message_id_messages_id_fk": {
|
||||
"name": "messages_parent_message_id_messages_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"parent_message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"pages": {
|
||||
"name": "pages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_page_id": {
|
||||
"name": "parent_page_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pages_initiative_id_initiatives_id_fk": {
|
||||
"name": "pages_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "pages",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"pages_parent_page_id_pages_id_fk": {
|
||||
"name": "pages_parent_page_id_pages_id_fk",
|
||||
"tableFrom": "pages",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"parent_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phase_dependencies": {
|
||||
"name": "phase_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_phase_id": {
|
||||
"name": "depends_on_phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phase_dependencies_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"phase_dependencies_depends_on_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_depends_on_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"depends_on_phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phases": {
|
||||
"name": "phases",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phases_initiative_id_initiatives_id_fk": {
|
||||
"name": "phases_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "phases",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"plans": {
|
||||
"name": "plans",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"plans_phase_id_phases_id_fk": {
|
||||
"name": "plans_phase_id_phases_id_fk",
|
||||
"tableFrom": "plans",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_dependencies": {
|
||||
"name": "task_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_task_id": {
|
||||
"name": "depends_on_task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_dependencies_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_dependencies_depends_on_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_depends_on_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"depends_on_task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_id": {
|
||||
"name": "plan_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tasks_plan_id_plans_id_fk": {
|
||||
"name": "tasks_plan_id_plans_id_fk",
|
||||
"tableFrom": "tasks",
|
||||
"tableTo": "plans",
|
||||
"columnsFrom": [
|
||||
"plan_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
923
drizzle/meta/0003_snapshot.json
Normal file
923
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,923 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0750ece4-b483-4fb3-8c64-3fbbc13b041d",
|
||||
"prevId": "cd4eb2e6-af83-473e-a189-8480d217b3c8",
|
||||
"tables": {
|
||||
"agents": {
|
||||
"name": "agents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"worktree_id": {
|
||||
"name": "worktree_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"mode": {
|
||||
"name": "mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'execute'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"agents_name_unique": {
|
||||
"name": "agents_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"agents_task_id_tasks_id_fk": {
|
||||
"name": "agents_task_id_tasks_id_fk",
|
||||
"tableFrom": "agents",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"initiative_projects": {
|
||||
"name": "initiative_projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"initiative_project_unique": {
|
||||
"name": "initiative_project_unique",
|
||||
"columns": [
|
||||
"initiative_id",
|
||||
"project_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"initiative_projects_initiative_id_initiatives_id_fk": {
|
||||
"name": "initiative_projects_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "initiative_projects",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"initiative_projects_project_id_projects_id_fk": {
|
||||
"name": "initiative_projects_project_id_projects_id_fk",
|
||||
"tableFrom": "initiative_projects",
|
||||
"tableTo": "projects",
|
||||
"columnsFrom": [
|
||||
"project_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"initiatives": {
|
||||
"name": "initiatives",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_type": {
|
||||
"name": "sender_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_id": {
|
||||
"name": "sender_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_type": {
|
||||
"name": "recipient_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_id": {
|
||||
"name": "recipient_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'info'"
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"requires_response": {
|
||||
"name": "requires_response",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"parent_message_id": {
|
||||
"name": "parent_message_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"messages_sender_id_agents_id_fk": {
|
||||
"name": "messages_sender_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"sender_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_recipient_id_agents_id_fk": {
|
||||
"name": "messages_recipient_id_agents_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"recipient_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"messages_parent_message_id_messages_id_fk": {
|
||||
"name": "messages_parent_message_id_messages_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "messages",
|
||||
"columnsFrom": [
|
||||
"parent_message_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"pages": {
|
||||
"name": "pages",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_page_id": {
|
||||
"name": "parent_page_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"pages_initiative_id_initiatives_id_fk": {
|
||||
"name": "pages_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "pages",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"pages_parent_page_id_pages_id_fk": {
|
||||
"name": "pages_parent_page_id_pages_id_fk",
|
||||
"tableFrom": "pages",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"parent_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phase_dependencies": {
|
||||
"name": "phase_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_phase_id": {
|
||||
"name": "depends_on_phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phase_dependencies_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"phase_dependencies_depends_on_phase_id_phases_id_fk": {
|
||||
"name": "phase_dependencies_depends_on_phase_id_phases_id_fk",
|
||||
"tableFrom": "phase_dependencies",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"depends_on_phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"phases": {
|
||||
"name": "phases",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"initiative_id": {
|
||||
"name": "initiative_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"phases_initiative_id_initiatives_id_fk": {
|
||||
"name": "phases_initiative_id_initiatives_id_fk",
|
||||
"tableFrom": "phases",
|
||||
"tableTo": "initiatives",
|
||||
"columnsFrom": [
|
||||
"initiative_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"plans": {
|
||||
"name": "plans",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"phase_id": {
|
||||
"name": "phase_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"number": {
|
||||
"name": "number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"plans_phase_id_phases_id_fk": {
|
||||
"name": "plans_phase_id_phases_id_fk",
|
||||
"tableFrom": "plans",
|
||||
"tableTo": "phases",
|
||||
"columnsFrom": [
|
||||
"phase_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"projects_name_unique": {
|
||||
"name": "projects_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"projects_url_unique": {
|
||||
"name": "projects_url_unique",
|
||||
"columns": [
|
||||
"url"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_dependencies": {
|
||||
"name": "task_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"depends_on_task_id": {
|
||||
"name": "depends_on_task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"task_dependencies_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"task_dependencies_depends_on_task_id_tasks_id_fk": {
|
||||
"name": "task_dependencies_depends_on_task_id_tasks_id_fk",
|
||||
"tableFrom": "task_dependencies",
|
||||
"tableTo": "tasks",
|
||||
"columnsFrom": [
|
||||
"depends_on_task_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"plan_id": {
|
||||
"name": "plan_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tasks_plan_id_plans_id_fk": {
|
||||
"name": "tasks_plan_id_plans_id_fk",
|
||||
"tableFrom": "tasks",
|
||||
"tableTo": "plans",
|
||||
"columnsFrom": [
|
||||
"plan_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
1034
drizzle/meta/0004_snapshot.json
Normal file
1034
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1062
drizzle/meta/0005_snapshot.json
Normal file
1062
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1082
drizzle/meta/0006_snapshot.json
Normal file
1082
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1152
drizzle/meta/0007_snapshot.json
Normal file
1152
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,90 @@
|
||||
"when": 1769882826521,
|
||||
"tag": "0000_bizarre_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770236400939,
|
||||
"tag": "0001_overrated_gladiator",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770283755529,
|
||||
"tag": "0002_bumpy_killraven",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1770310029604,
|
||||
"tag": "0003_curly_ser_duncan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1770311913089,
|
||||
"tag": "0004_white_captain_britain",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1770314201607,
|
||||
"tag": "0005_blushing_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1770317104950,
|
||||
"tag": "0006_curvy_sandman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1770373854589,
|
||||
"tag": "0007_robust_the_watchers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1770460800000,
|
||||
"tag": "0008_eliminate_plans_table",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1770508800000,
|
||||
"tag": "0009_drop_account_config_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1770512400000,
|
||||
"tag": "0010_add_account_credentials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1770595200000,
|
||||
"tag": "0011_drop_initiative_description",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1770420629437,
|
||||
"tag": "0012_add_agent_user_dismissed_at",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
102
drizzle/relations.ts
Normal file
102
drizzle/relations.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { tasks, agents, messages, initiatives, phases, plans, taskDependencies, phaseDependencies } from "./schema";
|
||||
|
||||
export const agentsRelations = relations(agents, ({one, many}) => ({
|
||||
task: one(tasks, {
|
||||
fields: [agents.taskId],
|
||||
references: [tasks.id]
|
||||
}),
|
||||
messages_recipientId: many(messages, {
|
||||
relationName: "messages_recipientId_agents_id"
|
||||
}),
|
||||
messages_senderId: many(messages, {
|
||||
relationName: "messages_senderId_agents_id"
|
||||
}),
|
||||
}));
|
||||
|
||||
export const tasksRelations = relations(tasks, ({one, many}) => ({
|
||||
agents: many(agents),
|
||||
taskDependencies_dependsOnTaskId: many(taskDependencies, {
|
||||
relationName: "taskDependencies_dependsOnTaskId_tasks_id"
|
||||
}),
|
||||
taskDependencies_taskId: many(taskDependencies, {
|
||||
relationName: "taskDependencies_taskId_tasks_id"
|
||||
}),
|
||||
plan: one(plans, {
|
||||
fields: [tasks.planId],
|
||||
references: [plans.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const messagesRelations = relations(messages, ({one, many}) => ({
|
||||
message: one(messages, {
|
||||
fields: [messages.parentMessageId],
|
||||
references: [messages.id],
|
||||
relationName: "messages_parentMessageId_messages_id"
|
||||
}),
|
||||
messages: many(messages, {
|
||||
relationName: "messages_parentMessageId_messages_id"
|
||||
}),
|
||||
agent_recipientId: one(agents, {
|
||||
fields: [messages.recipientId],
|
||||
references: [agents.id],
|
||||
relationName: "messages_recipientId_agents_id"
|
||||
}),
|
||||
agent_senderId: one(agents, {
|
||||
fields: [messages.senderId],
|
||||
references: [agents.id],
|
||||
relationName: "messages_senderId_agents_id"
|
||||
}),
|
||||
}));
|
||||
|
||||
export const phasesRelations = relations(phases, ({one, many}) => ({
|
||||
initiative: one(initiatives, {
|
||||
fields: [phases.initiativeId],
|
||||
references: [initiatives.id]
|
||||
}),
|
||||
plans: many(plans),
|
||||
phaseDependencies_dependsOnPhaseId: many(phaseDependencies, {
|
||||
relationName: "phaseDependencies_dependsOnPhaseId_phases_id"
|
||||
}),
|
||||
phaseDependencies_phaseId: many(phaseDependencies, {
|
||||
relationName: "phaseDependencies_phaseId_phases_id"
|
||||
}),
|
||||
}));
|
||||
|
||||
export const initiativesRelations = relations(initiatives, ({many}) => ({
|
||||
phases: many(phases),
|
||||
}));
|
||||
|
||||
export const plansRelations = relations(plans, ({one, many}) => ({
|
||||
phase: one(phases, {
|
||||
fields: [plans.phaseId],
|
||||
references: [phases.id]
|
||||
}),
|
||||
tasks: many(tasks),
|
||||
}));
|
||||
|
||||
export const taskDependenciesRelations = relations(taskDependencies, ({one}) => ({
|
||||
task_dependsOnTaskId: one(tasks, {
|
||||
fields: [taskDependencies.dependsOnTaskId],
|
||||
references: [tasks.id],
|
||||
relationName: "taskDependencies_dependsOnTaskId_tasks_id"
|
||||
}),
|
||||
task_taskId: one(tasks, {
|
||||
fields: [taskDependencies.taskId],
|
||||
references: [tasks.id],
|
||||
relationName: "taskDependencies_taskId_tasks_id"
|
||||
}),
|
||||
}));
|
||||
|
||||
export const phaseDependenciesRelations = relations(phaseDependencies, ({one}) => ({
|
||||
phase_dependsOnPhaseId: one(phases, {
|
||||
fields: [phaseDependencies.dependsOnPhaseId],
|
||||
references: [phases.id],
|
||||
relationName: "phaseDependencies_dependsOnPhaseId_phases_id"
|
||||
}),
|
||||
phase_phaseId: one(phases, {
|
||||
fields: [phaseDependencies.phaseId],
|
||||
references: [phases.id],
|
||||
relationName: "phaseDependencies_phaseId_phases_id"
|
||||
}),
|
||||
}));
|
||||
98
drizzle/schema.ts
Normal file
98
drizzle/schema.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { sqliteTable, AnySQLiteColumn, uniqueIndex, foreignKey, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const agents = sqliteTable("agents", {
|
||||
id: text().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
taskId: text("task_id").references(() => tasks.id, { onDelete: "set null" } ),
|
||||
sessionId: text("session_id"),
|
||||
worktreeId: text("worktree_id").notNull(),
|
||||
status: text().default("idle").notNull(),
|
||||
mode: text().default("execute").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("agents_name_unique").on(table.name),
|
||||
]);
|
||||
|
||||
export const initiatives = sqliteTable("initiatives", {
|
||||
id: text().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
status: text().default("active").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const messages = sqliteTable("messages", {
|
||||
id: text().primaryKey().notNull(),
|
||||
senderType: text("sender_type").notNull(),
|
||||
senderId: text("sender_id").references(() => agents.id, { onDelete: "set null" } ),
|
||||
recipientType: text("recipient_type").notNull(),
|
||||
recipientId: text("recipient_id").references(() => agents.id, { onDelete: "set null" } ),
|
||||
type: text().default("info").notNull(),
|
||||
content: text().notNull(),
|
||||
requiresResponse: integer("requires_response").default(false).notNull(),
|
||||
status: text().default("pending").notNull(),
|
||||
parentMessageId: text("parent_message_id"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey(() => ({
|
||||
columns: [table.parentMessageId],
|
||||
foreignColumns: [table.id],
|
||||
name: "messages_parent_message_id_messages_id_fk"
|
||||
})).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const phases = sqliteTable("phases", {
|
||||
id: text().primaryKey().notNull(),
|
||||
initiativeId: text("initiative_id").notNull().references(() => initiatives.id, { onDelete: "cascade" } ),
|
||||
number: integer().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
status: text().default("pending").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const plans = sqliteTable("plans", {
|
||||
id: text().primaryKey().notNull(),
|
||||
phaseId: text("phase_id").notNull().references(() => phases.id, { onDelete: "cascade" } ),
|
||||
number: integer().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
status: text().default("pending").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const taskDependencies = sqliteTable("task_dependencies", {
|
||||
id: text().primaryKey().notNull(),
|
||||
taskId: text("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" } ),
|
||||
dependsOnTaskId: text("depends_on_task_id").notNull().references(() => tasks.id, { onDelete: "cascade" } ),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable("tasks", {
|
||||
id: text().primaryKey().notNull(),
|
||||
planId: text("plan_id").notNull().references(() => plans.id, { onDelete: "cascade" } ),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
type: text().default("auto").notNull(),
|
||||
priority: text().default("medium").notNull(),
|
||||
status: text().default("pending").notNull(),
|
||||
order: integer().default(0).notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const phaseDependencies = sqliteTable("phase_dependencies", {
|
||||
id: text().primaryKey().notNull(),
|
||||
phaseId: text("phase_id").notNull().references(() => phases.id, { onDelete: "cascade" } ),
|
||||
dependsOnPhaseId: text("depends_on_phase_id").notNull().references(() => phases.id, { onDelete: "cascade" } ),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
1319
package-lock.json
generated
1319
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -3,7 +3,9 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Multi-agent workspace for orchestrating multiple Claude Code agents",
|
||||
"type": "module",
|
||||
"workspaces": ["packages/*"],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"bin": {
|
||||
"cw": "./dist/bin/cw.js"
|
||||
@@ -26,20 +28,28 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/markdown": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@trpc/client": "^11.9.0",
|
||||
"@trpc/server": "^11.9.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"commander": "^12.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"execa": "^9.5.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"pino": "^10.3.0",
|
||||
"simple-git": "^3.30.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.10.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export type { AppRouter } from './trpc.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent } from './types.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project } from './types.js';
|
||||
export { sortByPriorityAndQueueTime, type SortableItem } from './utils.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message } from '../../../src/db/schema.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, Page, Project, Account } from '../../../src/db/schema.js';
|
||||
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
|
||||
|
||||
/**
|
||||
|
||||
37
packages/shared/src/utils.ts
Normal file
37
packages/shared/src/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared utility functions that can be used across frontend and backend.
|
||||
*/
|
||||
|
||||
export interface SortableItem {
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority order mapping for sorting (higher number = higher priority)
|
||||
*/
|
||||
const PRIORITY_ORDER = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Sorts items by priority (high to low) then by queue time (oldest first).
|
||||
* This ensures high-priority items come first, but within the same priority,
|
||||
* items are processed in FIFO order.
|
||||
*/
|
||||
export function sortByPriorityAndQueueTime<T extends SortableItem>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
// First sort by priority (high to low)
|
||||
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
// Within same priority, sort by creation time (oldest first - FIFO)
|
||||
const aTime = typeof a.createdAt === 'string' ? new Date(a.createdAt) : a.createdAt;
|
||||
const bTime = typeof b.createdAt === 'string' ? new Date(b.createdAt) : b.createdAt;
|
||||
return aTime.getTime() - bTime.getTime();
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,14 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@tanstack/react-router": "^1.158.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/extension-table": "^3.19.0",
|
||||
"@tiptap/html": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/suggestion": "^3.19.0",
|
||||
"@trpc/client": "^11.9.0",
|
||||
"@trpc/react-query": "^11.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -23,7 +31,8 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
177
packages/web/src/components/AgentOutputViewer.tsx
Normal file
177
packages/web/src/components/AgentOutputViewer.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
interface AgentOutputViewerProps {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const containerRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Load initial/historical output
|
||||
const outputQuery = trpc.getAgentOutput.useQuery(
|
||||
{ id: agentId },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to live output with error handling
|
||||
const subscription = useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentOutput.useSubscription({ agentId }),
|
||||
{
|
||||
onData: (event) => {
|
||||
// event is TrackedEnvelope<{ agentId: string; data: string }>
|
||||
// event.data is the inner data object
|
||||
const payload = event.data as { agentId: string; data: string };
|
||||
setOutput((prev) => [...prev, payload.data]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Agent output subscription error:', error);
|
||||
},
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 3,
|
||||
}
|
||||
);
|
||||
|
||||
// Set initial output when query loads
|
||||
useEffect(() => {
|
||||
if (outputQuery.data) {
|
||||
// Split NDJSON content into chunks for display
|
||||
// Each line might be a JSON event, so we just display raw for now
|
||||
const lines = outputQuery.data.split("\n").filter(Boolean);
|
||||
// Extract text from JSONL events for display
|
||||
const textChunks: string[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
||||
// Claude CLI stream-json: complete assistant messages with content blocks
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
textChunks.push(block.text);
|
||||
}
|
||||
}
|
||||
} else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
// Legacy streaming format: granular text deltas
|
||||
textChunks.push(event.event.delta.text);
|
||||
} else if (event.type === "result" && event.result) {
|
||||
// Don't add result text since it duplicates the content
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
textChunks.push(line + "\n");
|
||||
}
|
||||
}
|
||||
setOutput(textChunks);
|
||||
}
|
||||
}, [outputQuery.data]);
|
||||
|
||||
// Reset output when agent changes
|
||||
useEffect(() => {
|
||||
setOutput([]);
|
||||
setFollow(true);
|
||||
}, [agentId]);
|
||||
|
||||
// Auto-scroll to bottom when following
|
||||
useEffect(() => {
|
||||
if (follow && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [output, follow]);
|
||||
|
||||
// Handle scroll to detect user scrolling up
|
||||
function handleScroll() {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
if (!isAtBottom && follow) {
|
||||
setFollow(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to bottom
|
||||
function scrollToBottom() {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
setFollow(true);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = outputQuery.isLoading;
|
||||
const hasOutput = output.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-100">
|
||||
{agentName ? `Output: ${agentName}` : "Agent Output"}
|
||||
</span>
|
||||
{subscription.error && (
|
||||
<div className="flex items-center gap-1 text-red-400" title={subscription.error.message}>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="text-xs">Connection error</span>
|
||||
</div>
|
||||
)}
|
||||
{subscription.isConnecting && (
|
||||
<span className="text-xs text-yellow-400">Connecting...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFollow(!follow)}
|
||||
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
|
||||
>
|
||||
{follow ? (
|
||||
<>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Paused
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!follow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={scrollToBottom}
|
||||
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
|
||||
>
|
||||
<ArrowDown className="mr-1 h-3 w-3" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output content */}
|
||||
<pre
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-900 p-4 font-mono text-sm text-zinc-100 whitespace-pre-wrap"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="text-zinc-500">Loading output...</span>
|
||||
) : !hasOutput ? (
|
||||
<span className="text-zinc-500">No output yet...</span>
|
||||
) : (
|
||||
output.join("")
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
|
||||
interface CreateInitiativeDialogProps {
|
||||
open: boolean;
|
||||
@@ -24,7 +24,7 @@ export function CreateInitiativeDialog({
|
||||
onOpenChange,
|
||||
}: CreateInitiativeDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -44,7 +44,7 @@ export function CreateInitiativeDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setProjectIds([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -54,7 +54,7 @@ export function CreateInitiativeDialog({
|
||||
setError(null);
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,19 +81,13 @@ export function CreateInitiativeDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="initiative-description">
|
||||
Description{" "}
|
||||
<Label>
|
||||
Projects{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="initiative-description"
|
||||
placeholder="Brief description of the initiative..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<ProjectPicker value={projectIds} onChange={setProjectIds} />
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
|
||||
48
packages/web/src/components/ExecutionTab.tsx
Normal file
48
packages/web/src/components/ExecutionTab.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ExecutionProvider,
|
||||
PhaseActions,
|
||||
PhasesList,
|
||||
ProgressSidebar,
|
||||
TaskModal,
|
||||
type PhaseData,
|
||||
} from "@/components/execution";
|
||||
|
||||
interface ExecutionTabProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
}
|
||||
|
||||
export function ExecutionTab({
|
||||
initiativeId,
|
||||
phases,
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
}: ExecutionTabProps) {
|
||||
return (
|
||||
<ExecutionProvider>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<PhaseActions initiativeId={initiativeId} phases={phases} />
|
||||
</div>
|
||||
|
||||
<PhasesList
|
||||
initiativeId={initiativeId}
|
||||
phases={phases}
|
||||
phasesLoading={phasesLoading}
|
||||
phasesLoaded={phasesLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<ProgressSidebar phases={phases} />
|
||||
</div>
|
||||
|
||||
<TaskModal />
|
||||
</ExecutionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,118 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, Pencil, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface InitiativeHeaderProps {
|
||||
initiative: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
projects?: Array<{ id: string; name: string; url: string }>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function InitiativeHeader({
|
||||
initiative,
|
||||
projects,
|
||||
onBack,
|
||||
}: InitiativeHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top bar: back button + actions placeholder */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Actions
|
||||
</Button>
|
||||
</div>
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editIds, setEditIds] = useState<string[]>([]);
|
||||
|
||||
{/* Initiative metadata card */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.updateInitiativeProjects.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.getInitiative.invalidate({ id: initiative.id });
|
||||
setEditing(false);
|
||||
toast.success("Projects updated");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function startEditing() {
|
||||
setEditIds(projects?.map((p) => p.id) ?? []);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function saveProjects() {
|
||||
if (editIds.length === 0) {
|
||||
toast.error("At least one project is required");
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
initiativeId: initiative.id,
|
||||
projectIds: editIds,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{initiative.name}</h1>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onBack}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{initiative.name}</h1>
|
||||
<StatusBadge status={initiative.status} />
|
||||
{!editing && projects && projects.length > 0 && (
|
||||
<>
|
||||
{projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" className="text-xs font-normal">
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && (!projects || projects.length === 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={startEditing}
|
||||
>
|
||||
+ Add projects
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created: {new Date(initiative.createdAt).toLocaleDateString()}
|
||||
{" | "}
|
||||
Updated: {new Date(initiative.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{editing && (
|
||||
<div className="ml-11 max-w-sm space-y-2">
|
||||
<ProjectPicker value={editIds} onChange={setEditIds} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveProjects}
|
||||
disabled={editIds.length === 0 || updateMutation.isPending}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return "just now";
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
interface MessageCardProps {
|
||||
agentName: string;
|
||||
|
||||
65
packages/web/src/components/ProjectPicker.tsx
Normal file
65
packages/web/src/components/ProjectPicker.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { RegisterProjectDialog } from "./RegisterProjectDialog";
|
||||
|
||||
interface ProjectPickerProps {
|
||||
value: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ProjectPicker({ value, onChange, error }: ProjectPickerProps) {
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
|
||||
const projectsQuery = trpc.listProjects.useQuery();
|
||||
const projects = projectsQuery.data ?? [];
|
||||
|
||||
function toggle(id: string) {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((v) => v !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{projects.length === 0 && !projectsQuery.isLoading && (
|
||||
<p className="text-sm text-muted-foreground">No projects registered yet.</p>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto rounded border border-border p-2 space-y-1">
|
||||
{projects.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(p.id)}
|
||||
onChange={() => toggle(p.id)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-muted-foreground text-xs truncate">{p.url}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegisterOpen(true)}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Register new project
|
||||
</button>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<RegisterProjectDialog
|
||||
open={registerOpen}
|
||||
onOpenChange={setRegisterOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
packages/web/src/components/RefineSpawnDialog.tsx
Normal file
125
packages/web/src/components/RefineSpawnDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface RefineSpawnDialogProps {
|
||||
/** Button text to show in the trigger */
|
||||
triggerText: string;
|
||||
/** Dialog title */
|
||||
title: string;
|
||||
/** Dialog description */
|
||||
description: string;
|
||||
/** Whether to show the instruction textarea */
|
||||
showInstructionInput?: boolean;
|
||||
/** Placeholder text for the instruction textarea */
|
||||
instructionPlaceholder?: string;
|
||||
/** Whether the spawn mutation is pending */
|
||||
isSpawning: boolean;
|
||||
/** Error message if spawn failed */
|
||||
error?: string;
|
||||
/** Called when the user wants to spawn */
|
||||
onSpawn: (instruction?: string) => void;
|
||||
/** Custom trigger button (optional) */
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RefineSpawnDialog({
|
||||
triggerText,
|
||||
title,
|
||||
description,
|
||||
showInstructionInput = true,
|
||||
instructionPlaceholder = "What should the agent focus on? (optional)",
|
||||
isSpawning,
|
||||
error,
|
||||
onSpawn,
|
||||
trigger,
|
||||
}: RefineSpawnDialogProps) {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [instruction, setInstruction] = useState("");
|
||||
|
||||
const handleSpawn = () => {
|
||||
const finalInstruction = showInstructionInput && instruction.trim()
|
||||
? instruction.trim()
|
||||
: undefined;
|
||||
onSpawn(finalInstruction);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setShowDialog(open);
|
||||
if (!open) {
|
||||
setInstruction("");
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTrigger = (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDialog(true)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{triggerText}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger ? (
|
||||
<div onClick={() => setShowDialog(true)}>
|
||||
{trigger}
|
||||
</div>
|
||||
) : (
|
||||
defaultTrigger
|
||||
)}
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{showInstructionInput && (
|
||||
<Textarea
|
||||
placeholder={instructionPlaceholder}
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSpawn}
|
||||
disabled={isSpawning}
|
||||
>
|
||||
{isSpawning ? "Starting..." : "Start"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
packages/web/src/components/RegisterProjectDialog.tsx
Normal file
110
packages/web/src/components/RegisterProjectDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface RegisterProjectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function RegisterProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RegisterProjectDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const registerMutation = trpc.registerProject.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listProjects.invalidate();
|
||||
onOpenChange(false);
|
||||
toast.success("Project registered");
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setUrl("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
registerMutation.mutate({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
url.trim().length > 0 &&
|
||||
!registerMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register a git repository as a project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="e.g. my-app"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-url">Repository URL</Label>
|
||||
<Input
|
||||
id="project-url"
|
||||
placeholder="e.g. https://github.com/org/repo.git"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{registerMutation.isPending ? "Registering..." : "Register"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,59 +7,42 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
|
||||
interface SpawnArchitectDropdownProps {
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
initiativeName?: string;
|
||||
}
|
||||
|
||||
export function SpawnArchitectDropdown({
|
||||
initiativeId,
|
||||
initiativeName,
|
||||
}: SpawnArchitectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [successText, setSuccessText] = useState<string | null>(null);
|
||||
|
||||
const discussMutation = trpc.spawnArchitectDiscuss.useMutation({
|
||||
onSuccess: () => {
|
||||
const handleSuccess = () => {
|
||||
setOpen(false);
|
||||
setSuccessText("Spawned!");
|
||||
setTimeout(() => setSuccessText(null), 2000);
|
||||
toast.success("Architect spawned");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to spawn architect");
|
||||
},
|
||||
};
|
||||
|
||||
const discussSpawn = useSpawnMutation(trpc.spawnArchitectDiscuss.useMutation, {
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const breakdownMutation = trpc.spawnArchitectBreakdown.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setSuccessText("Spawned!");
|
||||
setTimeout(() => setSuccessText(null), 2000);
|
||||
toast.success("Architect spawned");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to spawn architect");
|
||||
},
|
||||
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const isPending = discussMutation.isPending || breakdownMutation.isPending;
|
||||
const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning;
|
||||
|
||||
function handleDiscuss() {
|
||||
discussMutation.mutate({
|
||||
name: initiativeName + "-discuss",
|
||||
initiativeId,
|
||||
});
|
||||
discussSpawn.spawn({ initiativeId });
|
||||
}
|
||||
|
||||
function handleBreakdown() {
|
||||
breakdownMutation.mutate({
|
||||
name: initiativeName + "-breakdown",
|
||||
initiativeId,
|
||||
});
|
||||
breakdownSpawn.spawn({ initiativeId });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
76
packages/web/src/components/StatusDot.tsx
Normal file
76
packages/web/src/components/StatusDot.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Color mapping for different status values.
|
||||
* Uses semantic colors that work well as small dots.
|
||||
*/
|
||||
const statusColors: Record<string, string> = {
|
||||
// Task statuses
|
||||
pending: "bg-gray-400",
|
||||
pending_approval: "bg-yellow-400",
|
||||
in_progress: "bg-blue-500",
|
||||
completed: "bg-green-500",
|
||||
blocked: "bg-red-500",
|
||||
|
||||
// Agent statuses
|
||||
idle: "bg-gray-400",
|
||||
running: "bg-blue-500",
|
||||
waiting_for_input: "bg-yellow-400",
|
||||
stopped: "bg-gray-600",
|
||||
crashed: "bg-red-500",
|
||||
|
||||
// Initiative/Phase statuses
|
||||
active: "bg-blue-500",
|
||||
archived: "bg-gray-400",
|
||||
|
||||
// Message statuses
|
||||
read: "bg-green-500",
|
||||
responded: "bg-blue-500",
|
||||
|
||||
// Priority indicators
|
||||
low: "bg-green-400",
|
||||
medium: "bg-yellow-400",
|
||||
high: "bg-red-400",
|
||||
} as const;
|
||||
|
||||
const defaultColor = "bg-gray-400";
|
||||
|
||||
interface StatusDotProps {
|
||||
status: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small colored dot to indicate status at a glance.
|
||||
* More compact than StatusBadge for use in lists or tight spaces.
|
||||
*/
|
||||
export function StatusDot({
|
||||
status,
|
||||
size = "md",
|
||||
className,
|
||||
title
|
||||
}: StatusDotProps) {
|
||||
const sizeClasses = {
|
||||
sm: "h-2 w-2",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-4 w-4"
|
||||
};
|
||||
|
||||
const color = statusColors[status] ?? defaultColor;
|
||||
const displayTitle = title ?? status.replace(/_/g, " ").toLowerCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
sizeClasses[size],
|
||||
color,
|
||||
className
|
||||
)}
|
||||
title={displayTitle}
|
||||
aria-label={`Status: ${displayTitle}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
|
||||
/** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */
|
||||
export interface SerializedTask {
|
||||
@@ -117,7 +118,7 @@ export function TaskDetailModal({
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span>{dep.name}</span>
|
||||
<StatusBadge status={dep.status} />
|
||||
<StatusDot status={dep.status} size="md" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -137,7 +138,7 @@ export function TaskDetailModal({
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span>{dep.name}</span>
|
||||
<StatusBadge status={dep.status} />
|
||||
<StatusDot status={dep.status} size="md" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
186
packages/web/src/components/editor/BlockSelectionExtension.ts
Normal file
186
packages/web/src/components/editor/BlockSelectionExtension.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
export type BlockSelectionState = {
|
||||
anchorIndex: number;
|
||||
headIndex: number;
|
||||
} | null;
|
||||
|
||||
export const blockSelectionKey = new PluginKey<BlockSelectionState>(
|
||||
"blockSelection",
|
||||
);
|
||||
|
||||
function selectedRange(
|
||||
state: BlockSelectionState,
|
||||
): { from: number; to: number } | null {
|
||||
if (!state) return null;
|
||||
return {
|
||||
from: Math.min(state.anchorIndex, state.headIndex),
|
||||
to: Math.max(state.anchorIndex, state.headIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns doc positions spanning the selected block range. */
|
||||
export function getBlockRange(
|
||||
editorState: EditorState,
|
||||
sel: BlockSelectionState,
|
||||
): { fromPos: number; toPos: number } | null {
|
||||
if (!sel) return null;
|
||||
const range = selectedRange(sel)!;
|
||||
const doc = editorState.doc;
|
||||
let fromPos = 0;
|
||||
let toPos = 0;
|
||||
let idx = 0;
|
||||
doc.forEach((node, offset) => {
|
||||
if (idx === range.from) fromPos = offset;
|
||||
if (idx === range.to) toPos = offset + node.nodeSize;
|
||||
idx++;
|
||||
});
|
||||
return { fromPos, toPos };
|
||||
}
|
||||
|
||||
function isPrintable(e: KeyboardEvent): boolean {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return false;
|
||||
return e.key.length === 1;
|
||||
}
|
||||
|
||||
export const BlockSelectionExtension = Extension.create({
|
||||
name: "blockSelection",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<BlockSelectionState>({
|
||||
key: blockSelectionKey,
|
||||
|
||||
state: {
|
||||
init(): BlockSelectionState {
|
||||
return null;
|
||||
},
|
||||
apply(tr: Transaction, value: BlockSelectionState): BlockSelectionState {
|
||||
const meta = tr.getMeta(blockSelectionKey);
|
||||
if (meta !== undefined) return meta;
|
||||
// Doc changed while selection active → clear (positions stale)
|
||||
if (value && tr.docChanged) return null;
|
||||
// User set a new text selection (not from our plugin) → clear
|
||||
if (value && tr.selectionSet && !tr.getMeta("blockSelectionInternal")) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state: EditorState): DecorationSet {
|
||||
const sel = blockSelectionKey.getState(state);
|
||||
const range = selectedRange(sel);
|
||||
if (!range) return DecorationSet.empty;
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
let idx = 0;
|
||||
state.doc.forEach((node, pos) => {
|
||||
if (idx >= range.from && idx <= range.to) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
class: "block-selected",
|
||||
}),
|
||||
);
|
||||
}
|
||||
idx++;
|
||||
});
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
|
||||
attributes(state: EditorState): Record<string, string> | null {
|
||||
const sel = blockSelectionKey.getState(state);
|
||||
if (sel) return { class: "has-block-selection" };
|
||||
return null;
|
||||
},
|
||||
|
||||
handleKeyDown(view, event) {
|
||||
const sel = blockSelectionKey.getState(view.state);
|
||||
if (!sel) return false;
|
||||
|
||||
const childCount = view.state.doc.childCount;
|
||||
|
||||
if (event.key === "ArrowDown" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const newHead = Math.min(sel.headIndex + 1, childCount - 1);
|
||||
const tr = view.state.tr.setMeta(blockSelectionKey, {
|
||||
anchorIndex: sel.anchorIndex,
|
||||
headIndex: newHead,
|
||||
});
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const newHead = Math.max(sel.headIndex - 1, 0);
|
||||
const tr = view.state.tr.setMeta(blockSelectionKey, {
|
||||
anchorIndex: sel.anchorIndex,
|
||||
headIndex: newHead,
|
||||
});
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(blockSelectionKey, null),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
const range = selectedRange(sel);
|
||||
if (!range) return true;
|
||||
const blockRange = getBlockRange(view.state, sel);
|
||||
if (!blockRange) return true;
|
||||
const tr = view.state.tr.delete(blockRange.fromPos, blockRange.toPos);
|
||||
tr.setMeta(blockSelectionKey, null);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPrintable(event)) {
|
||||
// Delete selected blocks, clear selection, let PM handle char insertion
|
||||
const blockRange = getBlockRange(view.state, sel);
|
||||
if (blockRange) {
|
||||
const tr = view.state.tr.delete(blockRange.fromPos, blockRange.toPos);
|
||||
tr.setMeta(blockSelectionKey, null);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Modifier-only keys (Shift, Ctrl, Alt, Meta) — ignore
|
||||
if (["Shift", "Control", "Alt", "Meta"].includes(event.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other key — clear selection and pass through
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(blockSelectionKey, null),
|
||||
);
|
||||
return false;
|
||||
},
|
||||
|
||||
handleClick(view) {
|
||||
const sel = blockSelectionKey.getState(view.state);
|
||||
if (sel) {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(blockSelectionKey, null),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
191
packages/web/src/components/editor/ContentProposalReview.tsx
Normal file
191
packages/web/src/components/editor/ContentProposalReview.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||
|
||||
interface ContentProposal {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
summary: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
interface ContentProposalReviewProps {
|
||||
proposals: ContentProposal[];
|
||||
agentCreatedAt: Date;
|
||||
agentId: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ContentProposalReview({
|
||||
proposals,
|
||||
agentCreatedAt,
|
||||
agentId,
|
||||
onDismiss,
|
||||
}: ContentProposalReviewProps) {
|
||||
const [accepted, setAccepted] = useState<Set<string>>(new Set());
|
||||
const utils = trpc.useUtils();
|
||||
const updatePageMutation = trpc.updatePage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
onDismiss();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (proposal: ContentProposal) => {
|
||||
const tiptapJson = markdownToTiptapJson(proposal.markdown);
|
||||
await updatePageMutation.mutateAsync({
|
||||
id: proposal.pageId,
|
||||
content: JSON.stringify(tiptapJson),
|
||||
});
|
||||
setAccepted((prev) => new Set(prev).add(proposal.pageId));
|
||||
},
|
||||
[updatePageMutation],
|
||||
);
|
||||
|
||||
const handleAcceptAll = useCallback(async () => {
|
||||
for (const proposal of proposals) {
|
||||
if (!accepted.has(proposal.pageId)) {
|
||||
const tiptapJson = markdownToTiptapJson(proposal.markdown);
|
||||
await updatePageMutation.mutateAsync({
|
||||
id: proposal.pageId,
|
||||
content: JSON.stringify(tiptapJson),
|
||||
});
|
||||
setAccepted((prev) => new Set(prev).add(proposal.pageId));
|
||||
}
|
||||
}
|
||||
}, [proposals, accepted, updatePageMutation]);
|
||||
|
||||
const allAccepted = proposals.every((p) => accepted.has(p.pageId));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Agent Proposals ({proposals.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{!allAccepted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAcceptAll}
|
||||
disabled={updatePageMutation.isPending}
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => dismissMutation.mutate({ id: agentId })}
|
||||
disabled={dismissMutation.isPending}
|
||||
>
|
||||
{dismissMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.pageId}
|
||||
proposal={proposal}
|
||||
isAccepted={accepted.has(proposal.pageId)}
|
||||
agentCreatedAt={agentCreatedAt}
|
||||
onAccept={() => handleAccept(proposal)}
|
||||
isAccepting={updatePageMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: ContentProposal;
|
||||
isAccepted: boolean;
|
||||
agentCreatedAt: Date;
|
||||
onAccept: () => void;
|
||||
isAccepting: boolean;
|
||||
}
|
||||
|
||||
function ProposalCard({
|
||||
proposal,
|
||||
isAccepted,
|
||||
agentCreatedAt,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: ProposalCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Check if page was modified since agent started
|
||||
const pageQuery = trpc.getPage.useQuery({ id: proposal.pageId });
|
||||
const pageUpdatedAt = pageQuery.data?.updatedAt;
|
||||
const isStale =
|
||||
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{proposal.pageTitle}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
|
||||
{proposal.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAccepted ? (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accepted
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
disabled={isAccepting}
|
||||
className="shrink-0"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStale && !isAccepted && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-yellow-600 pl-5">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Content was modified since agent started
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="pl-5 pt-1">
|
||||
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
|
||||
<pre className="whitespace-pre-wrap text-xs">{proposal.markdown}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
packages/web/src/components/editor/ContentTab.tsx
Normal file
348
packages/web/src/components/editor/ContentTab.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAutoSave } from "@/hooks/useAutoSave";
|
||||
import { TiptapEditor } from "./TiptapEditor";
|
||||
import { PageTitleProvider } from "./PageTitleContext";
|
||||
import { PageTree } from "./PageTree";
|
||||
import { RefineAgentPanel } from "./RefineAgentPanel";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ContentTabProps {
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
}
|
||||
|
||||
interface DeleteConfirmation {
|
||||
pageId: string;
|
||||
redo: () => void;
|
||||
}
|
||||
|
||||
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const handleSaved = useCallback(() => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
}, [utils, initiativeId]);
|
||||
const { save, flush, isSaving } = useAutoSave({ onSaved: handleSaved });
|
||||
|
||||
// Get or create root page
|
||||
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
|
||||
const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
|
||||
const createPageMutation = trpc.createPage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
const deletePageMutation = trpc.deletePage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
|
||||
const updateInitiativeMutation = trpc.updateInitiative.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.getInitiative.invalidate({ id: initiativeId });
|
||||
},
|
||||
});
|
||||
const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingInitiativeNameRef = useRef<string | null>(null);
|
||||
|
||||
const [activePageId, setActivePageId] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmation | null>(null);
|
||||
const [pageTitle, setPageTitle] = useState("");
|
||||
|
||||
// Keep a ref to the current editor so subpage creation can insert links
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Resolve active page: use explicit selection, or fallback to root
|
||||
const resolvedActivePageId =
|
||||
activePageId ?? rootPageQuery.data?.id ?? null;
|
||||
|
||||
const isRootPage = resolvedActivePageId != null && resolvedActivePageId === rootPageQuery.data?.id;
|
||||
|
||||
// Fetch active page content
|
||||
const activePageQuery = trpc.getPage.useQuery(
|
||||
{ id: resolvedActivePageId! },
|
||||
{ enabled: !!resolvedActivePageId },
|
||||
);
|
||||
|
||||
const handleEditorUpdate = useCallback(
|
||||
(json: string) => {
|
||||
if (resolvedActivePageId) {
|
||||
save(resolvedActivePageId, { content: json });
|
||||
}
|
||||
},
|
||||
[resolvedActivePageId, save],
|
||||
);
|
||||
|
||||
// Sync title from server when active page changes
|
||||
useEffect(() => {
|
||||
if (activePageQuery.data) {
|
||||
setPageTitle(isRootPage ? initiativeName : activePageQuery.data.title);
|
||||
}
|
||||
}, [activePageQuery.data?.id, isRootPage, initiativeName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTitle = e.target.value;
|
||||
setPageTitle(newTitle);
|
||||
if (isRootPage) {
|
||||
// Debounce initiative name updates
|
||||
pendingInitiativeNameRef.current = newTitle;
|
||||
if (initiativeNameTimerRef.current) {
|
||||
clearTimeout(initiativeNameTimerRef.current);
|
||||
}
|
||||
initiativeNameTimerRef.current = setTimeout(() => {
|
||||
pendingInitiativeNameRef.current = null;
|
||||
updateInitiativeMutation.mutate({ id: initiativeId, name: newTitle });
|
||||
initiativeNameTimerRef.current = null;
|
||||
}, 1000);
|
||||
} else if (resolvedActivePageId) {
|
||||
save(resolvedActivePageId, { title: newTitle });
|
||||
}
|
||||
},
|
||||
[isRootPage, resolvedActivePageId, save, initiativeId, updateInitiativeMutation],
|
||||
);
|
||||
|
||||
// Flush pending initiative name save on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (initiativeNameTimerRef.current) {
|
||||
clearTimeout(initiativeNameTimerRef.current);
|
||||
initiativeNameTimerRef.current = null;
|
||||
}
|
||||
if (pendingInitiativeNameRef.current != null) {
|
||||
updateInitiativeMutation.mutate({ id: initiativeId, name: pendingInitiativeNameRef.current });
|
||||
pendingInitiativeNameRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// Focus the Tiptap editor below
|
||||
const el = (e.target as HTMLElement)
|
||||
.closest(".flex-1")
|
||||
?.querySelector<HTMLElement>("[contenteditable]");
|
||||
el?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateChild = useCallback(
|
||||
(parentPageId: string) => {
|
||||
createPageMutation.mutate({
|
||||
initiativeId,
|
||||
parentPageId,
|
||||
title: "Untitled",
|
||||
});
|
||||
},
|
||||
[initiativeId, createPageMutation],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((pageId: string) => {
|
||||
setActivePageId(pageId);
|
||||
}, []);
|
||||
|
||||
// Slash command: /subpage — creates a page and inserts a link at cursor
|
||||
const handleSubpageCreate = useCallback(
|
||||
async (editor: Editor) => {
|
||||
editorRef.current = editor;
|
||||
try {
|
||||
const page = await createPageMutation.mutateAsync({
|
||||
initiativeId,
|
||||
parentPageId: resolvedActivePageId,
|
||||
title: "Untitled",
|
||||
});
|
||||
// Insert page link at current cursor position
|
||||
editor.commands.insertPageLink({ pageId: page.id });
|
||||
// Wait for auto-save to persist the link before navigating
|
||||
await flush();
|
||||
// Update the query cache so navigating back shows content with the link
|
||||
utils.getPage.setData(
|
||||
{ id: resolvedActivePageId! },
|
||||
(old) => old ? { ...old, content: JSON.stringify(editor.getJSON()) } : undefined,
|
||||
);
|
||||
// Navigate directly to the newly created subpage
|
||||
setActivePageId(page.id);
|
||||
} catch {
|
||||
// Mutation errors surfaced via React Query state
|
||||
}
|
||||
},
|
||||
[initiativeId, resolvedActivePageId, createPageMutation, flush, utils],
|
||||
);
|
||||
|
||||
// Detect when a page link is deleted from the editor (already undone by plugin)
|
||||
const handlePageLinkDeleted = useCallback(
|
||||
(pageId: string, redo: () => void) => {
|
||||
// Don't prompt for pages that don't exist in our tree
|
||||
const allPages = allPagesQuery.data ?? [];
|
||||
const exists = allPages.some((p) => p.id === pageId);
|
||||
if (!exists) {
|
||||
// Page doesn't exist — redo the deletion so the stale link is removed
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteConfirm({ pageId, redo });
|
||||
},
|
||||
[allPagesQuery.data],
|
||||
);
|
||||
|
||||
const confirmDeleteSubpage = useCallback(() => {
|
||||
if (deleteConfirm) {
|
||||
// Re-delete the page link from the editor, then delete the page from DB
|
||||
deleteConfirm.redo();
|
||||
deletePageMutation.mutate({ id: deleteConfirm.pageId });
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
}, [deleteConfirm, deletePageMutation]);
|
||||
|
||||
const dismissDeleteConfirm = useCallback(() => {
|
||||
setDeleteConfirm(null);
|
||||
}, []);
|
||||
|
||||
const allPages = allPagesQuery.data ?? [];
|
||||
|
||||
// Loading
|
||||
if (rootPageQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-64 w-48" />
|
||||
<Skeleton className="h-64 flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error — server likely needs restart or migration hasn't applied
|
||||
if (rootPageQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load editor: {rootPageQuery.error.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Make sure the backend server is running with the latest code.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void rootPageQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitleProvider pages={allPages}>
|
||||
<div className="flex gap-4 pt-4">
|
||||
{/* Page tree sidebar */}
|
||||
<div className="w-48 shrink-0 border-r border-border pr-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Pages
|
||||
</span>
|
||||
</div>
|
||||
<PageTree
|
||||
pages={allPages}
|
||||
activePageId={resolvedActivePageId ?? ""}
|
||||
onNavigate={handleNavigate}
|
||||
onCreateChild={handleCreateChild}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Refine agent panel — sits above editor */}
|
||||
<RefineAgentPanel initiativeId={initiativeId} />
|
||||
|
||||
{resolvedActivePageId && (
|
||||
<>
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activePageQuery.isSuccess && (
|
||||
<input
|
||||
value={pageTitle}
|
||||
onChange={handleTitleChange}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="Untitled"
|
||||
className="w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 pl-11 mb-2"
|
||||
/>
|
||||
)}
|
||||
{activePageQuery.isSuccess && (
|
||||
<TiptapEditor
|
||||
key={resolvedActivePageId}
|
||||
pageId={resolvedActivePageId}
|
||||
content={activePageQuery.data?.content ?? null}
|
||||
onUpdate={handleEditorUpdate}
|
||||
onPageLinkClick={handleNavigate}
|
||||
onSubpageCreate={handleSubpageCreate}
|
||||
onPageLinkDeleted={handlePageLinkDeleted}
|
||||
/>
|
||||
)}
|
||||
{activePageQuery.isLoading && (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
)}
|
||||
{activePageQuery.isError && (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Failed to load page: {activePageQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete subpage confirmation dialog */}
|
||||
<Dialog
|
||||
open={deleteConfirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) dismissDeleteConfirm();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete subpage?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You removed the link to “{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}”.
|
||||
Do you also want to delete the subpage and all its content?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismissDeleteConfirm}>
|
||||
Keep subpage
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDeleteSubpage}>
|
||||
Delete subpage
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageTitleProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
packages/web/src/components/editor/PageBreadcrumb.tsx
Normal file
52
packages/web/src/components/editor/PageBreadcrumb.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface PageBreadcrumbProps {
|
||||
pages: Array<{
|
||||
id: string;
|
||||
parentPageId: string | null;
|
||||
title: string;
|
||||
}>;
|
||||
activePageId: string;
|
||||
onNavigate: (pageId: string) => void;
|
||||
}
|
||||
|
||||
export function PageBreadcrumb({
|
||||
pages,
|
||||
activePageId,
|
||||
onNavigate,
|
||||
}: PageBreadcrumbProps) {
|
||||
const trail = useMemo(() => {
|
||||
const byId = new Map(pages.map((p) => [p.id, p]));
|
||||
const result: Array<{ id: string; title: string }> = [];
|
||||
let current = byId.get(activePageId);
|
||||
|
||||
while (current) {
|
||||
result.unshift({ id: current.id, title: current.title });
|
||||
current = current.parentPageId
|
||||
? byId.get(current.parentPageId)
|
||||
: undefined;
|
||||
}
|
||||
return result;
|
||||
}, [pages, activePageId]);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{trail.map((item, i) => (
|
||||
<span key={item.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3" />}
|
||||
{i < trail.length - 1 ? (
|
||||
<button
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-foreground font-medium">{item.title}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
75
packages/web/src/components/editor/PageLinkExtension.tsx
Normal file
75
packages/web/src/components/editor/PageLinkExtension.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Node, mergeAttributes, ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { usePageTitle } from "./PageTitleContext";
|
||||
|
||||
declare module "@tiptap/react" {
|
||||
interface Commands<ReturnType> {
|
||||
pageLink: {
|
||||
insertPageLink: (attrs: { pageId: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function PageLinkNodeView({ node }: NodeViewProps) {
|
||||
const title = usePageTitle(node.attrs.pageId);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
(e.currentTarget as HTMLElement).dispatchEvent(
|
||||
new CustomEvent("page-link-click", {
|
||||
bubbles: true,
|
||||
detail: { pageId: node.attrs.pageId },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="page-link-block" data-page-link={node.attrs.pageId} onClick={handleClick}>
|
||||
<FileText className="h-5 w-5 shrink-0" />
|
||||
<span>{title}</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const PageLinkExtension = Node.create({
|
||||
name: "pageLink",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
pageId: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: 'div[data-page-link]' },
|
||||
{ tag: 'span[data-page-link]' },
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-page-link": HTMLAttributes.pageId,
|
||||
class: "page-link-block",
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertPageLink:
|
||||
(attrs) =>
|
||||
({ chain }) => {
|
||||
return chain().insertContent({ type: this.name, attrs }).run();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PageLinkNodeView);
|
||||
},
|
||||
});
|
||||
26
packages/web/src/components/editor/PageTitleContext.tsx
Normal file
26
packages/web/src/components/editor/PageTitleContext.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
const PageTitleContext = createContext<Map<string, string>>(new Map());
|
||||
|
||||
interface PageTitleProviderProps {
|
||||
pages: { id: string; title: string }[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageTitleProvider({ pages, children }: PageTitleProviderProps) {
|
||||
const titleMap = useMemo(
|
||||
() => new Map(pages.map((p) => [p.id, p.title])),
|
||||
[pages],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageTitleContext.Provider value={titleMap}>
|
||||
{children}
|
||||
</PageTitleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePageTitle(pageId: string): string {
|
||||
const titleMap = useContext(PageTitleContext);
|
||||
return titleMap.get(pageId) ?? "Untitled";
|
||||
}
|
||||
119
packages/web/src/components/editor/PageTree.tsx
Normal file
119
packages/web/src/components/editor/PageTree.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo } from "react";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PageTreeProps {
|
||||
pages: Array<{
|
||||
id: string;
|
||||
parentPageId: string | null;
|
||||
title: string;
|
||||
}>;
|
||||
activePageId: string;
|
||||
onNavigate: (pageId: string) => void;
|
||||
onCreateChild: (parentPageId: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
export function PageTree({
|
||||
pages,
|
||||
activePageId,
|
||||
onNavigate,
|
||||
onCreateChild,
|
||||
}: PageTreeProps) {
|
||||
const tree = useMemo(() => {
|
||||
const childrenMap = new Map<string | null, typeof pages>([]);
|
||||
for (const page of pages) {
|
||||
const key = page.parentPageId;
|
||||
const existing = childrenMap.get(key) ?? [];
|
||||
existing.push(page);
|
||||
childrenMap.set(key, existing);
|
||||
}
|
||||
|
||||
function buildTree(parentId: string | null): TreeNode[] {
|
||||
const children = childrenMap.get(parentId) ?? [];
|
||||
return children.map((page) => ({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
children: buildTree(page.id),
|
||||
}));
|
||||
}
|
||||
|
||||
return buildTree(null);
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{tree.map((node) => (
|
||||
<PageTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
activePageId={activePageId}
|
||||
onNavigate={onNavigate}
|
||||
onCreateChild={onCreateChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageTreeNodeProps {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
activePageId: string;
|
||||
onNavigate: (pageId: string) => void;
|
||||
onCreateChild: (parentPageId: string) => void;
|
||||
}
|
||||
|
||||
function PageTreeNode({
|
||||
node,
|
||||
depth,
|
||||
activePageId,
|
||||
onNavigate,
|
||||
onCreateChild,
|
||||
}: PageTreeNodeProps) {
|
||||
const isActive = node.id === activePageId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer ${
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => onNavigate(node.id)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate flex-1">{node.title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCreateChild(node.id);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{node.children.map((child) => (
|
||||
<PageTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
activePageId={activePageId}
|
||||
onNavigate={onNavigate}
|
||||
onCreateChild={onCreateChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
packages/web/src/components/editor/RefineAgentPanel.tsx
Normal file
142
packages/web/src/components/editor/RefineAgentPanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useCallback } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { ContentProposalReview } from "./ContentProposalReview";
|
||||
import { RefineSpawnDialog } from "../RefineSpawnDialog";
|
||||
import { useRefineAgent } from "@/hooks";
|
||||
|
||||
interface RefineAgentPanelProps {
|
||||
initiativeId: string;
|
||||
}
|
||||
|
||||
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
// All agent logic is now encapsulated in the hook
|
||||
const { state, agent, questions, proposals, spawn, resume, refresh } = useRefineAgent(initiativeId);
|
||||
|
||||
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
|
||||
// so these callbacks won't change on every render.
|
||||
const handleSpawn = useCallback((instruction?: string) => {
|
||||
spawn.mutate({
|
||||
initiativeId,
|
||||
instruction,
|
||||
});
|
||||
}, [initiativeId, spawn.mutate]);
|
||||
|
||||
const handleAnswerSubmit = useCallback(
|
||||
(answers: Record<string, string>) => {
|
||||
resume.mutate(answers);
|
||||
},
|
||||
[resume.mutate],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// No active agent — show spawn button
|
||||
if (state === "none") {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<RefineSpawnDialog
|
||||
triggerText="Refine with Agent"
|
||||
title="Refine Initiative Content"
|
||||
description="An agent will review all pages and suggest improvements. Optionally tell it what to focus on."
|
||||
instructionPlaceholder="What should the agent focus on? (optional)"
|
||||
isSpawning={spawn.isPending}
|
||||
error={spawn.error?.message}
|
||||
onSpawn={handleSpawn}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running
|
||||
if (state === "running") {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Architect is refining...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Waiting for input — show inline questions
|
||||
if (state === "waiting" && questions) {
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Agent has questions</h3>
|
||||
<QuestionForm
|
||||
questions={questions.questions}
|
||||
onSubmit={handleAnswerSubmit}
|
||||
onCancel={() => {
|
||||
// Can't cancel mid-question — just dismiss
|
||||
}}
|
||||
isSubmitting={resume.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed with proposals
|
||||
if (state === "completed" && proposals && proposals.length > 0) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ContentProposalReview
|
||||
proposals={proposals}
|
||||
agentCreatedAt={new Date(agent!.createdAt)}
|
||||
agentId={agent!.id}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed without proposals (or generic result)
|
||||
if (state === "completed") {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Agent completed — no changes proposed.
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Crashed
|
||||
if (state === "crashed") {
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-sm text-destructive">Agent crashed</span>
|
||||
<RefineSpawnDialog
|
||||
triggerText="Retry"
|
||||
title="Refine Initiative Content"
|
||||
description="An agent will review all pages and suggest improvements."
|
||||
instructionPlaceholder="What should the agent focus on? (optional)"
|
||||
isSpawning={spawn.isPending}
|
||||
error={spawn.error?.message}
|
||||
onSpawn={handleSpawn}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
88
packages/web/src/components/editor/SlashCommandList.tsx
Normal file
88
packages/web/src/components/editor/SlashCommandList.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import type { SlashCommandItem } from "./slash-command-items";
|
||||
|
||||
export interface SlashCommandListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
interface SlashCommandListProps {
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export const SlashCommandList = forwardRef<
|
||||
SlashCommandListRef,
|
||||
SlashCommandListProps
|
||||
>(({ items, command }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[items, command],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => selectItem(index)}
|
||||
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm ${
|
||||
index === selectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-popover-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded border border-border bg-muted text-xs font-mono">
|
||||
{item.icon}
|
||||
</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SlashCommandList.displayName = "SlashCommandList";
|
||||
121
packages/web/src/components/editor/SlashCommands.ts
Normal file
121
packages/web/src/components/editor/SlashCommands.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Extension } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
||||
import {
|
||||
slashCommandItems,
|
||||
type SlashCommandItem,
|
||||
} from "./slash-command-items";
|
||||
import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList";
|
||||
|
||||
export const SlashCommands = Extension.create({
|
||||
name: "slashCommands",
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
onSubpageCreate: null as ((editor: unknown) => void) | null,
|
||||
};
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
startOfLine: false,
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
|
||||
range: { from: number; to: number };
|
||||
props: SlashCommandItem;
|
||||
}) => {
|
||||
// Delete the slash command text
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
// Execute the selected command
|
||||
props.action(editor);
|
||||
},
|
||||
items: ({ query }: { query: string }): SlashCommandItem[] => {
|
||||
return slashCommandItems.filter((item) =>
|
||||
item.label.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandListRef> | null = null;
|
||||
let popup: TippyInstance[] | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}) => {
|
||||
component = new ReactRenderer(SlashCommandList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: getReferenceClientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: {
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
}) => {
|
||||
component?.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (popup?.[0]) {
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect:
|
||||
props.clientRect as unknown as () => DOMRect,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
361
packages/web/src/components/editor/TiptapEditor.tsx
Normal file
361
packages/web/src/components/editor/TiptapEditor.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useEditor, EditorContent, Extension } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { GripVertical, Plus } from "lucide-react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
|
||||
import { Plugin, PluginKey, NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
|
||||
import { SlashCommands } from "./SlashCommands";
|
||||
import { PageLinkExtension } from "./PageLinkExtension";
|
||||
import {
|
||||
BlockSelectionExtension,
|
||||
blockSelectionKey,
|
||||
getBlockRange,
|
||||
} from "./BlockSelectionExtension";
|
||||
|
||||
interface TiptapEditorProps {
|
||||
content: string | null;
|
||||
onUpdate: (json: string) => void;
|
||||
pageId: string;
|
||||
onPageLinkClick?: (pageId: string) => void;
|
||||
onSubpageCreate?: (
|
||||
editor: Editor,
|
||||
) => void;
|
||||
onPageLinkDeleted?: (pageId: string, redo: () => void) => void;
|
||||
}
|
||||
|
||||
export function TiptapEditor({
|
||||
content,
|
||||
onUpdate,
|
||||
pageId,
|
||||
onPageLinkClick,
|
||||
onSubpageCreate,
|
||||
onPageLinkDeleted,
|
||||
}: TiptapEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
|
||||
onPageLinkDeletedRef.current = onPageLinkDeleted;
|
||||
const blockIndexRef = useRef<number | null>(null);
|
||||
const savedBlockSelRef = useRef<{ anchorIndex: number; headIndex: number } | null>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
PageLinkExtension,
|
||||
BlockSelectionExtension,
|
||||
// Detect pageLink node deletions by comparing old/new doc state
|
||||
Extension.create({
|
||||
name: "pageLinkDeletionDetector",
|
||||
addStorage() {
|
||||
return { skipDetection: false };
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
const tiptapEditor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("pageLinkDeletionDetector"),
|
||||
appendTransaction(_transactions, oldState, newState) {
|
||||
if (oldState.doc.eq(newState.doc)) return null;
|
||||
|
||||
const oldLinks = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
oldLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
const newLinks = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
newLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const removedPageId of oldLinks) {
|
||||
if (!newLinks.has(removedPageId)) {
|
||||
// Fire async to avoid dispatching during appendTransaction
|
||||
setTimeout(() => {
|
||||
if (tiptapEditor.storage.pageLinkDeletionDetector.skipDetection) {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = false;
|
||||
return;
|
||||
}
|
||||
// Undo the deletion immediately so the link reappears
|
||||
tiptapEditor.commands.undo();
|
||||
// Pass a redo function so the caller can re-delete if confirmed
|
||||
onPageLinkDeletedRef.current?.(
|
||||
removedPageId,
|
||||
() => {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = true;
|
||||
tiptapEditor.commands.redo();
|
||||
},
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: content ? JSON.parse(content) : undefined,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onUpdate(JSON.stringify(e.getJSON()));
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm prose-p:my-1 prose-headings:mb-1 prose-headings:mt-3 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-blockquote:my-1 prose-pre:my-1 prose-hr:my-2 dark:prose-invert max-w-none focus:outline-none min-h-[400px] pl-11 pr-4 py-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
[pageId],
|
||||
);
|
||||
|
||||
// Wire the onSubpageCreate callback into editor storage
|
||||
useEffect(() => {
|
||||
if (editor && onSubpageCreate) {
|
||||
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => {
|
||||
onSubpageCreate(ed);
|
||||
};
|
||||
}
|
||||
}, [editor, onSubpageCreate]);
|
||||
|
||||
// Handle page link clicks via custom event
|
||||
const handlePageLinkClick = useCallback(
|
||||
(e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.pageId && onPageLinkClick) {
|
||||
onPageLinkClick(detail.pageId);
|
||||
}
|
||||
},
|
||||
[onPageLinkClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("page-link-click", handlePageLinkClick);
|
||||
return () =>
|
||||
el.removeEventListener("page-link-click", handlePageLinkClick);
|
||||
}, [handlePageLinkClick]);
|
||||
|
||||
// Floating drag handle: track which block the mouse is over
|
||||
const [handlePos, setHandlePos] = useState<{ top: number; height: number } | null>(null);
|
||||
const blockElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
// If hovering the handle itself, keep current position
|
||||
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
|
||||
|
||||
const editorEl = containerRef.current?.querySelector(".ProseMirror");
|
||||
if (!editorEl || !editor) return;
|
||||
|
||||
// Walk from event target up to a direct child of .ProseMirror
|
||||
let target = e.target as HTMLElement;
|
||||
while (target && target !== editorEl && target.parentElement !== editorEl) {
|
||||
target = target.parentElement!;
|
||||
}
|
||||
|
||||
if (target && target !== editorEl && target.parentElement === editorEl) {
|
||||
blockElRef.current = target;
|
||||
const editorRect = editorEl.getBoundingClientRect();
|
||||
const blockRect = target.getBoundingClientRect();
|
||||
setHandlePos({
|
||||
top: blockRect.top - editorRect.top,
|
||||
height: blockRect.height,
|
||||
});
|
||||
|
||||
// Track top-level block index for block selection
|
||||
try {
|
||||
const pos = editor.view.posAtDOM(target, 0);
|
||||
blockIndexRef.current = editor.view.state.doc.resolve(pos).index(0);
|
||||
} catch {
|
||||
blockIndexRef.current = null;
|
||||
}
|
||||
}
|
||||
// Don't clear — only onMouseLeave clears
|
||||
}, [editor]);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setHandlePos(null);
|
||||
blockElRef.current = null;
|
||||
blockIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Click on drag handle → select block (Shift+click extends)
|
||||
const onHandleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
const idx = blockIndexRef.current;
|
||||
if (idx == null) return;
|
||||
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const existing = savedBlockSelRef.current;
|
||||
|
||||
let newSel;
|
||||
if (e.shiftKey && existing) {
|
||||
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
|
||||
} else {
|
||||
newSel = { anchorIndex: idx, headIndex: idx };
|
||||
}
|
||||
|
||||
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
editor.view.dispatch(tr);
|
||||
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
|
||||
editor.view.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Add a new empty paragraph below the hovered block
|
||||
const onHandleAdd = useCallback(() => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
const view = editor.view;
|
||||
try {
|
||||
const pos = view.posAtDOM(blockElRef.current, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const after = $pos.after($pos.depth);
|
||||
const paragraph = view.state.schema.nodes.paragraph.create();
|
||||
const tr = view.state.tr.insert(after, paragraph);
|
||||
// Place cursor inside the new paragraph
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Initiate ProseMirror-native drag when handle is dragged
|
||||
const onHandleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
|
||||
const view = editor.view;
|
||||
const el = blockElRef.current;
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const bsel = savedBlockSelRef.current;
|
||||
|
||||
try {
|
||||
// Multi-block drag: if block selection is active and hovered block is in range
|
||||
if (bsel && blockIndexRef.current != null) {
|
||||
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
|
||||
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
|
||||
|
||||
if (blockIndexRef.current >= from && blockIndexRef.current <= to) {
|
||||
const blockRange = getBlockRange(view.state, bsel);
|
||||
if (blockRange) {
|
||||
const nodes: PmNode[] = [];
|
||||
let idx = 0;
|
||||
view.state.doc.forEach((node) => {
|
||||
if (idx >= from && idx <= to) nodes.push(node);
|
||||
idx++;
|
||||
});
|
||||
|
||||
const sel = TextSelection.create(
|
||||
view.state.doc,
|
||||
blockRange.fromPos,
|
||||
blockRange.toPos,
|
||||
);
|
||||
const tr = view.state.tr.setSelection(sel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
|
||||
view.dragging = {
|
||||
slice: new Slice(Fragment.from(nodes), 0, 0),
|
||||
move: true,
|
||||
};
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-block drag (existing behavior)
|
||||
const pos = view.posAtDOM(el, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const before = $pos.before($pos.depth);
|
||||
|
||||
const sel = NodeSelection.create(view.state.doc, before);
|
||||
view.dispatch(view.state.tr.setSelection(sel));
|
||||
|
||||
view.dragging = { slice: sel.content(), move: true };
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{handlePos && (
|
||||
<div
|
||||
data-block-handle-row
|
||||
className="absolute left-0 flex items-start z-10"
|
||||
style={{ top: handlePos.top + 1 }}
|
||||
>
|
||||
<div
|
||||
onClick={onHandleAdd}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<div
|
||||
data-drag-handle
|
||||
draggable
|
||||
onMouseDown={() => {
|
||||
if (editor) {
|
||||
savedBlockSelRef.current = blockSelectionKey.getState(editor.view.state) ?? null;
|
||||
}
|
||||
}}
|
||||
onClick={onHandleClick}
|
||||
onDragStart={onHandleDragStart}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/web/src/components/editor/slash-command-items.ts
Normal file
86
packages/web/src/components/editor/slash-command-items.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
export interface SlashCommandItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
/** If true, the action reads onSubpageCreate from editor.storage.slashCommands */
|
||||
isSubpage?: boolean;
|
||||
action: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
export const slashCommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
label: "Heading 1",
|
||||
icon: "H1",
|
||||
description: "Large heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 2",
|
||||
icon: "H2",
|
||||
description: "Medium heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 3",
|
||||
icon: "H3",
|
||||
description: "Small heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Bullet List",
|
||||
icon: "UL",
|
||||
description: "Unordered list",
|
||||
action: (editor) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
label: "Numbered List",
|
||||
icon: "OL",
|
||||
description: "Ordered list",
|
||||
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
label: "Code Block",
|
||||
icon: "<>",
|
||||
description: "Code snippet",
|
||||
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
label: "Quote",
|
||||
icon: "\"",
|
||||
description: "Block quote",
|
||||
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
label: "Divider",
|
||||
icon: "---",
|
||||
description: "Horizontal rule",
|
||||
action: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
label: "Table",
|
||||
icon: "T#",
|
||||
description: "Insert a table",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
},
|
||||
{
|
||||
label: "Subpage",
|
||||
icon: "\uD83D\uDCC4",
|
||||
description: "Create a linked subpage",
|
||||
isSubpage: true,
|
||||
action: (editor) => {
|
||||
const callback = editor.storage.slashCommands
|
||||
?.onSubpageCreate as
|
||||
| ((editor: Editor) => void)
|
||||
| undefined;
|
||||
if (callback) {
|
||||
callback(editor);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
90
packages/web/src/components/execution/BreakdownSection.tsx
Normal file
90
packages/web/src/components/execution/BreakdownSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
|
||||
interface BreakdownSectionProps {
|
||||
initiativeId: string;
|
||||
phasesLoaded: boolean;
|
||||
phases: Array<{ status: string }>;
|
||||
}
|
||||
|
||||
export function BreakdownSection({
|
||||
initiativeId,
|
||||
phasesLoaded,
|
||||
phases,
|
||||
}: BreakdownSectionProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Breakdown agent tracking
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
const breakdownAgent = useMemo(() => {
|
||||
const candidates = allAgents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [allAgents, initiativeId]);
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
|
||||
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
showToast: false, // We show our own error UI
|
||||
});
|
||||
|
||||
const handleBreakdown = useCallback(() => {
|
||||
breakdownSpawn.spawn({ initiativeId });
|
||||
}, [initiativeId, breakdownSpawn]);
|
||||
|
||||
// Don't render if we have phases
|
||||
if (phasesLoaded && phases.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render during loading
|
||||
if (!phasesLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<p className="text-muted-foreground">No phases yet</p>
|
||||
{isBreakdownRunning ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Breaking down initiative...
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBreakdown}
|
||||
disabled={breakdownSpawn.isSpawning}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{breakdownSpawn.isSpawning
|
||||
? "Starting..."
|
||||
: "Break Down Initiative"}
|
||||
</Button>
|
||||
)}
|
||||
{breakdownSpawn.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{breakdownSpawn.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
packages/web/src/components/execution/ExecutionContext.tsx
Normal file
150
packages/web/src/components/execution/ExecutionContext.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaskCounts {
|
||||
complete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FlatTaskEntry {
|
||||
task: SerializedTask;
|
||||
phaseName: string;
|
||||
agentName: string | null;
|
||||
blockedBy: Array<{ name: string; status: string }>;
|
||||
dependents: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface PhaseData {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExecutionContextValue {
|
||||
// Task selection
|
||||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (taskId: string | null) => void;
|
||||
|
||||
// Task counts by phase
|
||||
taskCountsByPhase: Record<string, TaskCounts>;
|
||||
handleTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
|
||||
// Tasks by phase
|
||||
tasksByPhase: Record<string, FlatTaskEntry[]>;
|
||||
handleRegisterTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
|
||||
// Derived data
|
||||
allFlatTasks: FlatTaskEntry[];
|
||||
selectedEntry: FlatTaskEntry | null;
|
||||
tasksComplete: number;
|
||||
tasksTotal: number;
|
||||
}
|
||||
|
||||
const ExecutionContext = createContext<ExecutionContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExecutionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ExecutionProvider({ children }: ExecutionProviderProps) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||
Record<string, TaskCounts>
|
||||
>({});
|
||||
const [tasksByPhase, setTasksByPhase] = useState<
|
||||
Record<string, FlatTaskEntry[]>
|
||||
>({});
|
||||
|
||||
const handleTaskCounts = useCallback(
|
||||
(phaseId: string, counts: TaskCounts) => {
|
||||
setTaskCountsByPhase((prev) => {
|
||||
if (
|
||||
prev[phaseId]?.complete === counts.complete &&
|
||||
prev[phaseId]?.total === counts.total
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [phaseId]: counts };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRegisterTasks = useCallback(
|
||||
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||
setTasksByPhase((prev) => {
|
||||
if (prev[phaseId] === entries) return prev;
|
||||
return { ...prev, [phaseId]: entries };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const allFlatTasks = useMemo(
|
||||
() => Object.values(tasksByPhase).flat(),
|
||||
[tasksByPhase]
|
||||
);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => selectedTaskId
|
||||
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||
: null,
|
||||
[selectedTaskId, allFlatTasks]
|
||||
);
|
||||
|
||||
const { tasksComplete, tasksTotal } = useMemo(() => {
|
||||
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||
return {
|
||||
tasksComplete: allTaskCounts.reduce((s, c) => s + c.complete, 0),
|
||||
tasksTotal: allTaskCounts.reduce((s, c) => s + c.total, 0),
|
||||
};
|
||||
}, [taskCountsByPhase]);
|
||||
|
||||
const value: ExecutionContextValue = {
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
taskCountsByPhase,
|
||||
handleTaskCounts,
|
||||
tasksByPhase,
|
||||
handleRegisterTasks,
|
||||
allFlatTasks,
|
||||
selectedEntry,
|
||||
tasksComplete,
|
||||
tasksTotal,
|
||||
};
|
||||
|
||||
return (
|
||||
<ExecutionContext.Provider value={value}>
|
||||
{children}
|
||||
</ExecutionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useExecutionContext() {
|
||||
const context = useContext(ExecutionContext);
|
||||
if (!context) {
|
||||
throw new Error("useExecutionContext must be used within ExecutionProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
60
packages/web/src/components/execution/PhaseActions.tsx
Normal file
60
packages/web/src/components/execution/PhaseActions.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface PhaseActionsProps {
|
||||
initiativeId: string;
|
||||
phases: Array<{ id: string; status: string }>;
|
||||
}
|
||||
|
||||
export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) {
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
|
||||
// Breakdown agent tracking for status display
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
const breakdownAgent = useMemo(() => {
|
||||
const candidates = allAgents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [allAgents, initiativeId]);
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isBreakdownRunning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Breaking down...
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
>
|
||||
Queue All
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
packages/web/src/components/execution/PhaseWithTasks.tsx
Normal file
137
packages/web/src/components/execution/PhaseWithTasks.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||
import { PlanTasksFetcher } from "./PlanTasksFetcher";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import type { TaskCounts, FlatTaskEntry } from "./ExecutionContext";
|
||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
||||
|
||||
interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
export function PhaseWithTasks({
|
||||
phase,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksProps) {
|
||||
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
|
||||
const plans = plansQuery.data ?? [];
|
||||
const planIds = plans.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<PhaseWithTasksInner
|
||||
phase={phase}
|
||||
planIds={planIds}
|
||||
plansLoaded={plansQuery.isSuccess}
|
||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||
defaultExpanded={defaultExpanded}
|
||||
onTaskClick={onTaskClick}
|
||||
onTaskCounts={onTaskCounts}
|
||||
registerTasks={registerTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PhaseWithTasksInnerProps {
|
||||
phase: PhaseWithTasksProps["phase"];
|
||||
planIds: string[];
|
||||
plansLoaded: boolean;
|
||||
phaseDependencyIds: string[];
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasksInner({
|
||||
phase,
|
||||
planIds,
|
||||
plansLoaded,
|
||||
phaseDependencyIds: _phaseDependencyIds,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksInnerProps) {
|
||||
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handlePlanTasks = useCallback(
|
||||
(planId: string, tasks: SerializedTask[]) => {
|
||||
setPlanTasks((prev) => {
|
||||
if (prev[planId] === tasks) return prev;
|
||||
return { ...prev, [planId]: tasks };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Propagate derived counts and entries outside the setState updater
|
||||
// to avoid synchronous setState-inside-setState cascades.
|
||||
useEffect(() => {
|
||||
const allTasks = Object.values(planTasks).flat();
|
||||
const complete = allTasks.filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||
|
||||
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
agentName: null,
|
||||
blockedBy: [],
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
}, [planTasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
|
||||
|
||||
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||
const sortedTasks = sortByPriorityAndQueueTime(allTasks);
|
||||
const taskEntries = sortedTasks.map((task) => ({
|
||||
task,
|
||||
agentName: null as string | null,
|
||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{plansLoaded &&
|
||||
planIds.map((planId) => (
|
||||
<PlanTasksFetcher
|
||||
key={planId}
|
||||
planId={planId}
|
||||
onTasks={handlePlanTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PhaseAccordion
|
||||
phase={phase}
|
||||
tasks={taskEntries}
|
||||
defaultExpanded={defaultExpanded}
|
||||
phaseDependencies={phaseDeps}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
packages/web/src/components/execution/PhasesList.tsx
Normal file
74
packages/web/src/components/execution/PhasesList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
|
||||
import { PhaseWithTasks } from "./PhaseWithTasks";
|
||||
import { BreakdownSection } from "./BreakdownSection";
|
||||
|
||||
interface PhasesListProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
}
|
||||
|
||||
export function PhasesList({
|
||||
initiativeId,
|
||||
phases,
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
}: PhasesListProps) {
|
||||
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
|
||||
useExecutionContext();
|
||||
|
||||
const firstIncompletePhaseIndex = phases.findIndex(
|
||||
(p) => p.status !== "completed",
|
||||
);
|
||||
|
||||
if (phasesLoading) {
|
||||
return (
|
||||
<div className="space-y-1 pt-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phasesLoaded && phases.length === 0) {
|
||||
return (
|
||||
<BreakdownSection
|
||||
initiativeId={initiativeId}
|
||||
phasesLoaded={phasesLoaded}
|
||||
phases={phases}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{phasesLoaded &&
|
||||
phases.map((phase, idx) => {
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
};
|
||||
|
||||
return (
|
||||
<PhaseWithTasks
|
||||
key={phase.id}
|
||||
phase={serializedPhase}
|
||||
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onTaskCounts={handleTaskCounts}
|
||||
registerTasks={handleRegisterTasks}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
packages/web/src/components/execution/PlanTasksFetcher.tsx
Normal file
20
packages/web/src/components/execution/PlanTasksFetcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
interface PlanTasksFetcherProps {
|
||||
planId: string;
|
||||
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||
}
|
||||
|
||||
export function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||
|
||||
useEffect(() => {
|
||||
if (tasksQuery.data) {
|
||||
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
|
||||
}
|
||||
}, [tasksQuery.data, planId, onTasks]);
|
||||
|
||||
return null;
|
||||
}
|
||||
28
packages/web/src/components/execution/ProgressSidebar.tsx
Normal file
28
packages/web/src/components/execution/ProgressSidebar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ProgressPanel } from "@/components/ProgressPanel";
|
||||
import { DecisionList } from "@/components/DecisionList";
|
||||
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
|
||||
|
||||
interface ProgressSidebarProps {
|
||||
phases: PhaseData[];
|
||||
}
|
||||
|
||||
export function ProgressSidebar({ phases }: ProgressSidebarProps) {
|
||||
const { tasksComplete, tasksTotal } = useExecutionContext();
|
||||
|
||||
const phasesComplete = phases.filter(
|
||||
(p) => p.status === "completed",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProgressPanel
|
||||
phasesComplete={phasesComplete}
|
||||
phasesTotal={phases.length}
|
||||
tasksComplete={tasksComplete}
|
||||
tasksTotal={tasksTotal}
|
||||
/>
|
||||
|
||||
<DecisionList decisions={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
packages/web/src/components/execution/TaskModal.tsx
Normal file
34
packages/web/src/components/execution/TaskModal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import { TaskDetailModal } from "@/components/TaskDetailModal";
|
||||
import { useExecutionContext } from "./ExecutionContext";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
export function TaskModal() {
|
||||
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||
|
||||
const handleQueueTask = useCallback(
|
||||
(taskId: string) => {
|
||||
queueTaskMutation.mutate({ taskId });
|
||||
setSelectedTaskId(null);
|
||||
},
|
||||
[queueTaskMutation, setSelectedTaskId],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedTaskId(null);
|
||||
}, [setSelectedTaskId]);
|
||||
|
||||
return (
|
||||
<TaskDetailModal
|
||||
task={selectedEntry?.task ?? null}
|
||||
phaseName={selectedEntry?.phaseName ?? ""}
|
||||
agentName={selectedEntry?.agentName ?? null}
|
||||
dependencies={selectedEntry?.blockedBy ?? []}
|
||||
dependents={selectedEntry?.dependents ?? []}
|
||||
onClose={handleClose}
|
||||
onQueueTask={handleQueueTask}
|
||||
onStopTask={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
packages/web/src/components/execution/index.ts
Normal file
9
packages/web/src/components/execution/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
|
||||
export { BreakdownSection } from "./BreakdownSection";
|
||||
export { PhaseActions } from "./PhaseActions";
|
||||
export { PhasesList } from "./PhasesList";
|
||||
export { PhaseWithTasks } from "./PhaseWithTasks";
|
||||
export { PlanTasksFetcher } from "./PlanTasksFetcher";
|
||||
export { ProgressSidebar } from "./ProgressSidebar";
|
||||
export { TaskModal } from "./TaskModal";
|
||||
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
||||
18
packages/web/src/hooks/index.ts
Normal file
18
packages/web/src/hooks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared React hooks for the Codewalk District frontend.
|
||||
*
|
||||
* This module provides reusable hooks for common patterns like
|
||||
* debouncing, subscription management, and agent interactions.
|
||||
*/
|
||||
|
||||
export { useAutoSave } from './useAutoSave.js';
|
||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||
export { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
export type {
|
||||
RefineAgentState,
|
||||
ContentProposal,
|
||||
SpawnRefineAgentOptions,
|
||||
UseRefineAgentResult,
|
||||
} from './useRefineAgent.js';
|
||||
68
packages/web/src/hooks/useAutoSave.ts
Normal file
68
packages/web/src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface UseAutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions = {}) {
|
||||
const updateMutation = trpc.updatePage.useMutation({ onSuccess: onSaved });
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
id: string;
|
||||
title?: string;
|
||||
content?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
// Prevent unhandled rejection when called from debounce timer
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [updateMutation]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, data: { title?: string; content?: string | null }) => {
|
||||
pendingRef.current = { id, ...data };
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => void flush(), debounceMs);
|
||||
},
|
||||
[debounceMs, flush],
|
||||
);
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
// Fire off the save — mutation will complete asynchronously
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
updateMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
save,
|
||||
flush,
|
||||
isSaving: updateMutation.isPending,
|
||||
};
|
||||
}
|
||||
157
packages/web/src/hooks/useDebounce.ts
Normal file
157
packages/web/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that debounces a value, delaying updates until after a specified delay.
|
||||
*
|
||||
* Useful for delaying API calls, search queries, or other expensive operations
|
||||
* until the user has stopped typing or interacting.
|
||||
*
|
||||
* @param value - The value to debounce
|
||||
* @param delayMs - Delay in milliseconds (default: 500)
|
||||
* @returns The debounced value
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SearchInput() {
|
||||
* const [query, setQuery] = useState('');
|
||||
* const debouncedQuery = useDebounce(query, 300);
|
||||
*
|
||||
* // This effect will only run when debouncedQuery changes
|
||||
* useEffect(() => {
|
||||
* if (debouncedQuery) {
|
||||
* performSearch(debouncedQuery);
|
||||
* }
|
||||
* }, [debouncedQuery]);
|
||||
*
|
||||
* return (
|
||||
* <input
|
||||
* value={query}
|
||||
* onChange={(e) => setQuery(e.target.value)}
|
||||
* placeholder="Search..."
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delayMs: number = 500): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delayMs);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, delayMs]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative debounce hook that also provides immediate control.
|
||||
*
|
||||
* Returns both the debounced value and a function to immediately update it,
|
||||
* useful when you need to bypass the debounce in certain cases.
|
||||
*
|
||||
* @param value - The value to debounce
|
||||
* @param delayMs - Delay in milliseconds (default: 500)
|
||||
* @returns Object with debouncedValue and setImmediate function
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function AutoSaveForm() {
|
||||
* const [formData, setFormData] = useState({ title: '', content: '' });
|
||||
* const { debouncedValue: debouncedFormData, setImmediate } = useDebounceWithImmediate(formData, 1000);
|
||||
*
|
||||
* // Auto-save after 1 second of no changes
|
||||
* useEffect(() => {
|
||||
* saveFormData(debouncedFormData);
|
||||
* }, [debouncedFormData]);
|
||||
*
|
||||
* const handleSubmit = () => {
|
||||
* // Immediately save without waiting for debounce
|
||||
* setImmediate(formData);
|
||||
* submitForm(formData);
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <form onSubmit={handleSubmit}>
|
||||
* <input
|
||||
* value={formData.title}
|
||||
* onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
* />
|
||||
* <button type="submit">Submit</button>
|
||||
* </form>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useDebounceWithImmediate<T>(value: T, delayMs: number = 500) {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delayMs);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [value, delayMs]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setImmediate = (newValue: T) => {
|
||||
// Clear pending timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Immediately update debounced value
|
||||
setDebouncedValue(newValue);
|
||||
};
|
||||
|
||||
return {
|
||||
debouncedValue,
|
||||
setImmediate,
|
||||
};
|
||||
}
|
||||
253
packages/web/src/hooks/useRefineAgent.ts
Normal file
253
packages/web/src/hooks/useRefineAgent.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { Agent, PendingQuestions } from '@codewalk-district/shared';
|
||||
|
||||
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||
|
||||
export interface ContentProposal {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
summary: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface SpawnRefineAgentOptions {
|
||||
initiativeId: string;
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export interface UseRefineAgentResult {
|
||||
/** Current refine agent for the initiative */
|
||||
agent: Agent | null;
|
||||
/** Current state of the refine agent */
|
||||
state: RefineAgentState;
|
||||
/** Questions from the agent (when state is 'waiting') */
|
||||
questions: PendingQuestions | null;
|
||||
/** Parsed content proposals (when state is 'completed') */
|
||||
proposals: ContentProposal[] | null;
|
||||
/** Raw result message (when state is 'completed') */
|
||||
result: string | null;
|
||||
/** Mutation for spawning a new refine agent */
|
||||
spawn: {
|
||||
mutate: (options: SpawnRefineAgentOptions) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
/** Mutation for resuming agent with answers */
|
||||
resume: {
|
||||
mutate: (answers: Record<string, string>) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
/** Whether any queries are loading */
|
||||
isLoading: boolean;
|
||||
/** Function to refresh agent data */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing refine agents for a specific initiative.
|
||||
*
|
||||
* Encapsulates the logic for finding, spawning, and interacting with refine agents
|
||||
* that analyze and suggest improvements to initiative content.
|
||||
*
|
||||
* @param initiativeId - The ID of the initiative to manage refine agents for
|
||||
* @returns Object with agent state, mutations, and helper functions
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function RefineSection({ initiativeId }: { initiativeId: string }) {
|
||||
* const {
|
||||
* state,
|
||||
* agent,
|
||||
* questions,
|
||||
* proposals,
|
||||
* spawn,
|
||||
* resume,
|
||||
* refresh
|
||||
* } = useRefineAgent(initiativeId);
|
||||
*
|
||||
* const handleSpawn = () => {
|
||||
* spawn.mutate({
|
||||
* initiativeId,
|
||||
* instruction: 'Focus on clarity and structure'
|
||||
* });
|
||||
* };
|
||||
*
|
||||
* if (state === 'none') {
|
||||
* return (
|
||||
* <button onClick={handleSpawn} disabled={spawn.isPending}>
|
||||
* Start Refine Agent
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* if (state === 'waiting' && questions) {
|
||||
* return (
|
||||
* <QuestionForm
|
||||
* questions={questions.questions}
|
||||
* onSubmit={(answers) => resume.mutate(answers)}
|
||||
* isSubmitting={resume.isPending}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* if (state === 'completed' && proposals) {
|
||||
* return <ProposalReview proposals={proposals} onDismiss={refresh} />;
|
||||
* }
|
||||
*
|
||||
* return <div>Agent is {state}...</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Query all agents and find the active refine agent
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const agents = agentsQuery.data ?? [];
|
||||
|
||||
const agent = useMemo(() => {
|
||||
// Find the most recent refine agent for this initiative
|
||||
const candidates = agents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === 'refine' &&
|
||||
a.initiativeId === initiativeId &&
|
||||
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
||||
!a.userDismissedAt, // Exclude dismissed agents
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [agents, initiativeId]);
|
||||
|
||||
const state: RefineAgentState = useMemo(() => {
|
||||
if (!agent) return 'none';
|
||||
switch (agent.status) {
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'waiting_for_input':
|
||||
return 'waiting';
|
||||
case 'idle':
|
||||
return 'completed';
|
||||
case 'crashed':
|
||||
return 'crashed';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
// Fetch questions when waiting for input
|
||||
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||
{ id: agent?.id ?? '' },
|
||||
{ enabled: state === 'waiting' && !!agent },
|
||||
);
|
||||
|
||||
// Fetch result when completed
|
||||
const resultQuery = trpc.getAgentResult.useQuery(
|
||||
{ id: agent?.id ?? '' },
|
||||
{ enabled: state === 'completed' && !!agent },
|
||||
);
|
||||
|
||||
// Parse proposals from result
|
||||
const { proposals, result } = useMemo(() => {
|
||||
if (!resultQuery.data?.success || !resultQuery.data.message) {
|
||||
return { proposals: null, result: null };
|
||||
}
|
||||
|
||||
const message = resultQuery.data.message;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (parsed.proposals && Array.isArray(parsed.proposals)) {
|
||||
const proposals: ContentProposal[] = parsed.proposals.map(
|
||||
(p: { pageId: string; title?: string; pageTitle?: string; summary: string; body?: string; markdown?: string }) => ({
|
||||
pageId: p.pageId,
|
||||
pageTitle: p.pageTitle ?? p.title ?? '',
|
||||
summary: p.summary,
|
||||
markdown: p.markdown ?? p.body ?? '',
|
||||
}),
|
||||
);
|
||||
return { proposals, result: message };
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as regular result
|
||||
}
|
||||
|
||||
return { proposals: null, result: message };
|
||||
}, [resultQuery.data]);
|
||||
|
||||
// Spawn mutation
|
||||
const spawnMutation = trpc.spawnArchitectRefine.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Resume mutation
|
||||
const resumeMutation = trpc.resumeAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Keep mutation functions in refs so the returned spawn/resume objects are
|
||||
// stable across renders. tRPC mutation objects change identity every render,
|
||||
// which cascades into unstable callbacks → unstable props → Radix Dialog
|
||||
// re-renders that trigger the React 19 compose-refs infinite loop.
|
||||
const spawnMutateRef = useRef(spawnMutation.mutate);
|
||||
spawnMutateRef.current = spawnMutation.mutate;
|
||||
const agentRef = useRef(agent);
|
||||
agentRef.current = agent;
|
||||
const resumeMutateRef = useRef(resumeMutation.mutate);
|
||||
resumeMutateRef.current = resumeMutation.mutate;
|
||||
|
||||
const spawnFn = useCallback(({ initiativeId, instruction }: SpawnRefineAgentOptions) => {
|
||||
spawnMutateRef.current({
|
||||
initiativeId,
|
||||
instruction: instruction?.trim() || undefined,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const spawn = useMemo(() => ({
|
||||
mutate: spawnFn,
|
||||
isPending: spawnMutation.isPending,
|
||||
error: spawnMutation.error,
|
||||
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
|
||||
|
||||
const resumeFn = useCallback((answers: Record<string, string>) => {
|
||||
const a = agentRef.current;
|
||||
if (a) {
|
||||
resumeMutateRef.current({ id: a.id, answers });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useMemo(() => ({
|
||||
mutate: resumeFn,
|
||||
isPending: resumeMutation.isPending,
|
||||
error: resumeMutation.error,
|
||||
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void utils.listAgents.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const isLoading = agentsQuery.isLoading ||
|
||||
(state === 'waiting' && questionsQuery.isLoading) ||
|
||||
(state === 'completed' && resultQuery.isLoading);
|
||||
|
||||
return {
|
||||
agent,
|
||||
state,
|
||||
questions: questionsQuery.data ?? null,
|
||||
proposals,
|
||||
result,
|
||||
spawn,
|
||||
resume,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
49
packages/web/src/hooks/useSpawnMutation.ts
Normal file
49
packages/web/src/hooks/useSpawnMutation.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SpawnMutationOptions {
|
||||
onSuccess?: () => void;
|
||||
showToast?: boolean;
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function useSpawnMutation<T>(
|
||||
mutationFn: any,
|
||||
options: SpawnMutationOptions = {}
|
||||
) {
|
||||
const {
|
||||
onSuccess,
|
||||
showToast = true,
|
||||
successMessage = "Architect spawned",
|
||||
errorMessage = "Failed to spawn architect",
|
||||
} = options;
|
||||
|
||||
const mutation = mutationFn({
|
||||
onSuccess: () => {
|
||||
if (showToast) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: () => {
|
||||
if (showToast) {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const spawn = useCallback(
|
||||
(params: T) => {
|
||||
mutation.mutate(params);
|
||||
},
|
||||
[mutation]
|
||||
);
|
||||
|
||||
return {
|
||||
spawn,
|
||||
isSpawning: mutation.isPending,
|
||||
error: mutation.error?.message,
|
||||
isError: mutation.isError,
|
||||
};
|
||||
}
|
||||
180
packages/web/src/hooks/useSubscriptionWithErrorHandling.ts
Normal file
180
packages/web/src/hooks/useSubscriptionWithErrorHandling.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { SubscriptionEvent } from '@codewalk-district/shared';
|
||||
|
||||
interface UseSubscriptionWithErrorHandlingOptions {
|
||||
/** Called when subscription receives data */
|
||||
onData?: (data: SubscriptionEvent) => void;
|
||||
/** Called when subscription encounters an error */
|
||||
onError?: (error: Error) => void;
|
||||
/** Called when subscription starts */
|
||||
onStarted?: () => void;
|
||||
/** Called when subscription stops */
|
||||
onStopped?: () => void;
|
||||
/** Whether to automatically reconnect on errors (default: true) */
|
||||
autoReconnect?: boolean;
|
||||
/** Delay before attempting reconnection in ms (default: 1000) */
|
||||
reconnectDelay?: number;
|
||||
/** Maximum number of reconnection attempts (default: 5) */
|
||||
maxReconnectAttempts?: number;
|
||||
/** Whether the subscription is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface SubscriptionState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: Error | null;
|
||||
reconnectAttempts: number;
|
||||
lastEventId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing tRPC subscriptions with error handling, reconnection, and cleanup.
|
||||
*
|
||||
* Provides automatic reconnection on connection failures, tracks connection state,
|
||||
* and ensures proper cleanup on unmount.
|
||||
*/
|
||||
export function useSubscriptionWithErrorHandling(
|
||||
subscription: () => ReturnType<typeof trpc.subscribeToEvents.useSubscription>,
|
||||
options: UseSubscriptionWithErrorHandlingOptions = {}
|
||||
) {
|
||||
const {
|
||||
autoReconnect = true,
|
||||
reconnectDelay = 1000,
|
||||
maxReconnectAttempts = 5,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<SubscriptionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
lastEventId: null,
|
||||
});
|
||||
|
||||
// Store callbacks in refs so they never appear in effect deps.
|
||||
// Callers pass inline arrows that change identity every render —
|
||||
// putting them in deps causes setState → re-render → new callback → effect re-fire → infinite loop.
|
||||
const callbacksRef = useRef(options);
|
||||
callbacksRef.current = options;
|
||||
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Clear reconnect timeout on unmount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
callbacksRef.current.onStopped?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!autoReconnect || reconnectAttemptsRef.current >= maxReconnectAttempts || !mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: true,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
}));
|
||||
}
|
||||
}, reconnectDelay);
|
||||
}, [autoReconnect, maxReconnectAttempts, reconnectDelay]);
|
||||
|
||||
const subscriptionResult = subscription();
|
||||
|
||||
// Handle subscription state changes.
|
||||
// Only depends on primitive/stable values — never on caller callbacks.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState(prev => {
|
||||
if (!prev.isConnected && !prev.isConnecting && prev.error === null) return prev;
|
||||
return { ...prev, isConnected: false, isConnecting: false, error: null };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscriptionResult.status === 'pending') {
|
||||
setState(prev => {
|
||||
if (prev.isConnecting && prev.error === null) return prev;
|
||||
return { ...prev, isConnecting: true, error: null };
|
||||
});
|
||||
callbacksRef.current.onStarted?.();
|
||||
} else if (subscriptionResult.status === 'error') {
|
||||
const error = subscriptionResult.error instanceof Error
|
||||
? subscriptionResult.error
|
||||
: new Error('Subscription error');
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error,
|
||||
}));
|
||||
|
||||
callbacksRef.current.onError?.(error);
|
||||
scheduleReconnect();
|
||||
} else if (subscriptionResult.status === 'success') {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => {
|
||||
if (prev.isConnected && !prev.isConnecting && prev.error === null && prev.reconnectAttempts === 0) return prev;
|
||||
return { ...prev, isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0 };
|
||||
});
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [enabled, subscriptionResult.status, subscriptionResult.error, scheduleReconnect]);
|
||||
|
||||
// Handle incoming data
|
||||
useEffect(() => {
|
||||
if (subscriptionResult.data) {
|
||||
setState(prev => ({ ...prev, lastEventId: subscriptionResult.data.id }));
|
||||
callbacksRef.current.onData?.(subscriptionResult.data);
|
||||
}
|
||||
}, [subscriptionResult.data]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
/** Manually trigger a reconnection attempt */
|
||||
reconnect: () => {
|
||||
if (mountedRef.current) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
}));
|
||||
}
|
||||
},
|
||||
/** Reset error state and reconnection attempts */
|
||||
reset: () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
isConnecting: false,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -60,3 +60,71 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notion-style page link blocks inside the editor */
|
||||
.page-link-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.page-link-block:hover {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.page-link-block svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Block selection highlight */
|
||||
.ProseMirror .block-selected {
|
||||
background-color: hsl(var(--primary) / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.08), -0.375rem 0 0 hsl(var(--primary) / 0.08);
|
||||
}
|
||||
.dark .ProseMirror .block-selected {
|
||||
background-color: hsl(var(--primary) / 0.12);
|
||||
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.12), -0.375rem 0 0 hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
/* Hide cursor and text selection during block selection mode */
|
||||
.ProseMirror.has-block-selection {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.ProseMirror.has-block-selection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Notion-style placeholder on empty blocks */
|
||||
.ProseMirror .is-empty::before {
|
||||
color: hsl(var(--muted-foreground));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Table wrapper overflow */
|
||||
.ProseMirror .tableWrapper { overflow-x: auto; margin: 1em 0; }
|
||||
|
||||
/* Cell positioning for resize handles */
|
||||
.ProseMirror table td, .ProseMirror table th { position: relative; min-width: 50px; vertical-align: top; }
|
||||
|
||||
/* Column resize handle */
|
||||
.ProseMirror .column-resize-handle { position: absolute; right: -2px; top: 0; bottom: -2px; width: 4px; background-color: hsl(var(--primary) / 0.4); pointer-events: none; z-index: 20; }
|
||||
|
||||
/* Resize cursor */
|
||||
.ProseMirror.resize-cursor { cursor: col-resize; }
|
||||
|
||||
/* Selected cell highlight */
|
||||
.ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); }
|
||||
.dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); }
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Link } from '@tanstack/react-router'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Initiatives', to: '/initiatives' },
|
||||
{ label: 'Agents', to: '/agents' },
|
||||
{ label: 'Inbox', to: '/inbox' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
] as const
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
217
packages/web/src/lib/markdown-to-tiptap.ts
Normal file
217
packages/web/src/lib/markdown-to-tiptap.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Markdown to Tiptap JSON converter.
|
||||
*
|
||||
* Converts agent-produced markdown back into Tiptap JSON for page updates.
|
||||
* Uses @tiptap/html's generateJSON to parse HTML into Tiptap nodes.
|
||||
*/
|
||||
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
||||
|
||||
/**
|
||||
* Convert markdown string to Tiptap JSON document.
|
||||
*/
|
||||
export function markdownToTiptapJson(markdown: string): object {
|
||||
const html = markdownToHtml(markdown);
|
||||
return generateJSON(html, [StarterKit, Table, TableRow, TableCell, TableHeader]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown → HTML converter covering StarterKit nodes.
|
||||
* Handles: headings, paragraphs, bold, italic, code, code blocks,
|
||||
* bullet lists, ordered lists, blockquotes, links, horizontal rules, tables.
|
||||
*/
|
||||
function markdownToHtml(md: string): string {
|
||||
// Normalize line endings
|
||||
let text = md.replace(/\r\n/g, '\n');
|
||||
|
||||
// Code blocks (fenced)
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
||||
const escaped = escapeHtml(code.replace(/\n$/, ''));
|
||||
const langAttr = lang ? ` class="language-${lang}"` : '';
|
||||
return `<pre><code${langAttr}>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// Split into lines for block-level processing
|
||||
const lines = text.split('\n');
|
||||
const htmlLines: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip lines inside pre blocks (already handled)
|
||||
if (line.startsWith('<pre>')) {
|
||||
let block = line;
|
||||
while (i < lines.length && !lines[i].includes('</pre>')) {
|
||||
i++;
|
||||
block += '\n' + lines[i];
|
||||
}
|
||||
htmlLines.push(block);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^---+$/.test(line.trim())) {
|
||||
htmlLines.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
htmlLines.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
quoteLines.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
htmlLines.push(`<blockquote><p>${inlineMarkdown(quoteLines.join(' '))}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^[-*]\s+/, ''));
|
||||
i++;
|
||||
}
|
||||
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
|
||||
htmlLines.push(`<ul>${lis}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\d+\.\s+/, ''));
|
||||
i++;
|
||||
}
|
||||
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
|
||||
htmlLines.push(`<ol>${lis}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: current line has | and next line is a separator row
|
||||
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])) {
|
||||
const headerCells = parseTableRow(line);
|
||||
i += 2; // skip header + separator
|
||||
|
||||
const bodyRows: string[][] = [];
|
||||
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
|
||||
bodyRows.push(parseTableRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
|
||||
const ths = headerCells.map((c) => `<th>${inlineMarkdown(c)}</th>`).join('');
|
||||
const thead = `<thead><tr>${ths}</tr></thead>`;
|
||||
|
||||
let tbody = '';
|
||||
if (bodyRows.length > 0) {
|
||||
const trs = bodyRows
|
||||
.map((row) => {
|
||||
const tds = row.map((c) => `<td>${inlineMarkdown(c)}</td>`).join('');
|
||||
return `<tr>${tds}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
tbody = `<tbody>${trs}</tbody>`;
|
||||
}
|
||||
|
||||
htmlLines.push(`<table>${thead}${tbody}</table>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph (collect consecutive non-empty, non-block lines)
|
||||
const paraLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!lines[i].startsWith('#') &&
|
||||
!lines[i].startsWith('> ') &&
|
||||
!/^[-*]\s+/.test(lines[i]) &&
|
||||
!/^\d+\.\s+/.test(lines[i]) &&
|
||||
!/^---+$/.test(lines[i].trim()) &&
|
||||
!lines[i].startsWith('<pre>') &&
|
||||
!lines[i].startsWith('```') &&
|
||||
!isTableStart(lines, i)
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
htmlLines.push(`<p>${inlineMarkdown(paraLines.join(' '))}</p>`);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return htmlLines.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if lines[i] starts a markdown table (has | and next line is separator).
|
||||
*/
|
||||
function isTableStart(lines: string[], i: number): boolean {
|
||||
return (
|
||||
lines[i].includes('|') &&
|
||||
i + 1 < lines.length &&
|
||||
/^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown table row: strip leading/trailing pipes, split on |, trim cells.
|
||||
*/
|
||||
function parseTableRow(line: string): string[] {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
|
||||
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1);
|
||||
return trimmed.split('|').map((c) => c.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Process inline markdown: bold, italic, inline code, links.
|
||||
*/
|
||||
function inlineMarkdown(text: string): string {
|
||||
let result = escapeHtml(text);
|
||||
|
||||
// Inline code (must come before bold/italic to avoid conflicts)
|
||||
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url)
|
||||
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "1 hour ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInSeconds = Math.floor(diffInMs / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return diffInSeconds <= 1 ? "just now" : `${diffInSeconds} seconds ago`;
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return diffInMinutes === 1 ? "1 minute ago" : `${diffInMinutes} minutes ago`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return diffInHours === 1 ? "1 hour ago" : `${diffInHours} hours ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 30) {
|
||||
return diffInDays === 1 ? "1 day ago" : `${diffInDays} days ago`;
|
||||
}
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
if (diffInMonths < 12) {
|
||||
return diffInMonths === 1 ? "1 month ago" : `${diffInMonths} months ago`;
|
||||
}
|
||||
|
||||
const diffInYears = Math.floor(diffInMonths / 12);
|
||||
return diffInYears === 1 ? "1 year ago" : `${diffInYears} years ago`;
|
||||
}
|
||||
|
||||
@@ -9,26 +9,50 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as AgentsRouteImport } from './routes/agents'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
||||
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
||||
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InboxRoute = InboxRouteImport.update({
|
||||
id: '/inbox',
|
||||
path: '/inbox',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsRoute = AgentsRouteImport.update({
|
||||
id: '/agents',
|
||||
path: '/agents',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsIndexRoute = SettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
|
||||
id: '/initiatives/',
|
||||
path: '/initiatives/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsHealthRoute = SettingsHealthRouteImport.update({
|
||||
id: '/health',
|
||||
path: '/health',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
id: '/initiatives/$id',
|
||||
path: '/initiatives/$id',
|
||||
@@ -37,40 +61,84 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives/': typeof InitiativesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives': typeof InitiativesIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives/': typeof InitiativesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives/'
|
||||
| '/settings/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/inbox' | '/initiatives/$id' | '/initiatives'
|
||||
id: '__root__' | '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
|
||||
to:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives'
|
||||
| '/settings'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives/'
|
||||
| '/settings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AgentsRoute: typeof AgentsRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
SettingsRoute: typeof SettingsRouteWithChildren
|
||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||
InitiativesIndexRoute: typeof InitiativesIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/inbox': {
|
||||
id: '/inbox'
|
||||
path: '/inbox'
|
||||
@@ -78,6 +146,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InboxRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents': {
|
||||
id: '/agents'
|
||||
path: '/agents'
|
||||
fullPath: '/agents'
|
||||
preLoaderRoute: typeof AgentsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -85,6 +160,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/': {
|
||||
id: '/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof SettingsIndexRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/initiatives/': {
|
||||
id: '/initiatives/'
|
||||
path: '/initiatives'
|
||||
@@ -92,6 +174,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InitiativesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/health': {
|
||||
id: '/settings/health'
|
||||
path: '/health'
|
||||
fullPath: '/settings/health'
|
||||
preLoaderRoute: typeof SettingsHealthRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/initiatives/$id': {
|
||||
id: '/initiatives/$id'
|
||||
path: '/initiatives/$id'
|
||||
@@ -102,9 +191,25 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface SettingsRouteChildren {
|
||||
SettingsHealthRoute: typeof SettingsHealthRoute
|
||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||
}
|
||||
|
||||
const SettingsRouteChildren: SettingsRouteChildren = {
|
||||
SettingsHealthRoute: SettingsHealthRoute,
|
||||
SettingsIndexRoute: SettingsIndexRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
|
||||
SettingsRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AgentsRoute: AgentsRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
SettingsRoute: SettingsRouteWithChildren,
|
||||
InitiativesIdRoute: InitiativesIdRoute,
|
||||
InitiativesIndexRoute: InitiativesIndexRoute,
|
||||
}
|
||||
|
||||
196
packages/web/src/routes/agents.tsx
Normal file
196
packages/web/src/routes/agents.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
function AgentsPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
|
||||
// Live updates: invalidate agents on agent events with robust error handling
|
||||
const utils = trpc.useUtils();
|
||||
const subscription = useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => {
|
||||
// Clear any existing error toasts when reconnecting
|
||||
toast.dismiss("sub-error");
|
||||
},
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Data fetching
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
|
||||
// Handlers
|
||||
function handleRefresh() {
|
||||
void utils.listAgents.invalidate();
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (agentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-5 w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-2 w-2 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (agentsQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load agents: {agentsQuery.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const selectedAgent = selectedAgentId
|
||||
? agents.find((a) => a.id === selectedAgentId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">Agents</h1>
|
||||
<Badge variant="secondary">{agents.length}</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Left: Agent List */}
|
||||
<div className="space-y-2">
|
||||
{agents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No agents found</p>
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
|
||||
selectedAgentId === agent.id && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={agent.status} />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{agent.mode}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.createdAt))}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Output Viewer */}
|
||||
{selectedAgent ? (
|
||||
<AgentOutputViewer agentId={selectedAgent.id} agentName={selectedAgent.name} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an agent to view output
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-green-500",
|
||||
waiting_for_input: "bg-yellow-500",
|
||||
idle: "bg-zinc-400",
|
||||
stopped: "bg-zinc-400",
|
||||
crashed: "bg-red-500",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full shrink-0", colors[status] ?? "bg-zinc-400")}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InboxList } from "@/components/InboxList";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/inbox")({
|
||||
component: InboxPage,
|
||||
@@ -328,17 +329,3 @@ function InboxPage() {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return "just now";
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -6,247 +6,87 @@ import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
||||
import { ProgressPanel } from "@/components/ProgressPanel";
|
||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||
import { DecisionList } from "@/components/DecisionList";
|
||||
import { TaskDetailModal } from "@/components/TaskDetailModal";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import { ContentTab } from "@/components/editor/ContentTab";
|
||||
import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/$id")({
|
||||
component: InitiativeDetailPage,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Aggregated task counts reported upward from PhaseWithTasks */
|
||||
interface TaskCounts {
|
||||
complete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Flat task entry with metadata needed for the modal */
|
||||
interface FlatTaskEntry {
|
||||
task: SerializedTask;
|
||||
phaseName: string;
|
||||
agentName: string | null;
|
||||
blockedBy: Array<{ name: string; status: string }>;
|
||||
dependents: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PhaseWithTasks — solves the "hooks inside loops" problem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasks({
|
||||
phase,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksProps) {
|
||||
// Fetch all plans for this phase
|
||||
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||
|
||||
// Fetch phase dependencies
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
|
||||
const plans = plansQuery.data ?? [];
|
||||
const planIds = plans.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<PhaseWithTasksInner
|
||||
phase={phase}
|
||||
planIds={planIds}
|
||||
plansLoaded={plansQuery.isSuccess}
|
||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||
defaultExpanded={defaultExpanded}
|
||||
onTaskClick={onTaskClick}
|
||||
onTaskCounts={onTaskCounts}
|
||||
registerTasks={registerTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner component that fetches tasks for each plan — needs stable hook count
|
||||
// Since planIds array changes, we fetch tasks per plan inside yet another child
|
||||
interface PhaseWithTasksInnerProps {
|
||||
phase: PhaseWithTasksProps["phase"];
|
||||
planIds: string[];
|
||||
plansLoaded: boolean;
|
||||
phaseDependencyIds: string[];
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasksInner({
|
||||
phase,
|
||||
planIds,
|
||||
plansLoaded,
|
||||
phaseDependencyIds: _phaseDependencyIds,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksInnerProps) {
|
||||
// We can't call useQuery in a loop, so we render PlanTasksFetcher per plan
|
||||
// and aggregate the results
|
||||
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handlePlanTasks = useCallback(
|
||||
(planId: string, tasks: SerializedTask[]) => {
|
||||
setPlanTasks((prev) => {
|
||||
// Skip if unchanged (same reference)
|
||||
if (prev[planId] === tasks) return prev;
|
||||
const next = { ...prev, [planId]: tasks };
|
||||
|
||||
// Aggregate all tasks across plans
|
||||
const allTasks = Object.values(next).flat();
|
||||
const complete = allTasks.filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
|
||||
// Report counts up
|
||||
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||
|
||||
// Register flat entries for the modal lookup
|
||||
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
agentName: null, // No agent info from task data alone
|
||||
blockedBy: [], // Simplified: no dependency lookup per task in v1
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[phase.id, phase.number, phase.name, onTaskCounts, registerTasks],
|
||||
);
|
||||
|
||||
// Build task entries for PhaseAccordion
|
||||
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||
const taskEntries = allTasks.map((task) => ({
|
||||
task,
|
||||
agentName: null as string | null,
|
||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
// Phase-level dependencies (empty for now — would need to resolve IDs to names)
|
||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden fetchers — one per plan */}
|
||||
{plansLoaded &&
|
||||
planIds.map((planId) => (
|
||||
<PlanTasksFetcher
|
||||
key={planId}
|
||||
planId={planId}
|
||||
onTasks={handlePlanTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PhaseAccordion
|
||||
phase={phase}
|
||||
tasks={taskEntries}
|
||||
defaultExpanded={defaultExpanded}
|
||||
phaseDependencies={phaseDeps}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlanTasksFetcher — fetches tasks for a single plan (stable hook count)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PlanTasksFetcherProps {
|
||||
planId: string;
|
||||
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||
}
|
||||
|
||||
function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||
|
||||
// Report tasks upward via useEffect (not during render) to avoid
|
||||
// setState-during-render loops when the parent re-renders on state update.
|
||||
useEffect(() => {
|
||||
if (tasksQuery.data) {
|
||||
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
|
||||
}
|
||||
}, [tasksQuery.data, planId, onTasks]);
|
||||
|
||||
return null; // Render nothing — this is a data-fetching component
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
type Tab = "content" | "execution";
|
||||
|
||||
function InitiativeDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("content");
|
||||
|
||||
// Live updates: invalidate detail queries on task/phase and agent events
|
||||
// Live updates: keep subscriptions at page level so they work across both tabs
|
||||
const utils = trpc.useUtils();
|
||||
trpc.onTaskUpdate.useSubscription(undefined, {
|
||||
|
||||
// Task updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onTaskUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
void utils.listPlans.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Task updates subscription error:', error);
|
||||
},
|
||||
});
|
||||
trpc.onAgentUpdate.useSubscription(undefined, {
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Agent updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
});
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// State
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||
Record<string, TaskCounts>
|
||||
>({});
|
||||
const [tasksByPhase, setTasksByPhase] = useState<
|
||||
Record<string, FlatTaskEntry[]>
|
||||
>({});
|
||||
// Page updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onPageUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.getRootPage.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Page updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
@@ -255,94 +95,20 @@ function InitiativeDetailPage() {
|
||||
{ enabled: !!initiativeQuery.data },
|
||||
);
|
||||
|
||||
// tRPC mutations
|
||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
|
||||
// Callbacks for PhaseWithTasks
|
||||
const handleTaskCounts = useCallback(
|
||||
(phaseId: string, counts: TaskCounts) => {
|
||||
setTaskCountsByPhase((prev) => {
|
||||
if (
|
||||
prev[phaseId]?.complete === counts.complete &&
|
||||
prev[phaseId]?.total === counts.total
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [phaseId]: counts };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRegisterTasks = useCallback(
|
||||
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||
setTasksByPhase((prev) => {
|
||||
if (prev[phaseId] === entries) return prev;
|
||||
return { ...prev, [phaseId]: entries };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Derived data
|
||||
const phases = phasesQuery.data ?? [];
|
||||
const phasesComplete = phases.filter(
|
||||
(p) => p.status === "completed",
|
||||
).length;
|
||||
|
||||
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||
const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0);
|
||||
const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0);
|
||||
|
||||
// Find selected task across all phases
|
||||
const allFlatTasks = Object.values(tasksByPhase).flat();
|
||||
const selectedEntry = selectedTaskId
|
||||
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||
: null;
|
||||
|
||||
// Determine which phase should be expanded by default (first non-completed)
|
||||
const firstIncompletePhaseIndex = phases.findIndex(
|
||||
(p) => p.status !== "completed",
|
||||
);
|
||||
|
||||
// Queue all pending phases
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
|
||||
// Queue a single task
|
||||
const handleQueueTask = useCallback(
|
||||
(taskId: string) => {
|
||||
queueTaskMutation.mutate({ taskId });
|
||||
setSelectedTaskId(null);
|
||||
},
|
||||
[queueTaskMutation],
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (initiativeQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Two-column grid skeleton */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left: phase accordion skeletons */}
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
</div>
|
||||
|
||||
{/* Right: ProgressPanel + DecisionList skeletons */}
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-24 w-full rounded" />
|
||||
<Skeleton className="h-20 w-full rounded" />
|
||||
@@ -376,109 +142,59 @@ function InitiativeDetailPage() {
|
||||
const initiative = initiativeQuery.data;
|
||||
if (!initiative) return null;
|
||||
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedInitiative = {
|
||||
id: initiative.id,
|
||||
name: initiative.name,
|
||||
status: initiative.status,
|
||||
createdAt: String(initiative.createdAt),
|
||||
updatedAt: String(initiative.updatedAt),
|
||||
};
|
||||
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||
|
||||
const phases = phasesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<InitiativeHeader
|
||||
initiative={serializedInitiative}
|
||||
projects={projects}
|
||||
onBack={() => navigate({ to: "/initiatives" })}
|
||||
/>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<div className="space-y-0">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("content")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "content"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Queue All
|
||||
</Button>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("execution")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "execution"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Phase loading */}
|
||||
{phasesQuery.isLoading && (
|
||||
<div className="space-y-1 pt-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
{/* Tab content */}
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "execution" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phases list */}
|
||||
{phasesQuery.isSuccess && phases.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No phases yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phasesQuery.isSuccess &&
|
||||
phases.map((phase, idx) => {
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
};
|
||||
|
||||
return (
|
||||
<PhaseWithTasks
|
||||
key={phase.id}
|
||||
phase={serializedPhase}
|
||||
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onTaskCounts={handleTaskCounts}
|
||||
registerTasks={handleRegisterTasks}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<div className="space-y-6">
|
||||
<ProgressPanel
|
||||
phasesComplete={phasesComplete}
|
||||
phasesTotal={phases.length}
|
||||
tasksComplete={tasksComplete}
|
||||
tasksTotal={tasksTotal}
|
||||
/>
|
||||
|
||||
<DecisionList decisions={[]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
<TaskDetailModal
|
||||
task={selectedEntry?.task ?? null}
|
||||
phaseName={selectedEntry?.phaseName ?? ""}
|
||||
agentName={selectedEntry?.agentName ?? null}
|
||||
dependencies={selectedEntry?.blockedBy ?? []}
|
||||
dependents={selectedEntry?.dependents ?? []}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onQueueTask={handleQueueTask}
|
||||
onStopTask={() => setSelectedTaskId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
packages/web/src/routes/settings.tsx
Normal file
35
packages/web/src/routes/settings.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsLayout,
|
||||
})
|
||||
|
||||
const settingsTabs = [
|
||||
{ label: 'Health Check', to: '/settings/health' },
|
||||
] as const
|
||||
|
||||
function SettingsLayout() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
</div>
|
||||
<nav className="flex gap-1 border-b border-border">
|
||||
{settingsTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className:
|
||||
'px-4 py-2 text-sm font-medium border-b-2 border-primary text-foreground',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
383
packages/web/src/routes/settings/health.tsx
Normal file
383
packages/web/src/routes/settings/health.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/Skeleton'
|
||||
|
||||
export const Route = createFileRoute('/settings/health')({
|
||||
component: HealthCheckPage,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
if (d > 0) parts.push(`${d}d`)
|
||||
if (h > 0) parts.push(`${h}h`)
|
||||
if (m > 0) parts.push(`${m}m`)
|
||||
if (s > 0 || parts.length === 0) parts.push(`${s}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatResetTime(isoDate: string): string {
|
||||
const now = Date.now()
|
||||
const target = new Date(isoDate).getTime()
|
||||
const diffMs = target - now
|
||||
if (diffMs <= 0) return 'now'
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const totalDays = Math.floor(totalHours / 24)
|
||||
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours - totalDays * 24
|
||||
return `in ${totalDays}d ${remainingHours}h`
|
||||
}
|
||||
const remainingMinutes = totalMinutes - totalHours * 60
|
||||
return `in ${totalHours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsageBar({
|
||||
label,
|
||||
utilization,
|
||||
resetsAt,
|
||||
}: {
|
||||
label: string
|
||||
utilization: number
|
||||
resetsAt: string | null
|
||||
}) {
|
||||
const color =
|
||||
utilization >= 90
|
||||
? 'bg-destructive'
|
||||
: utilization >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
const resetText = resetsAt ? formatResetTime(resetsAt) : null
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right">
|
||||
{utilization.toFixed(0)}%
|
||||
</span>
|
||||
{resetText && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
resets {resetText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HealthCheckPage() {
|
||||
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = healthQuery
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<XCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load health check: {error?.message ?? 'Unknown error'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { server, accounts, projects } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Refresh button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
Server Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Running</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uptime: {formatUptime(server.uptime)}
|
||||
{server.startedAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Started{' '}
|
||||
{new Date(server.startedAt).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Accounts</h2>
|
||||
{accounts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
No accounts configured. Use{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
cw account add
|
||||
</code>{' '}
|
||||
to register one.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<AccountCard key={account.id} account={account} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
No projects registered yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
{project.repoExists ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{project.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{project.url}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{project.repoExists ? 'Clone found' : 'Clone missing'}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AccountData = {
|
||||
id: string
|
||||
email: string
|
||||
provider: string
|
||||
credentialsValid: boolean
|
||||
tokenValid: boolean
|
||||
tokenExpiresAt: string | null
|
||||
subscriptionType: string | null
|
||||
error: string | null
|
||||
usage: {
|
||||
five_hour: { utilization: number; resets_at: string | null } | null
|
||||
seven_day: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_sonnet: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_opus: { utilization: number; resets_at: string | null } | null
|
||||
extra_usage: {
|
||||
is_enabled: boolean
|
||||
monthly_limit: number | null
|
||||
used_credits: number | null
|
||||
utilization: number | null
|
||||
} | null
|
||||
} | null
|
||||
isExhausted: boolean
|
||||
exhaustedUntil: string | null
|
||||
lastUsedAt: string | null
|
||||
agentCount: number
|
||||
activeAgentCount: number
|
||||
}
|
||||
|
||||
function AccountCard({ account }: { account: AccountData }) {
|
||||
const statusIcon = !account.credentialsValid ? (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
) : account.isExhausted ? (
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
)
|
||||
|
||||
const statusText = !account.credentialsValid
|
||||
? 'Invalid credentials'
|
||||
: account.isExhausted
|
||||
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : 'unknown'}`
|
||||
: 'Available'
|
||||
|
||||
const usage = account.usage
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{account.email}</span>
|
||||
<Badge variant="outline">{account.provider}</Badge>
|
||||
{account.subscriptionType && (
|
||||
<Badge variant="secondary">
|
||||
{capitalize(account.subscriptionType)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{account.agentCount} agent{account.agentCount !== 1 ? 's' : ''}{' '}
|
||||
({account.activeAgentCount} active)
|
||||
</span>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
{usage && (
|
||||
<div className="space-y-1.5 pl-8">
|
||||
{usage.five_hour && (
|
||||
<UsageBar
|
||||
label="Session (5h)"
|
||||
utilization={usage.five_hour.utilization}
|
||||
resetsAt={usage.five_hour.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day && (
|
||||
<UsageBar
|
||||
label="Weekly (7d)"
|
||||
utilization={usage.seven_day.utilization}
|
||||
resetsAt={usage.seven_day.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_sonnet &&
|
||||
usage.seven_day_sonnet.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Sonnet (7d)"
|
||||
utilization={usage.seven_day_sonnet.utilization}
|
||||
resetsAt={usage.seven_day_sonnet.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Opus (7d)"
|
||||
utilization={usage.seven_day_opus.utilization}
|
||||
resetsAt={usage.seven_day_opus.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_usage && usage.extra_usage.is_enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">
|
||||
Extra usage
|
||||
</span>
|
||||
<span>
|
||||
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{' '}
|
||||
used
|
||||
{usage.extra_usage.monthly_limit != null && (
|
||||
<>
|
||||
{' '}
|
||||
/ ${(usage.extra_usage.monthly_limit / 100).toFixed(
|
||||
2
|
||||
)}{' '}
|
||||
limit
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{account.error && (
|
||||
<p className="pl-8 text-xs text-destructive">{account.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/routes/settings/index.tsx
Normal file
7
packages/web/src/routes/settings/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings/health' })
|
||||
},
|
||||
})
|
||||
1
packages/web/tsconfig.app.tsbuildinfo
Normal file
1
packages/web/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/actionmenu.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plantasksfetcher.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/markdown-to-tiptap.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}
|
||||
1614
reference/ccswitch
Executable file
1614
reference/ccswitch
Executable file
File diff suppressed because it is too large
Load Diff
1
reference/gastown
Submodule
1
reference/gastown
Submodule
Submodule reference/gastown added at c6832e4bac
1
reference/get-shit-done
Submodule
1
reference/get-shit-done
Submodule
Submodule reference/get-shit-done added at 5660b6fc0b
67
src/agent/accounts/extractor.ts
Normal file
67
src/agent/accounts/extractor.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { execa } from 'execa';
|
||||
|
||||
export interface ExtractedAccount {
|
||||
email: string;
|
||||
accountUuid: string;
|
||||
configJson: object;
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Claude Code config path with fallback logic.
|
||||
* Primary: ~/.claude/.claude.json (if it exists and has oauthAccount)
|
||||
* Fallback: ~/.claude.json
|
||||
*/
|
||||
function getClaudeConfigPath(): string {
|
||||
const home = homedir();
|
||||
const primary = join(home, '.claude', '.claude.json');
|
||||
const fallback = join(home, '.claude.json');
|
||||
|
||||
if (existsSync(primary)) {
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(primary, 'utf-8'));
|
||||
if (json.oauthAccount) return primary;
|
||||
} catch {
|
||||
// invalid JSON, fall through
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function extractCurrentClaudeAccount(): Promise<ExtractedAccount> {
|
||||
const home = homedir();
|
||||
|
||||
// 1. Read Claude config (with fallback logic matching ccswitch)
|
||||
const configPath = getClaudeConfigPath();
|
||||
const configRaw = readFileSync(configPath, 'utf-8');
|
||||
const configJson = JSON.parse(configRaw);
|
||||
|
||||
const email = configJson.oauthAccount?.emailAddress;
|
||||
const accountUuid = configJson.oauthAccount?.accountUuid;
|
||||
|
||||
if (!email || !accountUuid) {
|
||||
throw new Error('No Claude account found. Please log in with `claude` first.');
|
||||
}
|
||||
|
||||
// 2. Read credentials (platform-specific)
|
||||
let credentials: string;
|
||||
if (platform() === 'darwin') {
|
||||
// macOS: read from Keychain
|
||||
const { stdout } = await execa('security', [
|
||||
'find-generic-password',
|
||||
'-s', 'Claude Code-credentials',
|
||||
'-w',
|
||||
]);
|
||||
credentials = stdout;
|
||||
} else {
|
||||
// Linux: read from file
|
||||
const credPath = join(home, '.claude', '.credentials.json');
|
||||
credentials = readFileSync(credPath, 'utf-8');
|
||||
}
|
||||
|
||||
return { email, accountUuid, configJson, credentials };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user