Add userDismissedAt field to agents schema

This commit is contained in:
Lukas May
2026-02-07 00:33:12 +01:00
parent 111ed0962f
commit 2877484012
224 changed files with 30873 additions and 4672 deletions

9
.gitignore vendored
View File

@@ -19,6 +19,15 @@ dist/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local data
.cw/
# Test workspaces
workdir/
# Agent working directories
agent-workdirs/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

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

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

View 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`);

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

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

View File

@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `initiative_id` text REFERENCES initiatives(id);

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

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

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

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

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

View File

@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `user_dismissed_at` integer;

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,90 @@
"when": 1769882826521, "when": 1769882826521,
"tag": "0000_bizarre_naoko", "tag": "0000_bizarre_naoko",
"breakpoints": true "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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@
"version": "0.0.1", "version": "0.0.1",
"description": "Multi-agent workspace for orchestrating multiple Claude Code agents", "description": "Multi-agent workspace for orchestrating multiple Claude Code agents",
"type": "module", "type": "module",
"workspaces": ["packages/*"], "workspaces": [
"packages/*"
],
"main": "./dist/index.js", "main": "./dist/index.js",
"bin": { "bin": {
"cw": "./dist/bin/cw.js" "cw": "./dist/bin/cw.js"
@@ -26,20 +28,28 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "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/client": "^11.9.0",
"@trpc/server": "^11.9.0", "@trpc/server": "^11.9.0",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"commander": "^12.1.0", "commander": "^12.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"execa": "^9.5.2", "execa": "^9.5.2",
"gray-matter": "^4.0.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"pino": "^10.3.0",
"simple-git": "^3.30.0", "simple-git": "^3.30.0",
"unique-names-generator": "^4.7.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"pino-pretty": "^13.1.3",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@@ -1,2 +1,3 @@
export type { AppRouter } from './trpc.js'; 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';

View File

@@ -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'; export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
/** /**

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

View File

@@ -15,6 +15,14 @@
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@tanstack/react-query": "^5.75.0", "@tanstack/react-query": "^5.75.0",
"@tanstack/react-router": "^1.158.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/client": "^11.9.0",
"@trpc/react-query": "^11.9.0", "@trpc/react-query": "^11.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -23,7 +31,8 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7"
}, },
"devDependencies": { "devDependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",

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

View File

@@ -10,9 +10,9 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { ProjectPicker } from "./ProjectPicker";
interface CreateInitiativeDialogProps { interface CreateInitiativeDialogProps {
open: boolean; open: boolean;
@@ -24,7 +24,7 @@ export function CreateInitiativeDialog({
onOpenChange, onOpenChange,
}: CreateInitiativeDialogProps) { }: CreateInitiativeDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [projectIds, setProjectIds] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -44,7 +44,7 @@ export function CreateInitiativeDialog({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setName(""); setName("");
setDescription(""); setProjectIds([]);
setError(null); setError(null);
} }
}, [open]); }, [open]);
@@ -54,7 +54,7 @@ export function CreateInitiativeDialog({
setError(null); setError(null);
createMutation.mutate({ createMutation.mutate({
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, projectIds: projectIds.length > 0 ? projectIds : undefined,
}); });
} }
@@ -81,19 +81,13 @@ export function CreateInitiativeDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="initiative-description"> <Label>
Description{" "} Projects{" "}
<span className="text-muted-foreground font-normal"> <span className="text-muted-foreground font-normal">
(optional) (optional)
</span> </span>
</Label> </Label>
<Textarea <ProjectPicker value={projectIds} onChange={setProjectIds} />
id="initiative-description"
placeholder="Brief description of the initiative..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div> </div>
{error && ( {error && (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>

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

View File

@@ -1,52 +1,118 @@
import { ChevronLeft } from "lucide-react"; import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card"; import { ChevronLeft, Pencil, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { ProjectPicker } from "./ProjectPicker";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export interface InitiativeHeaderProps { export interface InitiativeHeaderProps {
initiative: { initiative: {
id: string; id: string;
name: string; name: string;
status: string; status: string;
createdAt: string;
updatedAt: string;
}; };
projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void; onBack: () => void;
} }
export function InitiativeHeader({ export function InitiativeHeader({
initiative, initiative,
projects,
onBack, onBack,
}: InitiativeHeaderProps) { }: InitiativeHeaderProps) {
return ( const [editing, setEditing] = useState(false);
<div className="flex flex-col gap-4"> const [editIds, setEditIds] = useState<string[]>([]);
{/* 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>
{/* Initiative metadata card */} const utils = trpc.useUtils();
<Card> const updateMutation = trpc.updateInitiativeProjects.useMutation({
<CardContent className="p-6"> onSuccess: () => {
<div className="flex flex-col gap-2"> utils.getInitiative.invalidate({ id: initiative.id });
<div className="flex items-center gap-3"> setEditing(false);
<h1 className="text-2xl font-bold">{initiative.name}</h1> toast.success("Projects updated");
<StatusBadge status={initiative.status} /> },
</div> onError: (err) => {
<p className="text-sm text-muted-foreground"> toast.error(err.message);
Created: {new Date(initiative.createdAt).toLocaleDateString()} },
{" | "} });
Updated: {new Date(initiative.updatedAt).toLocaleDateString()}
</p> 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">
<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>
</div>
{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>
</CardContent> </div>
</Card> )}
</div> </div>
); );
} }

View File

@@ -1,20 +1,5 @@
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn, formatRelativeTime } 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`;
}
interface MessageCardProps { interface MessageCardProps {
agentName: string; agentName: string;

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

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

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

View File

@@ -7,59 +7,42 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
interface SpawnArchitectDropdownProps { interface SpawnArchitectDropdownProps {
initiativeId: string; initiativeId: string;
initiativeName: string; initiativeName?: string;
} }
export function SpawnArchitectDropdown({ export function SpawnArchitectDropdown({
initiativeId, initiativeId,
initiativeName,
}: SpawnArchitectDropdownProps) { }: SpawnArchitectDropdownProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [successText, setSuccessText] = useState<string | null>(null); const [successText, setSuccessText] = useState<string | null>(null);
const discussMutation = trpc.spawnArchitectDiscuss.useMutation({ const handleSuccess = () => {
onSuccess: () => { setOpen(false);
setOpen(false); setSuccessText("Spawned!");
setSuccessText("Spawned!"); setTimeout(() => setSuccessText(null), 2000);
setTimeout(() => setSuccessText(null), 2000); };
toast.success("Architect spawned");
}, const discussSpawn = useSpawnMutation(trpc.spawnArchitectDiscuss.useMutation, {
onError: () => { onSuccess: handleSuccess,
toast.error("Failed to spawn architect");
},
}); });
const breakdownMutation = trpc.spawnArchitectBreakdown.useMutation({ const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
onSuccess: () => { onSuccess: handleSuccess,
setOpen(false);
setSuccessText("Spawned!");
setTimeout(() => setSuccessText(null), 2000);
toast.success("Architect spawned");
},
onError: () => {
toast.error("Failed to spawn architect");
},
}); });
const isPending = discussMutation.isPending || breakdownMutation.isPending; const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning;
function handleDiscuss() { function handleDiscuss() {
discussMutation.mutate({ discussSpawn.spawn({ initiativeId });
name: initiativeName + "-discuss",
initiativeId,
});
} }
function handleBreakdown() { function handleBreakdown() {
breakdownMutation.mutate({ breakdownSpawn.spawn({ initiativeId });
name: initiativeName + "-breakdown",
initiativeId,
});
} }
return ( return (

View 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}`}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
/** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */ /** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */
export interface SerializedTask { export interface SerializedTask {
@@ -117,7 +118,7 @@ export function TaskDetailModal({
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
> >
<span>{dep.name}</span> <span>{dep.name}</span>
<StatusBadge status={dep.status} /> <StatusDot status={dep.status} size="md" />
</li> </li>
))} ))}
</ul> </ul>
@@ -137,7 +138,7 @@ export function TaskDetailModal({
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
> >
<span>{dep.name}</span> <span>{dep.name}</span>
<StatusBadge status={dep.status} /> <StatusDot status={dep.status} size="md" />
</li> </li>
))} ))}
</ul> </ul>

View 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;
},
},
}),
];
},
});

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

View 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 &ldquo;{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}&rdquo;.
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>
</>
);
}

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

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

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

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

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

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

View 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,
}),
];
},
});

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

View 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);
}
},
},
];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -60,3 +60,71 @@
min-height: 100vh; 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); }

View File

@@ -2,7 +2,9 @@ import { Link } from '@tanstack/react-router'
const navItems = [ const navItems = [
{ label: 'Initiatives', to: '/initiatives' }, { label: 'Initiatives', to: '/initiatives' },
{ label: 'Agents', to: '/agents' },
{ label: 'Inbox', to: '/inbox' }, { label: 'Inbox', to: '/inbox' },
{ label: 'Settings', to: '/settings' },
] as const ] as const
export function AppLayout({ children }: { children: React.ReactNode }) { export function AppLayout({ children }: { children: React.ReactNode }) {

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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`;
}

View File

@@ -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. // 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 rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as InboxRouteImport } from './routes/inbox' import { Route as InboxRouteImport } from './routes/inbox'
import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index' 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 InitiativesIndexRouteImport } from './routes/initiatives/index'
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id' import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const InboxRoute = InboxRouteImport.update({ const InboxRoute = InboxRouteImport.update({
id: '/inbox', id: '/inbox',
path: '/inbox', path: '/inbox',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AgentsRoute = AgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsRoute,
} as any)
const InitiativesIndexRoute = InitiativesIndexRouteImport.update({ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
id: '/initiatives/', id: '/initiatives/',
path: '/initiatives/', path: '/initiatives/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SettingsHealthRoute = SettingsHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => SettingsRoute,
} as any)
const InitiativesIdRoute = InitiativesIdRouteImport.update({ const InitiativesIdRoute = InitiativesIdRouteImport.update({
id: '/initiatives/$id', id: '/initiatives/$id',
path: '/initiatives/$id', path: '/initiatives/$id',
@@ -37,40 +61,84 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives/': typeof InitiativesIndexRoute '/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives': typeof InitiativesIndexRoute '/initiatives': typeof InitiativesIndexRoute
'/settings': typeof SettingsIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute '/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute '/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives/': typeof InitiativesIndexRoute '/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/inbox' | '/initiatives/$id' | '/initiatives/' fullPaths:
| '/'
| '/agents'
| '/inbox'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/inbox' | '/initiatives/$id' | '/initiatives' to:
id: '__root__' | '/' | '/inbox' | '/initiatives/$id' | '/initiatives/' | '/'
| '/agents'
| '/inbox'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives'
| '/settings'
id:
| '__root__'
| '/'
| '/agents'
| '/inbox'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives/'
| '/settings/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
AgentsRoute: typeof AgentsRoute
InboxRoute: typeof InboxRoute InboxRoute: typeof InboxRoute
SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute InitiativesIdRoute: typeof InitiativesIdRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute InitiativesIndexRoute: typeof InitiativesIndexRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox': { '/inbox': {
id: '/inbox' id: '/inbox'
path: '/inbox' path: '/inbox'
@@ -78,6 +146,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InboxRouteImport preLoaderRoute: typeof InboxRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/agents': {
id: '/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AgentsRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -85,6 +160,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings/': {
id: '/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRoute
}
'/initiatives/': { '/initiatives/': {
id: '/initiatives/' id: '/initiatives/'
path: '/initiatives' path: '/initiatives'
@@ -92,6 +174,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InitiativesIndexRouteImport preLoaderRoute: typeof InitiativesIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/settings/health': {
id: '/settings/health'
path: '/health'
fullPath: '/settings/health'
preLoaderRoute: typeof SettingsHealthRouteImport
parentRoute: typeof SettingsRoute
}
'/initiatives/$id': { '/initiatives/$id': {
id: '/initiatives/$id' id: '/initiatives/$id'
path: '/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 = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
AgentsRoute: AgentsRoute,
InboxRoute: InboxRoute, InboxRoute: InboxRoute,
SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute, InitiativesIdRoute: InitiativesIdRoute,
InitiativesIndexRoute: InitiativesIndexRoute, InitiativesIndexRoute: InitiativesIndexRoute,
} }

View 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
// ---------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList"; import { InboxList } from "@/components/InboxList";
import { QuestionForm } from "@/components/QuestionForm"; import { QuestionForm } from "@/components/QuestionForm";
import { formatRelativeTime } from "@/lib/utils";
export const Route = createFileRoute("/inbox")({ export const Route = createFileRoute("/inbox")({
component: InboxPage, component: InboxPage,
@@ -328,17 +329,3 @@ function InboxPage() {
// Helpers // 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`;
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react"; import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -6,247 +6,87 @@ import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { InitiativeHeader } from "@/components/InitiativeHeader"; import { InitiativeHeader } from "@/components/InitiativeHeader";
import { ProgressPanel } from "@/components/ProgressPanel"; import { ContentTab } from "@/components/editor/ContentTab";
import { PhaseAccordion } from "@/components/PhaseAccordion"; import { ExecutionTab } from "@/components/ExecutionTab";
import { DecisionList } from "@/components/DecisionList"; import { useSubscriptionWithErrorHandling } from "@/hooks";
import { TaskDetailModal } from "@/components/TaskDetailModal";
import type { SerializedTask } from "@/components/TaskRow";
export const Route = createFileRoute("/initiatives/$id")({ export const Route = createFileRoute("/initiatives/$id")({
component: InitiativeDetailPage, component: InitiativeDetailPage,
}); });
// --------------------------------------------------------------------------- type Tab = "content" | "execution";
// 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
// ---------------------------------------------------------------------------
function InitiativeDetailPage() { function InitiativeDetailPage() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const navigate = useNavigate(); 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(); const utils = trpc.useUtils();
trpc.onTaskUpdate.useSubscription(undefined, {
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
void utils.listPlans.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
trpc.onAgentUpdate.useSubscription(undefined, {
onData: () => {
void utils.listAgents.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
// State // Task updates subscription with robust error handling
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); useSubscriptionWithErrorHandling(
const [taskCountsByPhase, setTaskCountsByPhase] = useState< () => trpc.onTaskUpdate.useSubscription(undefined),
Record<string, TaskCounts> {
>({}); onData: () => {
const [tasksByPhase, setTasksByPhase] = useState< void utils.listPhases.invalidate();
Record<string, FlatTaskEntry[]> void utils.listTasks.invalidate();
>({}); void utils.listPlans.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Task updates subscription error:', error);
},
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: (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,
}
);
// 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 // tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id }); const initiativeQuery = trpc.getInitiative.useQuery({ id });
@@ -255,94 +95,20 @@ function InitiativeDetailPage() {
{ enabled: !!initiativeQuery.data }, { 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 // Loading state
if (initiativeQuery.isLoading) { if (initiativeQuery.isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header skeleton */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Skeleton className="h-4 w-4" /> <Skeleton className="h-4 w-4" />
<Skeleton className="h-7 w-64" /> <Skeleton className="h-7 w-64" />
<Skeleton className="h-5 w-20" /> <Skeleton className="h-5 w-20" />
</div> </div>
{/* Two-column grid skeleton */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
{/* Left: phase accordion skeletons */}
<div className="space-y-1"> <div className="space-y-1">
<Skeleton className="h-12 w-full rounded border" /> <Skeleton className="h-12 w-full rounded border" />
<Skeleton className="h-12 w-full rounded border" /> <Skeleton className="h-12 w-full rounded border" />
</div> </div>
{/* Right: ProgressPanel + DecisionList skeletons */}
<div className="space-y-6"> <div className="space-y-6">
<Skeleton className="h-24 w-full rounded" /> <Skeleton className="h-24 w-full rounded" />
<Skeleton className="h-20 w-full rounded" /> <Skeleton className="h-20 w-full rounded" />
@@ -376,109 +142,59 @@ function InitiativeDetailPage() {
const initiative = initiativeQuery.data; const initiative = initiativeQuery.data;
if (!initiative) return null; if (!initiative) return null;
// tRPC serializes Date to string over JSON — cast to wire format
const serializedInitiative = { const serializedInitiative = {
id: initiative.id, id: initiative.id,
name: initiative.name, name: initiative.name,
status: initiative.status, 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 ( return (
<div className="space-y-6"> <div className="space-y-3">
{/* Header */} {/* Header */}
<InitiativeHeader <InitiativeHeader
initiative={serializedInitiative} initiative={serializedInitiative}
projects={projects}
onBack={() => navigate({ to: "/initiatives" })} onBack={() => navigate({ to: "/initiatives" })}
/> />
{/* Two-column layout */} {/* Tab bar */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]"> <div className="flex gap-1 border-b border-border">
{/* Left column: Phases */} <button
<div className="space-y-0"> onClick={() => setActiveTab("content")}
{/* Section header */} className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
<div className="flex items-center justify-between border-b border-border pb-3"> activeTab === "content"
<h2 className="text-lg font-semibold">Phases</h2> ? "border-primary text-foreground"
<Button : "border-transparent text-muted-foreground hover:text-foreground"
variant="outline" }`}
size="sm" >
disabled={!hasPendingPhases} Content
onClick={handleQueueAll} </button>
> <button
Queue All onClick={() => setActiveTab("execution")}
</Button> className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
</div> activeTab === "execution"
? "border-primary text-foreground"
{/* Phase loading */} : "border-transparent text-muted-foreground hover:text-foreground"
{phasesQuery.isLoading && ( }`}
<div className="space-y-1 pt-3"> >
{Array.from({ length: 3 }).map((_, i) => ( Execution
<Skeleton key={i} className="h-10 w-full" /> </button>
))}
</div>
)}
{/* 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> </div>
{/* Task Detail Modal */} {/* Tab content */}
<TaskDetailModal {activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
task={selectedEntry?.task ?? null} {activeTab === "execution" && (
phaseName={selectedEntry?.phaseName ?? ""} <ExecutionTab
agentName={selectedEntry?.agentName ?? null} initiativeId={id}
dependencies={selectedEntry?.blockedBy ?? []} phases={phases}
dependents={selectedEntry?.dependents ?? []} phasesLoading={phasesQuery.isLoading}
onClose={() => setSelectedTaskId(null)} phasesLoaded={phasesQuery.isSuccess}
onQueueTask={handleQueueTask} />
onStopTask={() => setSelectedTaskId(null)} )}
/>
</div> </div>
); );
} }

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

View 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 && (
<>
{' '}
&middot; 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>
)
}

View File

@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/settings/')({
beforeLoad: () => {
throw redirect({ to: '/settings/health' })
},
})

View 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

File diff suppressed because it is too large Load Diff

1
reference/gastown Submodule

Submodule reference/gastown added at c6832e4bac

Submodule reference/get-shit-done added at 5660b6fc0b

View 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