feat: Task decomposition for Tailwind/Radix/shadcn foundation setup

Decomposed "Foundation Setup - Install Dependencies & Configure Tailwind"
phase into 6 executable tasks:

1. Install Tailwind CSS, PostCSS & Autoprefixer
2. Map MUI theme to Tailwind design tokens
3. Setup CSS variables for dynamic theming
4. Install Radix UI primitives
5. Initialize shadcn/ui and setup component directory
6. Move MUI to devDependencies and verify setup

Tasks follow logical dependency chain with final human verification
checkpoint before proceeding with component migration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-02-10 09:48:51 +01:00
parent da4152264c
commit 342b490fe7
83 changed files with 3200 additions and 913 deletions

View File

@@ -1,54 +1,50 @@
# Codewalk District # Codewalk District
Multi-agent workspace for orchestrating multiple Claude Code agents. Multi-agent workspace for orchestrating multiple AI coding agents working in parallel on a shared codebase.
## Database **Architecture**: [docs/architecture.md](docs/architecture.md) — system diagram, module map, entity relationships, tech stack.
Schema is defined in `src/db/schema.ts` using drizzle-orm. Migrations are managed by drizzle-kit. ## Quick Reference
See [docs/database-migrations.md](docs/database-migrations.md) for the full migration workflow, rules, and commands. | Module | Docs | Path |
|--------|------|------|
| Agent (lifecycle, providers, accounts) | [docs/agent.md](docs/agent.md) | `src/agent/` |
| Database (schema, repositories) | [docs/database.md](docs/database.md) | `src/db/` |
| Server & API (tRPC procedures) | [docs/server-api.md](docs/server-api.md) | `src/server/`, `src/trpc/`, `src/coordination/` |
| Frontend (React UI) | [docs/frontend.md](docs/frontend.md) | `packages/web/` |
| CLI & Config | [docs/cli-config.md](docs/cli-config.md) | `src/cli/`, `src/config/` |
| Dispatch & Events | [docs/dispatch-events.md](docs/dispatch-events.md) | `src/dispatch/`, `src/events/` |
| Git, Process, Logging | [docs/git-process-logging.md](docs/git-process-logging.md) | `src/git/`, `src/process/`, `src/logger/`, `src/logging/` |
| Testing | [docs/testing.md](docs/testing.md) | `src/test/` |
| Database Migrations | [docs/database-migrations.md](docs/database-migrations.md) | `drizzle/` |
| Logging Guide | [docs/logging.md](docs/logging.md) | `src/logger/` |
Key rule: **never use raw SQL for schema initialization.** Always use `drizzle-kit generate` and the migration system. Pre-implementation design docs are archived in `docs/archive/`.
## Logging ## Key Rules
Structured logging via pino. See [docs/logging.md](docs/logging.md) for full details. - **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md).
- **Logging**: Use `createModuleLogger()` from `src/logger/index.ts`. Keep `console.log` for CLI user-facing output only.
Key rule: use `createModuleLogger()` from `src/logger/index.ts` for backend logging. Keep `console.log` for CLI user-facing output only. - **Hexagonal architecture**: Repository ports in `src/db/repositories/*.ts`, Drizzle adapters in `src/db/repositories/drizzle/*.ts`. All re-exported from `src/db/index.ts`.
- **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `src/trpc/routers/_helpers.ts`.
## Build ## Build
After completing any change to server-side code (`src/**`), rebuild and re-link the `cw` binary:
```sh ```sh
npm run build && npm link npm run build && npm link
``` ```
Run after any change to server-side code (`src/**`).
## Testing ## Testing
### Unit Tests
```sh ```sh
npm test npm test # Unit tests
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000 # Real provider tests (~$0.50)
``` ```
### E2E Tests (Real CLI) See [docs/testing.md](docs/testing.md) for details.
Real provider integration tests call actual CLI tools and incur API costs. They are **skipped by default**. ## Documentation Maintenance
```sh **After every code change, update the relevant docs/ file.** Documentation must stay in sync with implementation. When adding a new module, table, tRPC procedure, component, or CLI command, update the corresponding doc. When refactoring, update affected docs and remove stale information.
# 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

103
docs/agent.md Normal file
View File

@@ -0,0 +1,103 @@
# Agent Module
`src/agent/` — Agent lifecycle management, output parsing, multi-provider support, and account failover.
## File Inventory
| File | Purpose |
|------|---------|
| `types.ts` | Core types: `AgentInfo`, `AgentManager` interface, `SpawnOptions`, `StreamEvent` |
| `manager.ts` | `MultiProviderAgentManager` — main orchestrator class |
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation |
| `file-tailer.ts` | `FileTailer` — watches output files, emits line events |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion |
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
| `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias |
### Sub-modules
| Directory | Purpose |
|-----------|---------|
| `providers/` | Provider registry, presets (7 providers), config types |
| `providers/parsers/` | Provider-specific output parsers (Claude JSONL, generic line) |
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, breakdown, decompose, refine) |
## Key Flows
### Spawning an Agent
1. **tRPC procedure** calls `agentManager.spawn(options)`
2. Manager generates alias (adjective-animal), creates DB record
3. `AgentProcessManager.createWorktree()` — creates git worktree at `.cw-worktrees/agent/<alias>/`
4. `file-io.writeInputFiles()` — writes `.cw/input/` with initiative, pages, phase, task as frontmatter
5. Provider config builds spawn command via `buildSpawnCommand()`
6. `spawnDetached()` — launches detached child process with file output redirection
7. `FileTailer` watches output file, fires `onEvent` and `onRawContent` callbacks
8. `OutputHandler.handleStreamEvent()` processes each JSONL line
9. DB record updated with PID, output file path, session ID
10. `agent:spawned` event emitted
### Completion Detection
1. `FileTailer` detects process exit
2. `OutputHandler.handleCompletion()` triggered
3. **Primary path**: Reads `.cw/output/signal.json` from agent worktree
4. Signal contains `{ status: "done"|"questions"|"error", result?, questions?, error? }`
5. Agent DB status updated accordingly (idle, waiting_for_input, crashed)
6. For `done`: proposals created from structured output; `agent:stopped` emitted
7. For `questions`: parsed and stored as `pendingQuestions`; `agent:waiting` emitted
8. **Fallback**: If signal.json missing, lifecycle controller retries with instruction injection
### Account Failover
1. On usage-limit error, `markAccountExhausted(id, until)` called
2. `findNextAvailable(provider)` returns least-recently-used non-exhausted account
3. Agent re-spawned with new account's credentials
4. `agent:account_switched` event emitted
### Resume Flow
1. tRPC `resumeAgent` called with `answers: Record<string, string>`
2. Manager looks up agent's session ID and provider config
3. `buildResumeCommand()` creates resume command with session flag
4. `formatAnswersAsPrompt(answers)` converts answers to prompt text
5. New detached process spawned, same worktree, incremented session number
## Provider Configuration
Providers defined in `providers/presets.ts`:
| Provider | Command | Resume | Prompt Mode |
|----------|---------|--------|-------------|
| claude | `claude` | `--resume <id>` | native (`-p`) |
| claude-code | `claude` | `--resume <id>` | native |
| codex | `codex` | none | flag (`--prompt`) |
| aider | `aider` | none | flag (`--message`) |
| cline | `cline` | none | flag |
| continue | `continue` | none | flag |
| cursor-agent | `cursor` | none | flag |
Each provider config specifies: `command`, `args`, `resumeStyle`, `promptMode`, `structuredOutput`, `sessionId` extraction, `nonInteractive` options.
## Output Parsing
The `OutputHandler` processes JSONL streams from Claude CLI:
- `text_delta` events → accumulated as text output, emitted via `agent:output`
- `init` event → session ID extracted
- `result` event → final result with structured data
- Signal file (`signal.json`) → authoritative completion status
For providers without structured output, the generic line parser accumulates raw text.
## Log Chunks
Agent output is persisted to `agent_log_chunks` table:
- `onRawContent` callback fires for every output chunk
- Fire-and-forget DB insert (no FK to agents — survives deletion)
- Session tracking: spawn=1, resume=previousMax+1
- Read path concatenates all sessions for full output history

109
docs/architecture.md Normal file
View File

@@ -0,0 +1,109 @@
# Architecture Overview
Codewalk District is a multi-agent workspace that orchestrates multiple AI coding agents (Claude, Codex, etc.) working in parallel on a shared codebase.
## System Diagram
```
CLI (cw)
└── CoordinationServer
├── HTTP Server (node:http, port 3847)
│ ├── GET /health
│ ├── GET /status
│ └── POST /trpc/* → tRPC Router (68+ procedures)
├── EventBus (typed pub/sub, 30+ event types)
├── MultiProviderAgentManager
│ ├── ProcessManager (detached child processes)
│ ├── WorktreeManager (git worktrees per agent)
│ ├── OutputHandler (JSONL stream parsing)
│ ├── AccountManager (round-robin, exhaustion failover)
│ └── LifecycleController (retry, signal recovery)
├── DispatchManager (task queue, dependency resolution)
├── PhaseDispatchManager (phase queue, DAG ordering)
└── CoordinationManager (merge queue, conflict resolution)
Web UI (packages/web/)
└── React 19 + TanStack Router + tRPC React Query
├── Initiative management (CRUD, projects)
├── Page editor (Tiptap rich text)
├── Pipeline visualization (phase DAG)
├── Execution tab (task dispatch, agent monitoring)
└── Review tab (proposal accept/dismiss)
```
## Architectural Patterns
### Hexagonal Architecture (Ports & Adapters)
Every data-access layer follows this pattern:
- **Port** (interface): `src/db/repositories/<entity>-repository.ts`
- **Adapter** (implementation): `src/db/repositories/drizzle/<entity>-repository.ts`
- Re-exported from barrel files so consumers import from `src/db/`
### Event-Driven Communication
All inter-module communication flows through a typed `EventBus`:
- Managers emit domain events (agent:spawned, task:completed, etc.)
- tRPC subscriptions bridge events to the frontend via SSE
- Dispatch/coordination reacts to task/phase lifecycle events
### Data-Driven Provider Configuration
Agent providers (Claude, Codex, etc.) are defined as configuration objects, not code:
- `AgentProviderConfig` specifies command, args, resume style, prompt mode
- `buildSpawnCommand()` / `buildResumeCommand()` are generic and data-driven
## Module Map
| Module | Path | Purpose | Docs |
|--------|------|---------|------|
| Agent | `src/agent/` | Agent lifecycle, output parsing, accounts | [agent.md](agent.md) |
| Database | `src/db/` | Schema, repositories, migrations | [database.md](database.md) |
| Server & API | `src/server/`, `src/trpc/` | HTTP server, tRPC procedures | [server-api.md](server-api.md) |
| Frontend | `packages/web/` | React UI, components, hooks | [frontend.md](frontend.md) |
| CLI & Config | `src/cli/`, `src/config/` | CLI commands, workspace config | [cli-config.md](cli-config.md) |
| Dispatch | `src/dispatch/` | Task/phase queue and dispatch | [dispatch-events.md](dispatch-events.md) |
| Coordination | `src/coordination/` | Merge queue, conflict resolution | [server-api.md](server-api.md#coordination) |
| Git | `src/git/` | Worktree management, project clones | [git-process-logging.md](git-process-logging.md) |
| Process | `src/process/` | Child process spawn/track/stop | [git-process-logging.md](git-process-logging.md) |
| Logging | `src/logger/`, `src/logging/` | Structured logging, file capture | [git-process-logging.md](git-process-logging.md) |
| Events | `src/events/` | EventBus, typed event system | [dispatch-events.md](dispatch-events.md) |
| Shared | `packages/shared/` | Types shared between frontend/backend | [frontend.md](frontend.md) |
| Tests | `src/test/` | E2E, integration, fixtures | [testing.md](testing.md) |
## Entity Relationships
```
INITIATIVES (root aggregate)
├── N PHASES (with phase_dependencies DAG)
│ └── N TASKS
├── N TASKS (initiative-level, with task_dependencies DAG)
│ └── N TASKS (parentTaskId self-ref for decomposition)
├── N PAGES (parentPageId self-ref for hierarchy)
├── N INITIATIVE_PROJECTS (junction → PROJECTS M:M)
└── N PROPOSALS
AGENTS (independent aggregate)
├── → TASK (optional)
├── → INITIATIVE (optional)
├── → ACCOUNT (optional)
├── N PROPOSALS
└── N MESSAGES (sender/recipient)
ACCOUNTS → N AGENTS
AGENT_LOG_CHUNKS (no FK, survives agent deletion)
```
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Runtime | Node.js (ESM) |
| Language | TypeScript (strict) |
| Database | SQLite via better-sqlite3 + Drizzle ORM |
| HTTP | Native node:http |
| API | tRPC v11 |
| Frontend | React 19, TanStack Router, Tailwind CSS, shadcn/ui |
| Editor | Tiptap (ProseMirror) |
| Git | simple-git |
| Process | execa (detached) |
| Logging | pino (structured) |
| Testing | vitest |
| Build | tsup (server), Vite (frontend) |

141
docs/cli-config.md Normal file
View File

@@ -0,0 +1,141 @@
# CLI & Configuration
`src/cli/` — CLI commands, `src/config/` — workspace configuration, `src/bin/` — entry point.
## Entry Point
`src/bin/cw.ts` — hashbang entry that imports and runs the CLI.
## CLI Framework
Uses **Commander.js** for command parsing.
## Global Flags
- `-s, --server` — Start coordination server in foreground
- `-p, --port <number>` — Server port (default: 3847, env: `CW_PORT`)
- `-d, --debug` — Enable debug mode (archive agent workdirs before cleanup)
## Commands
### System
| Command | Description |
|---------|-------------|
| `cw init` | Create `.cwrc` workspace file in current directory |
| `cw start` | Start coordination server (see Server Wiring below) |
| `cw stop` | Stop running server (reads PID file, sends signal) |
| `cw status` | Query server health endpoint |
| `cw id [-n count]` | Generate nanoid(s) offline |
### Agent Management (`cw agent`)
| Command | Description |
|---------|-------------|
| `spawn <prompt> --task <id> [--name] [--provider] [--initiative] [--cwd]` | Spawn agent |
| `stop <name>` | Stop running agent |
| `delete <name>` | Delete agent, clean workdir/branches/logs |
| `list` | List all agents with status |
| `get <name>` | Agent details (ID, task, session, worktree, status) |
| `resume <name> <answers>` | Resume with JSON answers or single string |
| `result <name>` | Get execution result |
### Task Management (`cw task`)
| Command | Description |
|---------|-------------|
| `list --parent\|--phase\|--initiative <id>` | List tasks with counts |
| `get <taskId>` | Task details |
| `status <taskId> <status>` | Update status |
### Dispatch (`cw dispatch`)
| Command | Description |
|---------|-------------|
| `queue <taskId>` | Queue task for dispatch |
| `next` | Dispatch next available task |
| `status` | Show queue status |
| `complete <taskId>` | Mark task complete |
### Initiative Management (`cw initiative`)
| Command | Description |
|---------|-------------|
| `create <name> [--project <ids...>]` | Create initiative |
| `list [-s status]` | List initiatives |
| `get <id>` | Initiative details |
| `phases <initiativeId>` | List phases |
### Architect (`cw architect`)
| Command | Description |
|---------|-------------|
| `discuss <initiativeId> [-c context]` | Start discussion agent |
| `breakdown <initiativeId> [-s summary]` | Start breakdown agent |
| `decompose <phaseId> [-t taskName] [-c context]` | Decompose phase into tasks |
### Phase (`cw phase`)
| Command | Description |
|---------|-------------|
| `add-dependency --phase <id> --depends-on <id>` | Add dependency edge |
| `dependencies <phaseId>` | List dependencies |
| `queue <phaseId>` | Queue approved phase |
| `dispatch` | Dispatch next phase |
| `queue-status` | Show phase queue |
### Merge & Coordination (`cw merge`, `cw coordinate`)
| Command | Description |
|---------|-------------|
| `merge queue <taskId>` | Queue task for merge |
| `merge status` | Show merge queue |
| `merge next` | Show next mergeable task |
| `coordinate [-t branch]` | Process all ready merges (default: main) |
### Messages (`cw message`)
| Command | Description |
|---------|-------------|
| `list [--agent <id>] [--status <s>]` | List messages |
| `read <messageId>` | Read full message |
| `respond <messageId> <response>` | Respond to message |
### Projects (`cw project`)
| Command | Description |
|---------|-------------|
| `register --name <n> --url <u>` | Register git repo |
| `list` | List projects |
| `delete <id>` | Delete project |
### Accounts (`cw account`)
| Command | Description |
|---------|-------------|
| `add [--provider] [--email]` | Auto-discover or manually register account |
| `list` | Show accounts with exhaustion status |
| `remove <id>` | Remove account |
| `refresh` | Clear expired exhaustion markers |
## Server Wiring
The CLI is the composition root. It creates concrete implementations and passes them as `contextDeps`:
```
ServerContextDeps = Omit<TrpcAdapterOptions, 'eventBus' | 'serverStartedAt' | 'processCount'>
```
This includes all repositories, managers, and the credential manager. The server adds `eventBus`, `serverStartedAt`, and `processCount` at startup.
## Workspace Configuration
`src/config/` module:
### .cwrc File
JSON file at workspace root that marks a `cw` workspace:
```json
{ "version": 1 }
```
### findWorkspaceRoot()
Walks up directory tree looking for `.cwrc` file. Returns the directory containing it, or throws if not found.
### Schema
Currently `{ version: 1 }` — extend with new top-level keys as needed.
### Files
| File | Purpose |
|------|---------|
| `types.ts` | CwrcConfig type definition |
| `cwrc.ts` | findWorkspaceRoot(), readCwrc(), writeCwrc() |
| `index.ts` | Public API exports |

172
docs/database.md Normal file
View File

@@ -0,0 +1,172 @@
# Database Module
`src/db/` — SQLite database via better-sqlite3 + Drizzle ORM with hexagonal architecture.
## Architecture
- **Schema**: `src/db/schema.ts` — all tables, columns, relations
- **Ports** (interfaces): `src/db/repositories/*.ts` — 10 repository interfaces
- **Adapters** (implementations): `src/db/repositories/drizzle/*.ts` — 10 Drizzle adapters
- **Barrel exports**: `src/db/index.ts` re-exports everything
All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes.
## Tables
### initiatives
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | nanoid |
| name | text NOT NULL | |
| status | text enum | 'active' \| 'completed' \| 'archived', default 'active' |
| mergeRequiresApproval | integer/boolean | default true |
| mergeTarget | text nullable | target branch for merges |
| createdAt, updatedAt | integer/timestamp | |
### phases
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| initiativeId | text FK → initiatives (cascade) | |
| name | text NOT NULL | |
| content | text nullable | Tiptap JSON |
| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' |
| createdAt, updatedAt | integer/timestamp | |
### phase_dependencies
`phaseId` FK → phases, `dependsOnPhaseId` FK → phases. Both cascade delete.
### tasks
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| phaseId | text nullable FK → phases (cascade) | |
| initiativeId | text nullable FK → initiatives (cascade) | |
| parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy |
| name | text NOT NULL | |
| description | text nullable | |
| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' |
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
| priority | text enum | 'low' \| 'medium' \| 'high' |
| status | text enum | 'pending_approval' \| 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
| requiresApproval | integer/boolean nullable | null = inherit from initiative |
| order | integer | default 0 |
| createdAt, updatedAt | integer/timestamp | |
### task_dependencies
`taskId` FK → tasks, `dependsOnTaskId` FK → tasks. Both cascade delete.
### agents
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| name | text NOT NULL UNIQUE | human-readable alias (adjective-animal) |
| taskId | text nullable FK → tasks (set null) | |
| initiativeId | text nullable FK → initiatives (set null) | |
| sessionId | text nullable | CLI session ID for resume |
| worktreeId | text NOT NULL | path to agent's git worktree |
| provider | text NOT NULL | default 'claude' |
| accountId | text nullable FK → accounts (set null) | |
| status | text enum | 'idle' \| 'running' \| 'waiting_for_input' \| 'stopped' \| 'crashed' |
| mode | text enum | 'execute' \| 'discuss' \| 'breakdown' \| 'decompose' \| 'refine' |
| pid | integer nullable | OS process ID |
| exitCode | integer nullable | |
| outputFilePath | text nullable | |
| result | text nullable | JSON |
| pendingQuestions | text nullable | JSON |
| userDismissedAt | integer/timestamp nullable | |
| createdAt, updatedAt | integer/timestamp | |
### accounts
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| email | text NOT NULL | |
| provider | text NOT NULL | default 'claude' |
| configJson | text nullable | serialized .claude.json |
| credentials | text nullable | serialized .credentials.json |
| isExhausted | integer/boolean | default false |
| exhaustedUntil | integer/timestamp nullable | |
| lastUsedAt | integer/timestamp nullable | round-robin scheduling |
| sortOrder | integer | |
| createdAt, updatedAt | integer/timestamp | |
### proposals
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| agentId | text FK → agents (cascade) | |
| initiativeId | text FK → initiatives (cascade) | |
| targetType | text enum | 'page' \| 'phase' \| 'task' |
| targetId | text nullable | existing entity ID, null for creates |
| title, summary, content | text | markdown body |
| metadata | text nullable | JSON |
| status | text enum | 'pending' \| 'accepted' \| 'dismissed' |
| sortOrder | integer | |
| createdAt, updatedAt | integer/timestamp | |
### pages
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| initiativeId | text FK → initiatives (cascade) | |
| parentPageId | text nullable self-ref FK (cascade) | root page has NULL |
| title | text NOT NULL | |
| content | text nullable | Tiptap JSON |
| sortOrder | integer | |
| createdAt, updatedAt | integer/timestamp | |
### projects
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| name | text NOT NULL UNIQUE | |
| url | text NOT NULL UNIQUE | git repo URL |
| createdAt, updatedAt | integer/timestamp | |
### initiative_projects (junction)
`initiativeId` + `projectId` with unique index. Both FK cascade.
### messages
Self-referencing (parentMessageId) for threading. Sender/recipient types: 'agent' | 'user'.
### agent_log_chunks
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | |
| agentId | text NOT NULL | **NO FK** — survives agent deletion |
| agentName | text NOT NULL | snapshot for display |
| sessionNumber | integer | spawn=1, resume=prev+1 |
| content | text NOT NULL | raw JSONL chunk |
| createdAt | integer/timestamp | |
Index on `agentId` for fast queries.
## Repository Interfaces
10 repositories, each with standard CRUD plus domain-specific methods:
| Repository | Key Methods |
|-----------|-------------|
| InitiativeRepository | create, findById, findAll, findByStatus, update, delete |
| PhaseRepository | + createDependency, getDependencies, getDependents, findByInitiativeId |
| TaskRepository | + findByParentTaskId, findByPhaseId, findPendingApproval, createDependency |
| AgentRepository | + findByName, findByTaskId, findBySessionId, findByStatus |
| MessageRepository | + findPendingForUser, findRequiringResponse, findReplies |
| PageRepository | + findRootPage, getOrCreateRootPage, findByParentPageId |
| ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId |
| AccountRepository | + findNextAvailable (round-robin), markExhausted, clearExpiredExhaustion |
| ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus |
| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount |
## Migrations
Located in `drizzle/`. Applied via `ensureSchema()` on startup using Drizzle's `migrate()`.
Key rules:
- **Never use raw SQL** for schema initialization
- Run `npx drizzle-kit generate` to create migrations
- See [database-migrations.md](database-migrations.md) for full workflow
- Snapshots stale after 0008; migrations 0008+ are hand-written
Current migrations: 0000 through 0018 (19 total).

92
docs/dispatch-events.md Normal file
View File

@@ -0,0 +1,92 @@
# Dispatch & Events
`src/dispatch/` — Task and phase dispatch queues. `src/events/` — Typed event bus.
## Event Bus
`src/events/` — Typed pub/sub system for inter-module communication.
### Architecture
- **Port**: `EventBus` interface with `emit(event)` and `on(type, handler)`
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
- All events implement `BaseEvent { type, timestamp, payload }`
### Event Types (48)
| Category | Events | Count |
|----------|--------|-------|
| **Agent** | `agent:spawned`, `agent:stopped`, `agent:crashed`, `agent:resumed`, `agent:account_switched`, `agent:deleted`, `agent:waiting`, `agent:output` | 8 |
| **Task** | `task:queued`, `task:dispatched`, `task:completed`, `task:blocked`, `task:pending_approval` | 5 |
| **Phase** | `phase:queued`, `phase:started`, `phase:completed`, `phase:blocked` | 4 |
| **Merge** | `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted` | 4 |
| **Page** | `page:created`, `page:updated`, `page:deleted` | 3 |
| **Process** | `process:spawned`, `process:stopped`, `process:crashed` | 3 |
| **Server** | `server:started`, `server:stopped` | 2 |
| **Worktree** | `worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict` | 4 |
| **Account** | `account:credentials_refreshed`, `account:credentials_expired`, `account:credentials_validated` | 3 |
| **Log** | `log:entry` | 1 |
### Key Event Payloads
```typescript
AgentSpawnedEvent { agentId, name, taskId, worktreeId, provider }
AgentStoppedEvent { agentId, name, taskId, reason }
// reason: 'user_requested'|'task_complete'|'error'|'waiting_for_input'|
// 'context_complete'|'breakdown_complete'|'decompose_complete'|'refine_complete'
AgentWaitingEvent { agentId, name, taskId, sessionId, questions[] }
AgentOutputEvent { agentId, stream, data }
TaskCompletedEvent { taskId, agentId, success, message }
TaskQueuedEvent { taskId, priority, dependsOn[] }
PhaseStartedEvent { phaseId, initiativeId }
MergeConflictedEvent { taskId, agentId, worktreeId, targetBranch, conflictingFiles[] }
AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? }
```
## Task Dispatch
`src/dispatch/` — In-memory queue with dependency-ordered dispatch.
### Architecture
- **Port**: `DispatchManager` interface
- **Adapter**: `DefaultDispatchManager`
### How Task Dispatch Works
1. **Queue**`queue(taskId)` fetches task dependencies, adds to internal Map
2. **Dispatch**`dispatchNext()` finds highest-priority task with all deps complete
3. **Priority order**: high > medium > low, then oldest first (FIFO within priority)
4. **Checkpoint skip** — Tasks with type starting with `checkpoint:` skip auto-dispatch
5. **Approval check**`completeTask()` checks `requiresApproval` (task-level, then initiative-level)
6. **Approval flow** — If approval required: status → `pending_approval`, emit `task:pending_approval`
### DispatchManager Methods
| Method | Purpose |
|--------|---------|
| `queue(taskId)` | Add task to dispatch queue |
| `dispatchNext()` | Find and dispatch next ready task |
| `getNextDispatchable()` | Get next task without dispatching |
| `completeTask(taskId, agentId?)` | Complete with approval check |
| `approveTask(taskId)` | Approve pending task |
| `blockTask(taskId, reason)` | Block task with reason |
| `getQueueState()` | Return queued, ready, blocked tasks |
## Phase Dispatch
`DefaultPhaseDispatchManager` — Same pattern for phases:
1. **Queue**`queuePhase(phaseId)` validates phase is approved, gets dependencies
2. **Dispatch**`dispatchNextPhase()` finds phase with all deps complete
3. **Auto-queue tasks** — When phase starts, all pending tasks are queued
4. **Events**`phase:queued`, `phase:started`, `phase:completed`, `phase:blocked`
### PhaseDispatchManager Methods
| Method | Purpose |
|--------|---------|
| `queuePhase(phaseId)` | Queue approved phase |
| `dispatchNextPhase()` | Start next ready phase, auto-queue its tasks |
| `getNextDispatchablePhase()` | Get next phase without dispatching |
| `completePhase(phaseId)` | Mark phase complete |
| `blockPhase(phaseId, reason)` | Block phase |
| `getPhaseQueueState()` | Return queued, ready, blocked phases |

134
docs/frontend.md Normal file
View File

@@ -0,0 +1,134 @@
# Frontend
`packages/web/` — React web UI for managing initiatives, agents, and content.
## Tech Stack
| Technology | Purpose |
|-----------|---------|
| React 19 | UI framework |
| TanStack Router | File-based routing |
| tRPC React Query | Type-safe API client with caching |
| Tailwind CSS | Utility-first styling |
| shadcn/ui | Component library (button, card, dialog, dropdown, input, label, textarea, badge, sonner) |
| Tiptap | Rich text editor (ProseMirror-based) |
| Lucide | Icon library |
## Path Alias
`@/*` maps to `./src/*` (configured in `tsconfig.app.json`).
## Routes
| Route | Component | Purpose |
|-------|-----------|---------|
| `/` | `routes/index.tsx` | Dashboard / initiative list |
| `/initiatives/$id` | `routes/initiatives/$initiativeId.tsx` | Initiative detail (tabbed) |
| `/settings` | `routes/settings/index.tsx` | Settings page |
## Initiative Detail Tabs
The initiative detail page has three tabs managed via local state (not URL params):
1. **Content Tab** — Page tree + Tiptap editor, proposal review
2. **Execution Tab** — Pipeline visualization, phase management, task dispatch
3. **Review Tab** — Pending proposals from agents
## Component Inventory (73 components)
### Core Components (`src/components/`)
| Component | Purpose |
|-----------|---------|
| `InitiativeHeader` | Initiative name, project badges, merge config |
| `InitiativeContent` | Content tab with page tree + editor |
| `StatusBadge` | Colored status indicator |
| `TaskRow` | Task list item with status, priority, category |
| `QuestionForm` | Agent question form with options |
| `InboxDetailPanel` | Agent message detail + response form |
| `ProjectPicker` | Checkbox list for project selection |
| `RegisterProjectDialog` | Dialog to register new git project |
| `Skeleton` | Loading placeholder |
### Editor Components (`src/components/editor/`)
| Component | Purpose |
|-----------|---------|
| `TiptapEditor` | Core rich text editor wrapper |
| `PageEditor` | Page content editor with auto-save |
| `PhaseContentEditor` | Phase content editor |
| `ContentProposalReview` | Accept/dismiss proposals from agents |
| `SlashCommandMenu` | Slash command popup in editor |
### Execution Components (`src/components/execution/`)
| Component | Purpose |
|-----------|---------|
| `ExecutionTab` | Main execution view container |
| `ExecutionContext` | React context for execution state |
| `PhaseDetailPanel` | Phase detail with tasks, dependencies, breakdown |
| `PhaseSidebar` | Phase list sidebar |
| `TaskDetailPanel` | Task detail with agent status, output |
### Pipeline Components (`src/components/pipeline/`)
| Component | Purpose |
|-----------|---------|
| `PipelineVisualization` | DAG visualization of phase pipeline |
| `PipelineNode` | Individual phase node in pipeline |
| `PipelineEdge` | Dependency edge between nodes |
### Review Components (`src/components/review/`)
| Component | Purpose |
|-----------|---------|
| `ReviewTab` | Review tab container |
| `ProposalCard` | Individual proposal display |
### UI Primitives (`src/components/ui/`)
shadcn/ui components: badge, button, card, dialog, dropdown-menu, input, label, sonner, textarea.
## Custom Hooks (`src/hooks/`)
| Hook | Purpose |
|------|---------|
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
| `useDecomposeAgent` | Manages decompose agent for phase breakdown |
| `useAgentOutput` | Subscribes to live agent output stream |
## tRPC Client
Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query for caching and optimistic updates.
## Key User Flows
### Creating an Initiative
1. Dashboard → "New Initiative" → enter name, select projects
2. `createInitiative` mutation → auto-creates root page
3. Navigate to initiative detail
### Managing Content (Pages)
1. Content tab → page tree sidebar
2. Click page → Tiptap editor loads content
3. Edit → auto-saves via `updatePage` mutation
4. Use slash commands for formatting
### Refining Content with AI
1. Content tab → "Refine" button
2. `spawnArchitectRefine` mutation → agent analyzes pages
3. Agent creates proposals (page edits, new phases, tasks)
4. Proposals appear in review section → accept/dismiss each
### Pipeline Visualization
1. Execution tab → pipeline DAG shows phases as nodes
2. Drag to add dependencies between phases
3. Approve phases → queue for dispatch
4. Tasks auto-queued when phase starts
### Decomposing Phases
1. Select phase → "Breakdown" button
2. `spawnArchitectDecompose` mutation → agent creates task proposals
3. Accept proposals → tasks created under phase
4. View tasks in phase detail panel
## Shared Package
`packages/shared/` exports:
- `sortByPriorityAndQueueTime()` — priority-based task sorting
- `topologicalSort()` / `groupByPipelineColumn()` — phase DAG layout
- Shared type re-exports from `packages/shared/src/types.ts`

121
docs/git-process-logging.md Normal file
View File

@@ -0,0 +1,121 @@
# Git, Process, and Logging Modules
Three infrastructure modules supporting agent execution.
## Git Module (`src/git/`)
Manages git worktrees for isolated agent workspaces.
### Architecture
- **Port**: `WorktreeManager` interface
- **Adapter**: `SimpleGitWorktreeManager` using simple-git library
### WorktreeManager Methods
| Method | Purpose |
|--------|---------|
| `create(id, branch, baseBranch?)` | Create worktree with new branch (default base: 'main') |
| `remove(id)` | Clean up worktree directory |
| `list()` | All worktrees including main |
| `get(id)` | Specific worktree by ID |
| `diff(id)` | Changed files vs HEAD |
| `merge(id, targetBranch)` | Merge worktree branch into target |
### Worktree Storage
Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a worktree at `.cw-worktrees/agent/<alias>/`.
### Merge Flow
1. Check out target branch
2. `git merge <source> --no-edit`
3. On success: emit `worktree:merged`
4. On conflict: `git merge --abort`, emit `worktree:conflict` with conflicting file list
5. Restore original branch
### Project Clones
- `cloneProject(url, destPath)` — Simple git clone wrapper
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
### Events Emitted
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`
---
## Process Module (`src/process/`)
Spawns, tracks, and controls child processes.
### Classes
**ProcessRegistry** — In-memory metadata store:
- `register(info)`, `unregister(id)`, `get(id)`, `getAll()`, `getByPid(pid)`, `updateStatus(id, status)`
**ProcessManager** — Lifecycle management:
| Method | Purpose |
|--------|---------|
| `spawn(options)` | Spawn detached process (survives parent exit) |
| `stop(id)` | SIGTERM → wait 5s → SIGKILL |
| `stopAll()` | Stop all running processes in parallel |
| `restart(id)` | Stop + re-spawn with same options |
| `isRunning(id)` | Check with `process.kill(pid, 0)` |
### Spawn Details
- Uses `execa` with `detached: true`, `stdio: 'ignore'`
- Calls `subprocess.unref()` so parent can exit
- Exit handler updates registry and emits events
### Events Emitted
`process:spawned`, `process:stopped`, `process:crashed`
---
## Logger Module (`src/logger/`)
Structured logging via **pino**.
### Usage
```typescript
import { createModuleLogger } from './logger/index.js';
const log = createModuleLogger('my-module');
log.info({ key: 'value' }, 'message');
```
### Configuration
| Env Var | Effect |
|---------|--------|
| `CW_LOG_LEVEL` | Override log level |
| `CW_LOG_PRETTY` | Set to `'1'` for human-readable output |
| `NODE_ENV=development` | Default to 'debug' level |
### Output
- Default: JSON to stderr (fd 2)
- Pretty mode: pino-pretty to stdout with colors and timestamps
---
## Logging Module (`src/logging/`)
File-based per-process output capture (separate from pino).
### Classes
**LogManager** — Directory management:
- Base dir: `~/.cw/logs/`
- Structure: `{processId}/stdout.log`, `{processId}/stderr.log`
- `cleanOldLogs(retainDays)` — removes old directories by mtime
**ProcessLogWriter** — File I/O with timestamps:
- `open()` — create directories and append-mode WriteStreams
- `writeStdout(data)` / `writeStderr(data)` — prefix each line with `[YYYY-MM-DD HH:mm:ss.SSS]`
- Handles backpressure (waits for drain event)
- Emits `log:entry` event via EventBus
### Factory
```typescript
import { createLogger } from './logging/index.js';
const writer = createLogger(processId, eventBus);
await writer.open();
await writer.writeStdout('output data');
await writer.close();
```

200
docs/server-api.md Normal file
View File

@@ -0,0 +1,200 @@
# Server & API Module
`src/server/` — HTTP server, `src/trpc/` — tRPC procedures, `src/coordination/` — merge queue.
## HTTP Server
**Framework**: Native `node:http` (no Express/Fastify)
**Default**: `127.0.0.1:3847`
**PID file**: `~/.cw/server.pid`
### Routes
| Route | Method | Purpose |
|-------|--------|---------|
| `/health` | GET | Health check (`{ status, uptime, processCount }`) |
| `/status` | GET | Full server status with process list |
| `/trpc/*` | POST | All tRPC procedure calls |
### Lifecycle
- `CoordinationServer.start()` — checks PID file, creates HTTP server, emits `server:started`
- `CoordinationServer.stop()` — emits `server:stopped`, closes server, removes PID file
- `GracefulShutdown` handles SIGTERM/SIGINT/SIGHUP with 10s timeout
### tRPC Adapter
`trpc-adapter.ts` converts `node:http` IncomingMessage/ServerResponse to fetch Request/Response for tRPC. Subscriptions stream via ReadableStream bodies (SSE).
## tRPC Context
All procedures share a context with optional dependencies:
```typescript
interface TRPCContext {
eventBus: EventBus // always present
serverStartedAt: Date | null
processCount: number
agentManager?: AgentManager // optional
taskRepository?: TaskRepository
// ... all 10 repositories, 3 managers, credentialManager, workspaceRoot
}
```
Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTERNAL_SERVER_ERROR)` if a dependency is missing.
## Procedure Reference
### System
| Procedure | Type | Description |
|-----------|------|-------------|
| health | query | Health check with uptime |
| status | query | Server status with process list |
| systemHealthCheck | query | Account, agent, project health |
### Agents
| Procedure | Type | Description |
|-----------|------|-------------|
| spawnAgent | mutation | Spawn new agent (taskId, prompt, provider, mode) |
| stopAgent | mutation | Stop agent by name or ID |
| deleteAgent | mutation | Delete agent and clean up worktree |
| dismissAgent | mutation | Dismiss agent (set userDismissedAt) |
| resumeAgent | mutation | Resume with answers |
| listAgents | query | All agents |
| getAgent | query | Single agent by name or ID |
| getAgentResult | query | Execution result |
| getAgentQuestions | query | Pending questions |
| getAgentOutput | query | Full output (DB chunks or file fallback) |
| getActiveRefineAgent | query | Active refine agent for initiative |
| listWaitingAgents | query | Agents waiting for input |
| onAgentOutput | subscription | Live output stream + buffered history |
### Tasks
| Procedure | Type | Description |
|-----------|------|-------------|
| listTasks | query | Child tasks of parent |
| getTask | query | Single task |
| updateTaskStatus | mutation | Change task status |
| createInitiativeTask | mutation | Create task on initiative |
| createPhaseTask | mutation | Create task on phase |
| listInitiativeTasks | query | All tasks for initiative |
| listPhaseTasks | query | All tasks for phase |
| listPendingApprovals | query | Tasks with status=pending_approval |
| approveTask | mutation | Approve and complete task |
### Initiatives
| Procedure | Type | Description |
|-----------|------|-------------|
| createInitiative | mutation | Create with optional projectIds, auto-creates root page |
| listInitiatives | query | Filter by status |
| getInitiative | query | With projects array |
| updateInitiative | mutation | Name, status |
| updateInitiativeMergeConfig | mutation | mergeRequiresApproval, mergeTarget |
### Phases
| Procedure | Type | Description |
|-----------|------|-------------|
| createPhase | mutation | Create in initiative |
| listPhases | query | By initiative |
| getPhase | query | Single phase |
| updatePhase | mutation | Name, content, status |
| approvePhase | mutation | Validate and approve |
| deletePhase | mutation | Cascade delete |
| createPhasesFromBreakdown | mutation | Bulk create from agent output |
| createPhaseDependency | mutation | Add dependency edge |
| removePhaseDependency | mutation | Remove dependency edge |
| listInitiativePhaseDependencies | query | All dependency edges |
| getPhaseDependencies | query | What this phase depends on |
| getPhaseDependents | query | What depends on this phase |
### Phase Dispatch
| Procedure | Type | Description |
|-----------|------|-------------|
| queuePhase | mutation | Queue approved phase |
| dispatchNextPhase | mutation | Start next ready phase |
| getPhaseQueueState | query | Queue state |
| createChildTasks | mutation | Create tasks from decompose parent |
### Architect (High-Level Agent Spawning)
| Procedure | Type | Description |
|-----------|------|-------------|
| spawnArchitectDiscuss | mutation | Discussion agent |
| spawnArchitectBreakdown | mutation | Breakdown agent (generates phases) |
| spawnArchitectRefine | mutation | Refine agent (generates proposals) |
| spawnArchitectDecompose | mutation | Decompose agent (generates tasks) |
### Dispatch
| Procedure | Type | Description |
|-----------|------|-------------|
| queueTask | mutation | Add task to dispatch queue |
| dispatchNext | mutation | Dispatch next ready task |
| getQueueState | query | Queue state |
| completeTask | mutation | Complete with approval check |
### Coordination (Merge Queue)
| Procedure | Type | Description |
|-----------|------|-------------|
| queueMerge | mutation | Queue task for merge |
| processMerges | mutation | Process merge queue |
| getMergeQueueStatus | query | Queue state |
| getNextMergeable | query | Next ready-to-merge task |
### Projects
| Procedure | Type | Description |
|-----------|------|-------------|
| registerProject | mutation | Clone git repo, create record |
| listProjects | query | All projects |
| getProject | query | Single project |
| deleteProject | mutation | Delete clone and record |
| getInitiativeProjects | query | Projects for initiative |
| updateInitiativeProjects | mutation | Sync junction table |
### Pages
| Procedure | Type | Description |
|-----------|------|-------------|
| getRootPage | query | Auto-creates if missing |
| getPage | query | Single page |
| getPageUpdatedAtMap | query | Bulk updatedAt check |
| listPages | query | By initiative |
| listChildPages | query | By parent page |
| createPage | mutation | Create, emit page:created |
| updatePage | mutation | Title/content/sortOrder, emit page:updated |
| deletePage | mutation | Delete, emit page:deleted |
### Accounts
| Procedure | Type | Description |
|-----------|------|-------------|
| listAccounts | query | All accounts |
| addAccount | mutation | Create account |
| removeAccount | mutation | Delete account |
| refreshAccounts | mutation | Clear expired exhaustion |
| updateAccountAuth | mutation | Update credentials |
| markAccountExhausted | mutation | Set exhaustion timer |
| listProviderNames | query | Available provider names |
### Proposals
| Procedure | Type | Description |
|-----------|------|-------------|
| listProposals | query | By agent or initiative |
| acceptProposal | mutation | Apply side effects, auto-dismiss agent |
| dismissProposal | mutation | Dismiss, auto-dismiss agent |
| acceptAllProposals | mutation | Bulk accept with error collection |
| dismissAllProposals | mutation | Bulk dismiss |
### Subscriptions (SSE)
| Procedure | Type | Events |
|-----------|------|--------|
| onEvent | subscription | All 30+ event types |
| onAgentUpdate | subscription | agent:* events (8 types) |
| onTaskUpdate | subscription | task:* + phase:* events (8 types) |
| onPageUpdate | subscription | page:created/updated/deleted |
Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat.
## Coordination Module
`src/coordination/` manages merge queue:
- **CoordinationManager** port: `queueMerge`, `getNextMergeable`, `processMerges`, `handleConflict`, `getQueueState`
- **DefaultCoordinationManager** adapter: in-memory queue, dependency-ordered processing
- **ConflictResolutionService**: creates resolution tasks for merge conflicts
- Merge flow: queue → check deps → merge via WorktreeManager → handle conflicts
- Events: `merge:queued`, `merge:started`, `merge:completed`, `merge:conflicted`

79
docs/testing.md Normal file
View File

@@ -0,0 +1,79 @@
# Testing
`src/test/` — Test infrastructure, fixtures, and test suites.
## Framework
**vitest** (Vite-native test runner)
## Test Categories
### Unit Tests
Located alongside source files (`*.test.ts`):
- `src/agent/*.test.ts` — Manager, output handler, completion detection, file I/O, process manager
- `src/db/repositories/drizzle/*.test.ts` — Repository adapters
- `src/dispatch/*.test.ts` — Dispatch manager
- `src/git/manager.test.ts` — Worktree operations
- `src/process/*.test.ts` — Process registry and manager
- `src/logging/*.test.ts` — Log manager and writer
### E2E Tests (Mocked Agents)
`src/test/e2e/`:
| File | Scenarios |
|------|-----------|
| `happy-path.test.ts` | Single task, parallel, complex flows |
| `architect-workflow.test.ts` | Discussion + breakdown agent workflows |
| `decompose-workflow.test.ts` | Task decomposition with child tasks |
| `phase-dispatch.test.ts` | Phase-level dispatch with dependencies |
| `recovery-scenarios.test.ts` | Crash recovery, agent resume |
| `edge-cases.test.ts` | Boundary conditions |
| `extended-scenarios.test.ts` | Advanced multi-phase workflows |
### Integration Tests (Real Providers)
`src/test/integration/real-providers/`**skipped by default** (cost real money):
| File | Provider | Cost |
|------|----------|------|
| `claude-manager.test.ts` | Claude CLI | ~$0.10 |
| `codex-manager.test.ts` | Codex | varies |
| `schema-retry.test.ts` | Claude CLI | ~$0.10 |
| `crash-recovery.test.ts` | Claude CLI | ~$0.10 |
Enable with env vars: `REAL_CLAUDE_TESTS=1`, `REAL_CODEX_TESTS=1`
## Test Infrastructure
### TestHarness (`src/test/harness.ts`)
Central test utility providing:
- In-memory SQLite database with schema applied
- All 10 repository instances
- `MockAgentManager` — simulates agent behavior (done, questions, error)
- `MockWorktreeManager` — in-memory worktree simulator
- `CapturingEventBus` — captures events for assertions
- `DefaultDispatchManager` and `DefaultPhaseDispatchManager`
- 25+ helper methods for test scenarios
### Fixtures (`src/test/fixtures.ts`)
Pre-built task hierarchies for testing:
| Fixture | Structure |
|---------|-----------|
| `SIMPLE_FIXTURE` | 1 initiative → 1 phase → 1 group → 3 tasks (A→B, A→C deps) |
| `PARALLEL_FIXTURE` | 1 initiative → 1 phase → 2 groups → 4 independent tasks |
| `COMPLEX_FIXTURE` | 1 initiative → 2 phases → 4 groups → cross-phase dependencies |
### Real Provider Harness (`src/test/integration/real-providers/harness.ts`)
- Creates real database, real agent manager with real CLI tools
- Provides `describeRealClaude()` / `describeRealCodex()` that skip when env var not set
- `MINIMAL_PROMPTS` — cheap prompts for testing output parsing
## Running Tests
```sh
# Unit tests
npm test
# Specific test file
npm test -- src/agent/manager.test.ts
# Real provider tests (costs money!)
REAL_CLAUDE_TESTS=1 npm test -- src/test/integration/real-providers/ --test-timeout=300000
```

View File

@@ -0,0 +1 @@
ALTER TABLE initiatives ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'review_per_phase';

View File

@@ -0,0 +1,25 @@
-- Add change_sets and change_set_entries tables
CREATE TABLE `change_sets` (
`id` text PRIMARY KEY NOT NULL,
`agent_id` text REFERENCES `agents`(`id`) ON DELETE SET NULL,
`agent_name` text NOT NULL,
`initiative_id` text NOT NULL REFERENCES `initiatives`(`id`) ON DELETE CASCADE,
`mode` text NOT NULL,
`summary` text,
`status` text NOT NULL DEFAULT 'applied',
`reverted_at` integer,
`created_at` integer NOT NULL
);--> statement-breakpoint
CREATE TABLE `change_set_entries` (
`id` text PRIMARY KEY NOT NULL,
`change_set_id` text NOT NULL REFERENCES `change_sets`(`id`) ON DELETE CASCADE,
`entity_type` text NOT NULL,
`entity_id` text NOT NULL,
`action` text NOT NULL,
`previous_state` text,
`new_state` text,
`sort_order` integer NOT NULL DEFAULT 0,
`created_at` integer NOT NULL
);--> statement-breakpoint
CREATE INDEX `change_sets_initiative_id_idx` ON `change_sets`(`initiative_id`);--> statement-breakpoint
CREATE INDEX `change_set_entries_change_set_id_idx` ON `change_set_entries`(`change_set_id`);

View File

@@ -0,0 +1,2 @@
-- Drop proposals table (replaced by change_sets + change_set_entries)
DROP TABLE IF EXISTS `proposals`;

View File

@@ -134,6 +134,27 @@
"when": 1771113600000, "when": 1771113600000,
"tag": "0018_drop_phase_number", "tag": "0018_drop_phase_number",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1771200000000,
"tag": "0019_add_execution_mode",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1771286400000,
"tag": "0020_add_change_sets",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1771372800000,
"tag": "0021_drop_proposals",
"breakpoints": true
} }
] ]
} }

118
package-lock.json generated
View File

@@ -1297,7 +1297,6 @@
"version": "1.7.4", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.10"
@@ -1307,7 +1306,6 @@
"version": "1.7.5", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.4", "@floating-ui/core": "^1.7.4",
@@ -1318,7 +1316,6 @@
"version": "2.1.7", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.5" "@floating-ui/dom": "^1.7.5"
@@ -1332,7 +1329,6 @@
"version": "0.2.10", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@isaacs/balanced-match": { "node_modules/@isaacs/balanced-match": {
@@ -1477,6 +1473,12 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1487,7 +1489,6 @@
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.1.3" "@radix-ui/react-primitive": "2.1.3"
@@ -1511,7 +1512,6 @@
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
@@ -1538,7 +1538,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@radix-ui/react-compose-refs": "1.1.2"
@@ -1641,7 +1640,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1878,7 +1876,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/react-dom": "^2.0.0", "@floating-ui/react-dom": "^2.0.0",
@@ -2028,6 +2025,67 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2131,11 +2189,25 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/rect": "1.1.1" "@radix-ui/rect": "1.1.1"
@@ -2154,7 +2226,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1" "@radix-ui/react-use-layout-effect": "1.1.1"
@@ -2169,11 +2240,33 @@
} }
} }
}, },
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": { "node_modules/@radix-ui/rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remirror/core-constants": { "node_modules/@remirror/core-constants": {
@@ -8016,6 +8109,7 @@
"@codewalk-district/shared": "*", "@codewalk-district/shared": "*",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@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-link": "^3.19.0",

View File

@@ -1,3 +1,3 @@
export type { AppRouter } from './trpc.js'; export type { AppRouter } from './trpc.js';
export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, Proposal } from './types.js'; export type { Initiative, Phase, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project, ChangeSet, ChangeSetEntry } from './types.js';
export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js'; export { sortByPriorityAndQueueTime, topologicalSortPhases, groupPhasesByDependencyLevel, type SortableItem, type PhaseForSort, type DependencyEdge, type PipelineColumn } from './utils.js';

View File

@@ -1,6 +1,9 @@
export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, Proposal } from '../../../src/db/schema.js'; export type { Initiative, Phase, Task, Agent, Message, Page, Project, Account, ChangeSet, ChangeSetEntry } from '../../../src/db/schema.js';
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js'; export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
export type ExecutionMode = 'yolo' | 'review_per_phase';
export type PhaseStatus = 'pending' | 'approved' | 'in_progress' | 'completed' | 'blocked' | 'pending_review';
/** /**
* Shape of events received from tRPC subscription streams. * Shape of events received from tRPC subscription streams.
* Used by the frontend in onData callbacks. * Used by the frontend in onData callbacks.

View File

@@ -13,6 +13,7 @@
"@codewalk-district/shared": "*", "@codewalk-district/shared": "*",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@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-link": "^3.19.0",

View File

@@ -0,0 +1,139 @@
import { useState, useCallback } from "react";
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import type { ChangeSet } from "@codewalk-district/shared";
interface ChangeSetBannerProps {
changeSet: ChangeSet;
onDismiss: () => void;
}
const MODE_LABELS: Record<string, string> = {
breakdown: "phases",
decompose: "tasks",
refine: "pages",
};
export function ChangeSetBanner({ changeSet, onDismiss }: ChangeSetBannerProps) {
const [expanded, setExpanded] = useState(false);
const [conflicts, setConflicts] = useState<string[] | null>(null);
const detailQuery = trpc.getChangeSet.useQuery(
{ id: changeSet.id },
{ enabled: expanded },
);
const revertMutation = trpc.revertChangeSet.useMutation({
onSuccess: (result) => {
if (!result.success && "conflicts" in result) {
setConflicts(result.conflicts);
} else {
setConflicts(null);
}
},
});
const handleRevert = useCallback(
(force?: boolean) => {
revertMutation.mutate({ id: changeSet.id, force });
},
[changeSet.id, revertMutation],
);
const entries = detailQuery.data?.entries ?? [];
const entityLabel = MODE_LABELS[changeSet.mode] ?? "entities";
const isReverted = changeSet.status === "reverted";
return (
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 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" />
)}
{changeSet.summary ??
`Agent ${isReverted ? "reverted" : "applied"} ${entityLabel}`}
</button>
{isReverted && (
<span className="text-xs text-muted-foreground italic">
(reverted)
</span>
)}
</div>
<div className="flex gap-1.5 shrink-0">
{!isReverted && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevert()}
disabled={revertMutation.isPending}
className="gap-1"
>
<Undo2 className="h-3 w-3" />
{revertMutation.isPending ? "Reverting..." : "Revert"}
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</div>
{conflicts && (
<div className="rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950 p-2 space-y-2">
<p className="text-xs font-medium text-amber-800 dark:text-amber-200">
Conflicts detected:
</p>
<ul className="text-xs text-amber-700 dark:text-amber-300 list-disc pl-4 space-y-0.5">
{conflicts.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
<Button
variant="outline"
size="sm"
onClick={() => {
setConflicts(null);
handleRevert(true);
}}
disabled={revertMutation.isPending}
>
Force Revert
</Button>
</div>
)}
{expanded && (
<div className="pl-5 space-y-1 text-xs text-muted-foreground">
{detailQuery.isLoading && <p>Loading entries...</p>}
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<span className="font-mono">
{entry.action === "create" ? "+" : entry.action === "delete" ? "-" : "~"}
</span>
<span>
{entry.entityType}
{entry.newState && (() => {
try {
const parsed = JSON.parse(entry.newState);
return parsed.name || parsed.title ? `: ${parsed.name || parsed.title}` : "";
} catch { return ""; }
})()}
</span>
</div>
))}
{entries.length === 0 && !detailQuery.isLoading && (
<p>No entries</p>
)}
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,13 @@ 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { ProjectPicker } from "./ProjectPicker"; import { ProjectPicker } from "./ProjectPicker";
@@ -25,6 +32,8 @@ export function CreateInitiativeDialog({
}: CreateInitiativeDialogProps) { }: CreateInitiativeDialogProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [projectIds, setProjectIds] = useState<string[]>([]); const [projectIds, setProjectIds] = useState<string[]>([]);
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
const [mergeTarget, setMergeTarget] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -63,6 +72,8 @@ export function CreateInitiativeDialog({
if (open) { if (open) {
setName(""); setName("");
setProjectIds([]); setProjectIds([]);
setExecutionMode("review_per_phase");
setMergeTarget("");
setError(null); setError(null);
} }
}, [open]); }, [open]);
@@ -73,6 +84,8 @@ export function CreateInitiativeDialog({
createMutation.mutate({ createMutation.mutate({
name: name.trim(), name: name.trim(),
projectIds: projectIds.length > 0 ? projectIds : undefined, projectIds: projectIds.length > 0 ? projectIds : undefined,
executionMode,
mergeTarget: mergeTarget.trim() || null,
}); });
} }
@@ -98,6 +111,32 @@ export function CreateInitiativeDialog({
autoFocus autoFocus
/> />
</div> </div>
<div className="space-y-2">
<Label>Execution Mode</Label>
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="review_per_phase">Review per Phase</SelectItem>
<SelectItem value="yolo">YOLO (auto-merge)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="merge-target">
Merge Target Branch{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="merge-target"
placeholder="e.g. feat/auth"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Projects{" "} Projects{" "}

View File

@@ -22,6 +22,7 @@ interface ExecutionTabProps {
phasesLoading: boolean; phasesLoading: boolean;
phasesLoaded: boolean; phasesLoaded: boolean;
dependencyEdges: DependencyEdge[]; dependencyEdges: DependencyEdge[];
mergeTarget?: string | null;
} }
export function ExecutionTab({ export function ExecutionTab({
@@ -30,6 +31,7 @@ export function ExecutionTab({
phasesLoading, phasesLoading,
phasesLoaded, phasesLoaded,
dependencyEdges, dependencyEdges,
mergeTarget,
}: ExecutionTabProps) { }: ExecutionTabProps) {
// Topological sort // Topological sort
const sortedPhases = useMemo( const sortedPhases = useMemo(
@@ -257,6 +259,7 @@ export function ExecutionTab({
tasksLoading={allTasksQuery.isLoading} tasksLoading={allTasksQuery.isLoading}
onDelete={() => deletePhase.mutate({ id: activePhase.id })} onDelete={() => deletePhase.mutate({ id: activePhase.id })}
decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null} decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null}
mergeTarget={mergeTarget}
/> />
) : ( ) : (
<PhaseDetailEmpty /> <PhaseDetailEmpty />

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronLeft, Pencil, Check } from "lucide-react"; import { ChevronLeft, Pencil, Check, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
@@ -12,6 +12,8 @@ export interface InitiativeHeaderProps {
id: string; id: string;
name: string; name: string;
status: string; status: string;
executionMode?: string;
mergeTarget?: string | null;
}; };
projects?: Array<{ id: string; name: string; url: string }>; projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void; onBack: () => void;
@@ -60,6 +62,24 @@ export function InitiativeHeader({
</Button> </Button>
<h1 className="text-xl font-semibold">{initiative.name}</h1> <h1 className="text-xl font-semibold">{initiative.name}</h1>
<StatusBadge status={initiative.status} /> <StatusBadge status={initiative.status} />
{initiative.executionMode && (
<Badge
variant="outline"
className={
initiative.executionMode === "yolo"
? "border-orange-300 text-orange-700 text-[10px]"
: "border-blue-300 text-blue-700 text-[10px]"
}
>
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"}
</Badge>
)}
{initiative.mergeTarget && (
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
<GitBranch className="h-3 w-3" />
{initiative.mergeTarget}
</Badge>
)}
{!editing && projects && projects.length > 0 && ( {!editing && projects && projects.length > 0 && (
<> <>
{projects.map((p) => ( {projects.map((p) => (

View File

@@ -12,6 +12,8 @@ const statusStyles: Record<string, string> = {
approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200", approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200", in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200", blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
pending_review: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
pending_approval: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
}; };
const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200"; const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200";

View File

@@ -1,234 +0,0 @@
import { useState, useCallback, useMemo } from "react";
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import type { Proposal } from "@codewalk-district/shared";
interface ContentProposalReviewProps {
proposals: Proposal[];
agentCreatedAt: Date;
agentId: string;
onDismiss: () => void;
}
export function ContentProposalReview({
proposals,
agentCreatedAt,
agentId,
onDismiss,
}: ContentProposalReviewProps) {
const [accepted, setAccepted] = useState<Set<string>>(new Set());
const [acceptError, setAcceptError] = useState<string | null>(null);
const utils = trpc.useUtils();
const acceptMutation = trpc.acceptProposal.useMutation({
onMutate: async ({ id }) => {
await utils.listProposals.cancel({ agentId });
const previousProposals = utils.listProposals.getData({ agentId });
utils.listProposals.setData({ agentId }, (old = []) =>
old.map(p => p.id === id ? { ...p, status: 'accepted' as const } : p)
);
return { previousProposals };
},
onSuccess: () => {
setAcceptError(null);
},
onError: (err, _variables, context) => {
if (context?.previousProposals) {
utils.listProposals.setData({ agentId }, context.previousProposals);
}
setAcceptError(err.message);
},
});
const acceptAllMutation = trpc.acceptAllProposals.useMutation({
onSuccess: (result) => {
if (result.failed > 0) {
setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`);
} else {
setAcceptError(null);
onDismiss();
}
},
});
const dismissAllMutation = trpc.dismissAllProposals.useMutation({
onMutate: async () => {
await utils.listProposals.cancel({ agentId });
const previousProposals = utils.listProposals.getData({ agentId });
utils.listProposals.setData({ agentId }, []);
return { previousProposals };
},
onError: (_err, _variables, context) => {
if (context?.previousProposals) {
utils.listProposals.setData({ agentId }, context.previousProposals);
}
},
});
const handleAccept = useCallback(
async (proposal: Proposal) => {
await acceptMutation.mutateAsync({ id: proposal.id });
setAccepted((prev) => new Set(prev).add(proposal.id));
},
[acceptMutation],
);
const handleAcceptAll = useCallback(async () => {
await acceptAllMutation.mutateAsync({ agentId });
}, [acceptAllMutation, agentId]);
const handleDismissAll = useCallback(() => {
dismissAllMutation.mutate({ agentId });
}, [dismissAllMutation, agentId]);
// Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1)
const pageTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const p of proposals) {
if (p.targetType === 'page' && p.targetId) ids.add(p.targetId);
}
return [...ids];
}, [proposals]);
const pageUpdatedAtMap = trpc.getPageUpdatedAtMap.useQuery(
{ ids: pageTargetIds },
{ enabled: pageTargetIds.length > 0 },
);
const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
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={acceptAllMutation.isPending}
>
Accept All
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleDismissAll}
disabled={dismissAllMutation.isPending}
>
{dismissAllMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</div>
</div>
{acceptError && (
<div className="flex items-center gap-1.5 text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{acceptError}</span>
</div>
)}
<div className="space-y-2">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
agentCreatedAt={agentCreatedAt}
pageUpdatedAt={proposal.targetType === 'page' && proposal.targetId
? pageUpdatedAtMap.data?.[proposal.targetId] ?? null
: null}
onAccept={() => handleAccept(proposal)}
isAccepting={acceptMutation.isPending}
/>
))}
</div>
</div>
);
}
interface ProposalCardProps {
proposal: Proposal;
isAccepted: boolean;
agentCreatedAt: Date;
pageUpdatedAt: string | null;
onAccept: () => void;
isAccepting: boolean;
}
function ProposalCard({
proposal,
isAccepted,
agentCreatedAt,
pageUpdatedAt,
onAccept,
isAccepting,
}: ProposalCardProps) {
const [expanded, setExpanded] = useState(false);
const isStale =
proposal.targetType === 'page' &&
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.title}
</button>
{proposal.summary && (
<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 && proposal.content && (
<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.content}</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react";
import { Loader2, AlertCircle } from "lucide-react"; import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm"; import { QuestionForm } from "@/components/QuestionForm";
import { ContentProposalReview } from "./ContentProposalReview"; import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { RefineSpawnDialog } from "../RefineSpawnDialog"; import { RefineSpawnDialog } from "../RefineSpawnDialog";
import { useRefineAgent } from "@/hooks"; import { useRefineAgent } from "@/hooks";
@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) { export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
// All agent logic is now encapsulated in the hook // All agent logic is now encapsulated in the hook
const { state, agent, questions, proposals, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId); const { state, agent, questions, changeSet, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId);
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent), // spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
// so these callbacks won't change on every render. // so these callbacks won't change on every render.
@@ -95,26 +95,24 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
); );
} }
// Completed with proposals // Completed with change set
if (state === "completed" && proposals && proposals.length > 0) { if (state === "completed" && changeSet) {
return ( return (
<div className="mb-3"> <div className="mb-3">
<ContentProposalReview <ChangeSetBanner
proposals={proposals} changeSet={changeSet}
agentCreatedAt={new Date(agent!.createdAt)}
agentId={agent!.id}
onDismiss={handleDismiss} onDismiss={handleDismiss}
/> />
</div> </div>
); );
} }
// Completed without proposals (or generic result) // Completed without changes
if (state === "completed") { if (state === "completed") {
return ( return (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2"> <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"> <span className="text-sm text-muted-foreground">
Agent completed no changes proposed. Agent completed no changes made.
</span> </span>
<Button variant="ghost" size="sm" onClick={handleDismiss}> <Button variant="ghost" size="sm" onClick={handleDismiss}>
Dismiss Dismiss

View File

@@ -3,7 +3,7 @@ import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation"; import { useSpawnMutation } from "@/hooks/useSpawnMutation";
import { ContentProposalReview } from "@/components/editor/ContentProposalReview"; import { ChangeSetBanner } from "@/components/ChangeSetBanner";
interface BreakdownSectionProps { interface BreakdownSectionProps {
initiativeId: string; initiativeId: string;
@@ -38,14 +38,14 @@ export function BreakdownSection({
const isBreakdownRunning = breakdownAgent?.status === "running"; const isBreakdownRunning = breakdownAgent?.status === "running";
// Query proposals when we have a completed breakdown agent // Query change sets when we have a completed breakdown agent
const proposalsQuery = trpc.listProposals.useQuery( const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: breakdownAgent?.id ?? "" }, { agentId: breakdownAgent?.id ?? "" },
{ enabled: !!breakdownAgent && breakdownAgent.status === "idle" }, { enabled: !!breakdownAgent && breakdownAgent.status === "idle" },
); );
const pendingProposals = useMemo( const latestChangeSet = useMemo(
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"), () => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[proposalsQuery.data], [changeSetsQuery.data],
); );
const dismissMutation = trpc.dismissAgent.useMutation(); const dismissMutation = trpc.dismissAgent.useMutation();
@@ -68,19 +68,17 @@ export function BreakdownSection({
return null; return null;
} }
// If phases exist and no pending proposals to review, hide section // If phases exist and no change set to show, hide section
if (phases.length > 0 && pendingProposals.length === 0) { if (phases.length > 0 && !latestChangeSet) {
return null; return null;
} }
// Show proposal review when breakdown agent completed with pending proposals // Show change set banner when breakdown agent completed
if (breakdownAgent?.status === "idle" && pendingProposals.length > 0) { if (breakdownAgent?.status === "idle" && latestChangeSet) {
return ( return (
<div className="py-4"> <div className="py-4">
<ContentProposalReview <ChangeSetBanner
proposals={pendingProposals} changeSet={latestChangeSet}
agentCreatedAt={new Date(breakdownAgent.createdAt)}
agentId={breakdownAgent.id}
onDismiss={handleDismiss} onDismiss={handleDismiss}
/> />
</div> </div>

View File

@@ -1,11 +1,11 @@
import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react"; import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { TaskRow, type SerializedTask } from "@/components/TaskRow"; import { TaskRow, type SerializedTask } from "@/components/TaskRow";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ContentProposalReview } from "@/components/editor/ContentProposalReview"; import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -36,6 +36,7 @@ interface PhaseDetailPanelProps {
tasks: SerializedTask[]; tasks: SerializedTask[];
tasksLoading: boolean; tasksLoading: boolean;
onDelete?: () => void; onDelete?: () => void;
mergeTarget?: string | null;
decomposeAgent: { decomposeAgent: {
id: string; id: string;
status: string; status: string;
@@ -52,6 +53,7 @@ export function PhaseDetailPanel({
tasks, tasks,
tasksLoading, tasksLoading,
onDelete, onDelete,
mergeTarget,
decomposeAgent, decomposeAgent,
}: PhaseDetailPanelProps) { }: PhaseDetailPanelProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } = const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
@@ -135,14 +137,14 @@ export function PhaseDetailPanel({
handleRegisterTasks(phase.id, entries); handleRegisterTasks(phase.id, entries);
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]); }, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
// --- Proposals for decompose agent --- // --- Change sets for decompose agent ---
const proposalsQuery = trpc.listProposals.useQuery( const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: decomposeAgent?.id ?? "" }, { agentId: decomposeAgent?.id ?? "" },
{ enabled: !!decomposeAgent && decomposeAgent.status === "idle" }, { enabled: !!decomposeAgent && decomposeAgent.status === "idle" },
); );
const pendingProposals = useMemo( const latestChangeSet = useMemo(
() => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"), () => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[proposalsQuery.data], [changeSetsQuery.data],
); );
// --- Decompose spawn --- // --- Decompose spawn ---
@@ -152,13 +154,20 @@ export function PhaseDetailPanel({
decomposeMutation.mutate({ phaseId: phase.id }); decomposeMutation.mutate({ phaseId: phase.id });
}, [phase.id, decomposeMutation]); }, [phase.id, decomposeMutation]);
// --- Dismiss handler for proposal review --- // --- Dismiss handler for decompose agent ---
const dismissMutation = trpc.dismissAgent.useMutation(); const dismissMutation = trpc.dismissAgent.useMutation();
const handleDismissDecompose = useCallback(() => { const handleDismissDecompose = useCallback(() => {
if (!decomposeAgent) return; if (!decomposeAgent) return;
dismissMutation.mutate({ id: decomposeAgent.id }); dismissMutation.mutate({ id: decomposeAgent.id });
}, [decomposeAgent, dismissMutation]); }, [decomposeAgent, dismissMutation]);
// Compute phase branch name if initiative has a merge target
const phaseBranch = mergeTarget
? `${mergeTarget}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
: null;
const isPendingReview = phase.status === "pending_review";
const sortedTasks = sortByPriorityAndQueueTime(tasks); const sortedTasks = sortByPriorityAndQueueTime(tasks);
const hasTasks = tasks.length > 0; const hasTasks = tasks.length > 0;
const isDecomposeRunning = const isDecomposeRunning =
@@ -166,8 +175,8 @@ export function PhaseDetailPanel({
decomposeAgent?.status === "waiting_for_input"; decomposeAgent?.status === "waiting_for_input";
const showBreakdownButton = const showBreakdownButton =
!decomposeAgent && !hasTasks; !decomposeAgent && !hasTasks;
const showProposals = const showChangeSet =
decomposeAgent?.status === "idle" && pendingProposals.length > 0; decomposeAgent?.status === "idle" && !!latestChangeSet;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -198,6 +207,12 @@ export function PhaseDetailPanel({
</h3> </h3>
)} )}
<StatusBadge status={phase.status} /> <StatusBadge status={phase.status} />
{phaseBranch && ["in_progress", "completed", "pending_review"].includes(phase.status) && (
<span className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[10px] font-mono text-muted-foreground">
<GitBranch className="h-3 w-3" />
{phaseBranch}
</span>
)}
{/* Breakdown button in header */} {/* Breakdown button in header */}
{showBreakdownButton && ( {showBreakdownButton && (
@@ -243,6 +258,16 @@ export function PhaseDetailPanel({
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Pending review banner */}
{isPendingReview && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
This phase is pending review. Switch to the{" "}
<span className="font-semibold">Review</span> tab to view the diff and approve.
</p>
</div>
)}
{/* Tiptap Editor */} {/* Tiptap Editor */}
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} /> <PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
@@ -317,12 +342,10 @@ export function PhaseDetailPanel({
)} )}
</div> </div>
{/* Decompose proposals */} {/* Decompose change set */}
{showProposals && ( {showChangeSet && (
<ContentProposalReview <ChangeSetBanner
proposals={pendingProposals as any} changeSet={latestChangeSet!}
agentCreatedAt={new Date(decomposeAgent!.createdAt)}
agentId={decomposeAgent!.id}
onDismiss={handleDismissDecompose} onDismiss={handleDismissDecompose}
/> />
)} )}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useMemo, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DUMMY_REVIEW } from "./dummy-data"; import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar"; import { ReviewSidebar } from "./ReviewSidebar";
import type { ReviewComment, ReviewStatus, DiffLine } from "./types"; import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
@@ -9,11 +10,44 @@ interface ReviewTabProps {
initiativeId: string; initiativeId: string;
} }
export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) { export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>(DUMMY_REVIEW.comments); const [comments, setComments] = useState<ReviewComment[]>([]);
const [status, setStatus] = useState<ReviewStatus>(DUMMY_REVIEW.status); const [status, setStatus] = useState<ReviewStatus>("pending");
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
[phasesQuery.data],
);
// Select first pending review phase
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
// Fetch diff for active phase
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId },
);
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
toast.success("Phase approved and merged");
phasesQuery.refetch();
},
onError: (err) => {
toast.error(err.message);
},
});
const files = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
const handleAddComment = useCallback( const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => { (filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
const newComment: ReviewComment = { const newComment: ReviewComment = {
@@ -45,9 +79,9 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
}, []); }, []);
const handleApprove = useCallback(() => { const handleApprove = useCallback(() => {
setStatus("approved"); if (!activePhaseId) return;
toast.success("Review approved"); approveMutation.mutate({ phaseId: activePhaseId });
}, []); }, [activePhaseId, approveMutation]);
const handleRequestChanges = useCallback(() => { const handleRequestChanges = useCallback(() => {
setStatus("changes_requested"); setStatus("changes_requested");
@@ -63,42 +97,85 @@ export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
} }
}, []); }, []);
return ( if (pendingReviewPhases.length === 0) {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]"> return (
{/* Left: Diff */} <div className="flex h-64 items-center justify-center text-muted-foreground">
<div className="min-w-0"> <p>No phases pending review</p>
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
<DiffViewer
files={DUMMY_REVIEW.files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
</div> </div>
);
}
{/* Right: Sidebar */} const activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase";
<div className="w-full lg:w-[300px]">
<ReviewSidebar return (
title={DUMMY_REVIEW.title} <div className="space-y-4">
description={DUMMY_REVIEW.description} {/* Phase selector if multiple pending */}
author={DUMMY_REVIEW.author} {pendingReviewPhases.length > 1 && (
status={status} <div className="flex gap-2">
sourceBranch={DUMMY_REVIEW.sourceBranch} {pendingReviewPhases.map((phase) => (
targetBranch={DUMMY_REVIEW.targetBranch} <button
files={DUMMY_REVIEW.files} key={phase.id}
comments={comments} onClick={() => setSelectedPhaseId(phase.id)}
onApprove={handleApprove} className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
onRequestChanges={handleRequestChanges} phase.id === activePhaseId
onFileClick={handleFileClick} ? "border-primary bg-primary/10 font-medium"
/> : "border-border hover:bg-muted"
</div> }`}
>
{phase.name}
</button>
))}
</div>
)}
{diffQuery.isLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Loading diff...
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
{/* Left: Diff */}
<div className="min-w-0">
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review: {activePhaseName}</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
{files.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
No changes in this phase
</div>
) : (
<DiffViewer
files={files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
)}
</div>
{/* Right: Sidebar */}
<div className="w-full lg:w-[300px]">
<ReviewSidebar
title={`Phase: ${activePhaseName}`}
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
author="system"
status={status}
sourceBranch={diffQuery.data?.sourceBranch ?? ""}
targetBranch={diffQuery.data?.targetBranch ?? ""}
files={files}
comments={comments}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
onFileClick={handleFileClick}
/>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import type { PendingQuestions, Proposal } from '@codewalk-district/shared'; import type { PendingQuestions, ChangeSet } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed'; export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
@@ -18,8 +18,8 @@ export interface UseRefineAgentResult {
state: RefineAgentState; state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */ /** Questions from the agent (when state is 'waiting') */
questions: PendingQuestions | null; questions: PendingQuestions | null;
/** Proposal rows from the DB (when state is 'completed') */ /** Latest applied change set (when state is 'completed') */
proposals: Proposal[] | null; changeSet: ChangeSet | null;
/** Raw result message (when state is 'completed') */ /** Raw result message (when state is 'completed') */
result: string | null; result: string | null;
/** Mutation for spawning a new refine agent */ /** Mutation for spawning a new refine agent */
@@ -82,8 +82,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
{ enabled: state === 'waiting' && !!agent }, { enabled: state === 'waiting' && !!agent },
); );
// Fetch proposals from DB when completed // Fetch change sets from DB when completed
const proposalsQuery = trpc.listProposals.useQuery( const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: agent?.id ?? '' }, { agentId: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent }, { enabled: state === 'completed' && !!agent },
); );
@@ -94,12 +94,11 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
{ enabled: state === 'completed' && !!agent }, { enabled: state === 'completed' && !!agent },
); );
// Filter to only pending proposals // Get latest applied change set
const proposals = useMemo(() => { const changeSet = useMemo(() => {
if (!proposalsQuery.data || proposalsQuery.data.length === 0) return null; if (!changeSetsQuery.data || changeSetsQuery.data.length === 0) return null;
const pending = proposalsQuery.data.filter((p) => p.status === 'pending'); return changeSetsQuery.data.find((cs) => cs.status === 'applied') ?? null;
return pending.length > 0 ? pending : null; }, [changeSetsQuery.data]);
}, [proposalsQuery.data]);
const result = useMemo(() => { const result = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) return null; if (!resultQuery.data?.success || !resultQuery.data.message) return null;
@@ -182,9 +181,7 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
return { previousAgents }; return { previousAgents };
}, },
onSuccess: () => { onSuccess: () => {},
void utils.listProposals.invalidate();
},
onError: (err, variables, context) => { onError: (err, variables, context) => {
// Revert optimistic update // Revert optimistic update
if (context?.previousAgents) { if (context?.previousAgents) {
@@ -256,18 +253,18 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const refresh = useCallback(() => { const refresh = useCallback(() => {
void utils.getActiveRefineAgent.invalidate({ initiativeId }); void utils.getActiveRefineAgent.invalidate({ initiativeId });
void utils.listProposals.invalidate(); void utils.listChangeSets.invalidate();
}, [utils, initiativeId]); }, [utils, initiativeId]);
const isLoading = agentQuery.isLoading || const isLoading = agentQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) || (state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading)); (state === 'completed' && (resultQuery.isLoading || changeSetsQuery.isLoading));
return { return {
agent, agent,
state, state,
questions: questionsQuery.data ?? null, questions: questionsQuery.data ?? null,
proposals, changeSet,
result, result,
spawn, spawn,
resume, resume,

View File

@@ -35,7 +35,7 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Agents --- // --- Agents ---
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"], stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
deleteAgent: ["listAgents"], deleteAgent: ["listAgents"],
dismissAgent: ["listAgents", "listProposals"], dismissAgent: ["listAgents"],
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"], resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
respondToMessage: ["listWaitingAgents", "listMessages"], respondToMessage: ["listWaitingAgents", "listMessages"],
@@ -66,10 +66,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"], queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"], approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"],
// --- Proposals --- // --- Change Sets ---
acceptProposal: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"], revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage"],
acceptAllProposals: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
dismissAllProposals: ["listProposals", "listAgents"],
// --- Pages --- // --- Pages ---
updatePage: ["listPages", "getPage", "getRootPage"], updatePage: ["listPages", "getPage", "getRootPage"],

View File

@@ -89,6 +89,8 @@ function InitiativeDetailPage() {
id: initiative.id, id: initiative.id,
name: initiative.name, name: initiative.name,
status: initiative.status, status: initiative.status,
executionMode: (initiative as any).executionMode as string | undefined,
mergeTarget: (initiative as any).mergeTarget as string | null | undefined,
}; };
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects; const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
@@ -135,6 +137,7 @@ function InitiativeDetailPage() {
phasesLoading={phasesQuery.isLoading} phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess} phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []} dependencyEdges={depsQuery.data ?? []}
mergeTarget={serializedInitiative.mergeTarget}
/> />
)} )}
{activeTab === "execution" && ( {activeTab === "execution" && (

View File

@@ -9,13 +9,11 @@ import { tmpdir } from 'node:os';
import { rmSync } from 'node:fs'; import { rmSync } from 'node:fs';
import { OutputHandler } from './output-handler.js'; import { OutputHandler } from './output-handler.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
describe('Completion Detection Fix', () => { describe('Completion Detection Fix', () => {
let tempDir: string; let tempDir: string;
let outputHandler: OutputHandler; let outputHandler: OutputHandler;
let mockAgentRepo: AgentRepository; let mockAgentRepo: AgentRepository;
let mockProposalRepo: ProposalRepository;
beforeEach(async () => { beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'completion-test-')); tempDir = await mkdtemp(join(tmpdir(), 'completion-test-'));
@@ -26,11 +24,7 @@ describe('Completion Detection Fix', () => {
findById: vi.fn().mockResolvedValue({ id: 'test-agent', mode: 'refine' }), findById: vi.fn().mockResolvedValue({ id: 'test-agent', mode: 'refine' }),
} as any; } as any;
mockProposalRepo = { outputHandler = new OutputHandler(mockAgentRepo);
create: vi.fn(),
} as any;
outputHandler = new OutputHandler(mockAgentRepo, undefined, mockProposalRepo);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -50,6 +50,7 @@ describe('writeInputFiles', () => {
status: 'active', status: 'active',
mergeRequiresApproval: true, mergeRequiresApproval: true,
mergeTarget: 'main', mergeTarget: 'main',
executionMode: 'review_per_phase',
createdAt: new Date('2026-01-01'), createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'), updatedAt: new Date('2026-01-02'),
}; };

View File

@@ -21,7 +21,10 @@ import type {
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { AccountRepository } from '../db/repositories/account-repository.js'; import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { ProposalRepository } from '../db/repositories/proposal-repository.js'; import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import { generateUniqueAlias } from './alias.js'; import { generateUniqueAlias } from './alias.js';
import type { import type {
@@ -72,14 +75,17 @@ export class MultiProviderAgentManager implements AgentManager {
private accountRepository?: AccountRepository, private accountRepository?: AccountRepository,
private eventBus?: EventBus, private eventBus?: EventBus,
private credentialManager?: AccountCredentialManager, private credentialManager?: AccountCredentialManager,
private proposalRepository?: ProposalRepository, private changeSetRepository?: ChangeSetRepository,
private phaseRepository?: PhaseRepository,
private taskRepository?: TaskRepository,
private pageRepository?: PageRepository,
private logChunkRepository?: LogChunkRepository, private logChunkRepository?: LogChunkRepository,
private debug: boolean = false, private debug: boolean = false,
) { ) {
this.signalManager = new FileSystemSignalManager(); this.signalManager = new FileSystemSignalManager();
this.processManager = new ProcessManager(workspaceRoot, projectRepository, eventBus); this.processManager = new ProcessManager(workspaceRoot, projectRepository, eventBus);
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager); this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
this.outputHandler = new OutputHandler(repository, eventBus, proposalRepository, this.signalManager); this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager); this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
this.lifecycleController = createLifecycleController({ this.lifecycleController = createLifecycleController({
repository, repository,
@@ -164,9 +170,9 @@ export class MultiProviderAgentManager implements AgentManager {
* Used by both legacy spawn() and new lifecycle-managed spawn. * Used by both legacy spawn() and new lifecycle-managed spawn.
*/ */
private async spawnInternal(options: SpawnAgentOptions): Promise<AgentInfo> { private async spawnInternal(options: SpawnAgentOptions): Promise<AgentInfo> {
const { taskId, cwd, mode = 'execute', provider: providerName = 'claude', initiativeId } = options; const { taskId, cwd, mode = 'execute', provider: providerName = 'claude', initiativeId, baseBranch, branchName } = options;
let { prompt } = options; let { prompt } = options;
log.info({ taskId, provider: providerName, initiativeId, mode }, 'spawn requested'); log.info({ taskId, provider: providerName, initiativeId, mode, baseBranch, branchName }, 'spawn requested');
const provider = getProvider(providerName); const provider = getProvider(providerName);
if (!provider) { if (!provider) {
@@ -215,8 +221,8 @@ export class MultiProviderAgentManager implements AgentManager {
// 2. Create isolated worktrees // 2. Create isolated worktrees
let agentCwd: string; let agentCwd: string;
if (initiativeId) { if (initiativeId) {
log.debug({ alias, initiativeId }, 'creating initiative-based worktrees'); log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId); agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch ?? 'main', branchName);
// Log projects linked to the initiative // Log projects linked to the initiative
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);

View File

@@ -8,7 +8,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { OutputHandler } from './output-handler.js'; import { OutputHandler } from './output-handler.js';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
import type { EventBus, DomainEvent, AgentWaitingEvent } from '../events/types.js'; import type { EventBus, DomainEvent, AgentWaitingEvent } from '../events/types.js';
import { getProvider } from './providers/registry.js'; import { getProvider } from './providers/registry.js';
@@ -44,19 +43,6 @@ function createMockAgentRepository() {
}; };
} }
function createMockProposalRepository() {
return {
createMany: vi.fn(),
findByAgentId: vi.fn(),
findByInitiativeId: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
findAll: vi.fn(),
};
}
// ============================================================================= // =============================================================================
// Tests // Tests
// ============================================================================= // =============================================================================
@@ -64,7 +50,6 @@ function createMockProposalRepository() {
describe('OutputHandler', () => { describe('OutputHandler', () => {
let outputHandler: OutputHandler; let outputHandler: OutputHandler;
let mockAgentRepo: ReturnType<typeof createMockAgentRepository>; let mockAgentRepo: ReturnType<typeof createMockAgentRepository>;
let mockProposalRepo: ReturnType<typeof createMockProposalRepository>;
let eventBus: ReturnType<typeof createMockEventBus>; let eventBus: ReturnType<typeof createMockEventBus>;
const mockAgent = { const mockAgent = {
@@ -78,13 +63,11 @@ describe('OutputHandler', () => {
beforeEach(() => { beforeEach(() => {
mockAgentRepo = createMockAgentRepository(); mockAgentRepo = createMockAgentRepository();
mockProposalRepo = createMockProposalRepository();
eventBus = createMockEventBus(); eventBus = createMockEventBus();
outputHandler = new OutputHandler( outputHandler = new OutputHandler(
mockAgentRepo as any, mockAgentRepo as any,
eventBus, eventBus,
mockProposalRepo as any
); );
// Setup default mock behavior // Setup default mock behavior

View File

@@ -10,7 +10,10 @@ import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { ProposalRepository, CreateProposalData } from '../db/repositories/proposal-repository.js'; import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js';
import type { import type {
EventBus, EventBus,
AgentStoppedEvent, AgentStoppedEvent,
@@ -36,6 +39,7 @@ import {
readFrontmatterFile, readFrontmatterFile,
} from './file-io.js'; } from './file-io.js';
import { getProvider } from './providers/registry.js'; import { getProvider } from './providers/registry.js';
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
import type { SignalManager } from './lifecycle/signal-manager.js'; import type { SignalManager } from './lifecycle/signal-manager.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
@@ -83,7 +87,10 @@ export class OutputHandler {
constructor( constructor(
private repository: AgentRepository, private repository: AgentRepository,
private eventBus?: EventBus, private eventBus?: EventBus,
private proposalRepository?: ProposalRepository, private changeSetRepository?: ChangeSetRepository,
private phaseRepository?: PhaseRepository,
private taskRepository?: TaskRepository,
private pageRepository?: PageRepository,
private signalManager?: SignalManager, private signalManager?: SignalManager,
) {} ) {}
@@ -404,6 +411,7 @@ export class OutputHandler {
/** /**
* Process output files from agent workdir after successful completion. * Process output files from agent workdir after successful completion.
* Performs direct writes to entities and records change sets.
*/ */
private async processOutputFiles( private async processOutputFiles(
agentId: string, agentId: string,
@@ -414,27 +422,82 @@ export class OutputHandler {
const agentWorkdir = getAgentWorkdir(agent.worktreeId); const agentWorkdir = getAgentWorkdir(agent.worktreeId);
const summary = readSummary(agentWorkdir); const summary = readSummary(agentWorkdir);
const initiativeId = agent.initiativeId; const initiativeId = agent.initiativeId;
const canWriteProposals = this.proposalRepository && initiativeId; const canWriteChangeSets = this.changeSetRepository && initiativeId;
let resultMessage = summary?.body ?? 'Task completed'; let resultMessage = summary?.body ?? 'Task completed';
switch (mode) { switch (mode) {
case 'breakdown': { case 'breakdown': {
const phases = readPhaseFiles(agentWorkdir); const phases = readPhaseFiles(agentWorkdir);
if (canWriteProposals && phases.length > 0) { if (canWriteChangeSets && this.phaseRepository && phases.length > 0) {
const proposalData: CreateProposalData[] = phases.map((p, i) => ({ const entries: CreateChangeSetEntryData[] = [];
agentId,
initiativeId, // First pass: create phases
targetType: 'phase' as const, for (const [i, p] of phases.entries()) {
targetId: null, try {
title: p.title, const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
summary: null, const created = await this.phaseRepository.create({
content: p.body || null, id: p.id ?? undefined,
metadata: JSON.stringify({ fileId: p.id, dependencies: p.dependencies }), initiativeId,
status: 'pending' as const, name: p.title,
sortOrder: i, content: tiptapContent,
})); });
await this.proposalRepository!.createMany(proposalData); entries.push({
resultMessage = summary?.body ?? `${phases.length} phase proposals created`; entityType: 'phase',
entityId: created.id,
action: 'create',
newState: JSON.stringify(created),
sortOrder: i,
});
this.eventBus?.emit({
type: 'phase:started' as const,
timestamp: new Date(),
payload: { phaseId: created.id, initiativeId },
});
} catch (err) {
log.warn({ agentId, phase: p.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase');
}
}
// Second pass: create phase dependencies
let depSortOrder = entries.length;
for (const p of phases) {
const phaseId = p.id;
if (!phaseId || !Array.isArray(p.dependencies)) continue;
for (const depFileId of p.dependencies) {
try {
await this.phaseRepository.createDependency(phaseId, depFileId);
entries.push({
entityType: 'phase_dependency',
entityId: `${phaseId}:${depFileId}`,
action: 'create',
newState: JSON.stringify({ phaseId, dependsOnPhaseId: depFileId }),
sortOrder: depSortOrder++,
});
} catch (err) {
log.warn({ agentId, phaseId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase dependency');
}
}
}
if (entries.length > 0) {
try {
const cs = await this.changeSetRepository!.createWithEntries({
agentId,
agentName: agent.name,
initiativeId,
mode: 'breakdown',
summary: summary?.body ?? `Created ${phases.length} phases`,
}, entries);
this.eventBus?.emit({
type: 'changeset:created' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'breakdown', entryCount: entries.length },
});
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
}
}
resultMessage = summary?.body ?? `${phases.length} phases created`;
} else { } else {
resultMessage = JSON.stringify({ summary: summary?.body, phases }); resultMessage = JSON.stringify({ summary: summary?.body, phases });
} }
@@ -442,32 +505,58 @@ export class OutputHandler {
} }
case 'decompose': { case 'decompose': {
const tasks = readTaskFiles(agentWorkdir); const tasks = readTaskFiles(agentWorkdir);
if (canWriteProposals && tasks.length > 0) { if (canWriteChangeSets && this.taskRepository && tasks.length > 0) {
// Read phase info from input context if available
const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md')); const phaseInput = readFrontmatterFile(join(agentWorkdir, '.cw', 'input', 'phase.md'));
const phaseId = (phaseInput?.data?.id as string) ?? null; const phaseId = (phaseInput?.data?.id as string) ?? null;
const entries: CreateChangeSetEntryData[] = [];
const proposalData: CreateProposalData[] = tasks.map((t, i) => ({ for (const [i, t] of tasks.entries()) {
agentId, try {
initiativeId, const created = await this.taskRepository.create({
targetType: 'task' as const, initiativeId,
targetId: null, phaseId,
title: t.title, parentTaskId: agent.taskId ?? null,
summary: null, name: t.title,
content: t.body || null, description: t.body ?? undefined,
metadata: JSON.stringify({ category: (t.category as any) ?? 'execute',
fileId: t.id, type: (t.type as any) ?? 'auto',
category: t.category, });
type: t.type, entries.push({
dependencies: t.dependencies, entityType: 'task',
parentTaskId: agent.taskId, entityId: created.id,
phaseId, action: 'create',
}), newState: JSON.stringify(created),
status: 'pending' as const, sortOrder: i,
sortOrder: i, });
})); this.eventBus?.emit({
await this.proposalRepository!.createMany(proposalData); type: 'task:completed' as const,
resultMessage = summary?.body ?? `${tasks.length} task proposals created`; timestamp: new Date(),
payload: { taskId: created.id, agentId, success: true, message: 'Task created by decompose' },
});
} catch (err) {
log.warn({ agentId, task: t.title, err: err instanceof Error ? err.message : String(err) }, 'failed to create task');
}
}
if (entries.length > 0) {
try {
const cs = await this.changeSetRepository!.createWithEntries({
agentId,
agentName: agent.name,
initiativeId,
mode: 'decompose',
summary: summary?.body ?? `Created ${tasks.length} tasks`,
}, entries);
this.eventBus?.emit({
type: 'changeset:created' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'decompose', entryCount: entries.length },
});
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
}
}
resultMessage = summary?.body ?? `${tasks.length} tasks created`;
} else { } else {
resultMessage = JSON.stringify({ summary: summary?.body, tasks }); resultMessage = JSON.stringify({ summary: summary?.body, tasks });
} }
@@ -480,43 +569,63 @@ export class OutputHandler {
} }
case 'refine': { case 'refine': {
const pages = readPageFiles(agentWorkdir); const pages = readPageFiles(agentWorkdir);
if (canWriteProposals) { if (canWriteChangeSets && this.pageRepository && pages.length > 0) {
if (pages.length > 0) { const entries: CreateChangeSetEntryData[] = [];
// Create proposals for actual page changes
const proposalData: CreateProposalData[] = pages.map((p, i) => ({ for (const [i, p] of pages.entries()) {
agentId, try {
initiativeId, if (!p.pageId) continue;
targetType: 'page' as const, const existing = await this.pageRepository.findById(p.pageId);
targetId: p.pageId, if (!existing) {
title: p.title, log.warn({ agentId, pageId: p.pageId }, 'page not found for refine update');
summary: p.summary || null, continue;
content: p.body || null, }
metadata: null, const previousState = JSON.stringify(existing);
status: 'pending' as const, const tiptapJson = markdownToTiptapJson(p.body || '');
sortOrder: i, await this.pageRepository.update(p.pageId, {
})); content: JSON.stringify(tiptapJson),
await this.proposalRepository!.createMany(proposalData); title: p.title,
resultMessage = summary?.body ?? `${pages.length} page proposals created`; });
} else { const updated = await this.pageRepository.findById(p.pageId);
// Create a synthetic completion proposal when no changes are proposed entries.push({
// This ensures the dismiss flow always goes through proposals domain entityType: 'page',
const completionProposal: CreateProposalData = { entityId: p.pageId,
agentId, action: 'update',
initiativeId, previousState,
targetType: 'page' as const, newState: JSON.stringify(updated),
targetId: null, sortOrder: i,
title: 'Analysis Complete', });
summary: 'Agent completed review with no changes proposed', this.eventBus?.emit({
content: summary?.body || 'The agent has finished analyzing the content and determined no changes are needed.', type: 'page:updated' as const,
metadata: JSON.stringify({ synthetic: true, reason: 'no_changes' }), timestamp: new Date(),
status: 'pending' as const, payload: { pageId: p.pageId, initiativeId, title: p.title },
sortOrder: 0, });
}; } catch (err) {
await this.proposalRepository!.createMany([completionProposal]); log.warn({ agentId, pageId: p.pageId, err: err instanceof Error ? err.message : String(err) }, 'failed to update page');
resultMessage = summary?.body ?? 'Analysis completed with 1 completion notice'; }
} }
if (entries.length > 0) {
try {
const cs = await this.changeSetRepository!.createWithEntries({
agentId,
agentName: agent.name,
initiativeId,
mode: 'refine',
summary: summary?.body ?? `Updated ${entries.length} pages`,
}, entries);
this.eventBus?.emit({
type: 'changeset:created' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'refine', entryCount: entries.length },
});
} catch (err) {
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record change set after successful writes');
}
}
resultMessage = summary?.body ?? `${entries.length} pages updated`;
} else { } else {
resultMessage = JSON.stringify({ summary: summary?.body, proposals: pages }); resultMessage = JSON.stringify({ summary: summary?.body, pages });
} }
break; break;
} }

View File

@@ -55,6 +55,7 @@ export class ProcessManager {
alias: string, alias: string,
initiativeId: string, initiativeId: string,
baseBranch: string = 'main', baseBranch: string = 'main',
branchName?: string,
): Promise<string> { ): Promise<string> {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
const agentWorkdir = this.getAgentWorkdir(alias); const agentWorkdir = this.getAgentWorkdir(alias);
@@ -70,7 +71,7 @@ export class ProcessManager {
for (const project of projects) { for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot); const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir); const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);
const worktree = await worktreeManager.create(project.name, `agent/${alias}`, baseBranch); const worktree = await worktreeManager.create(project.name, branchName ?? `agent/${alias}`, baseBranch);
const worktreePath = worktree.path; const worktreePath = worktree.path;
const pathExists = existsSync(worktreePath); const pathExists = existsSync(worktreePath);

View File

@@ -45,6 +45,12 @@ export interface SpawnAgentOptions {
provider?: string; provider?: string;
/** Initiative ID — when set, worktrees are created for all linked projects */ /** Initiative ID — when set, worktrees are created for all linked projects */
initiativeId?: string; initiativeId?: string;
/** Phase ID — used by dispatch for branch-aware spawning */
phaseId?: string;
/** Base branch for worktree creation (defaults to 'main') */
baseBranch?: string;
/** Explicit branch name for worktree (overrides 'agent/<alias>') */
branchName?: string;
/** Context data to write as input files in agent workdir */ /** Context data to write as input files in agent workdir */
inputContext?: AgentInputContext; inputContext?: AgentInputContext;
} }

View File

@@ -17,7 +17,7 @@ import {
DrizzlePageRepository, DrizzlePageRepository,
DrizzleProjectRepository, DrizzleProjectRepository,
DrizzleAccountRepository, DrizzleAccountRepository,
DrizzleProposalRepository, DrizzleChangeSetRepository,
DrizzleLogChunkRepository, DrizzleLogChunkRepository,
} from './db/index.js'; } from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js'; import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
@@ -28,7 +28,7 @@ import type { AgentRepository } from './db/repositories/agent-repository.js';
import type { PageRepository } from './db/repositories/page-repository.js'; import type { PageRepository } from './db/repositories/page-repository.js';
import type { ProjectRepository } from './db/repositories/project-repository.js'; import type { ProjectRepository } from './db/repositories/project-repository.js';
import type { AccountRepository } from './db/repositories/account-repository.js'; import type { AccountRepository } from './db/repositories/account-repository.js';
import type { ProposalRepository } from './db/repositories/proposal-repository.js'; import type { ChangeSetRepository } from './db/repositories/change-set-repository.js';
import type { LogChunkRepository } from './db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from './db/repositories/log-chunk-repository.js';
import type { EventBus } from './events/index.js'; import type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js'; import { createEventBus } from './events/index.js';
@@ -40,6 +40,10 @@ import type { AccountCredentialManager } from './agent/credentials/types.js';
import { DefaultDispatchManager } from './dispatch/manager.js'; import { DefaultDispatchManager } from './dispatch/manager.js';
import { DefaultPhaseDispatchManager } from './dispatch/phase-manager.js'; import { DefaultPhaseDispatchManager } from './dispatch/phase-manager.js';
import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js';
import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
import type { BranchManager } from './git/branch-manager.js';
import { ExecutionOrchestrator } from './execution/orchestrator.js';
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
import { findWorkspaceRoot } from './config/index.js'; import { findWorkspaceRoot } from './config/index.js';
import { createModuleLogger } from './logger/index.js'; import { createModuleLogger } from './logger/index.js';
import type { ServerContextDeps } from './server/index.js'; import type { ServerContextDeps } from './server/index.js';
@@ -60,7 +64,7 @@ export interface Repositories {
pageRepository: PageRepository; pageRepository: PageRepository;
projectRepository: ProjectRepository; projectRepository: ProjectRepository;
accountRepository: AccountRepository; accountRepository: AccountRepository;
proposalRepository: ProposalRepository; changeSetRepository: ChangeSetRepository;
logChunkRepository: LogChunkRepository; logChunkRepository: LogChunkRepository;
} }
@@ -78,7 +82,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
pageRepository: new DrizzlePageRepository(db), pageRepository: new DrizzlePageRepository(db),
projectRepository: new DrizzleProjectRepository(db), projectRepository: new DrizzleProjectRepository(db),
accountRepository: new DrizzleAccountRepository(db), accountRepository: new DrizzleAccountRepository(db),
proposalRepository: new DrizzleProposalRepository(db), changeSetRepository: new DrizzleChangeSetRepository(db),
logChunkRepository: new DrizzleLogChunkRepository(db), logChunkRepository: new DrizzleLogChunkRepository(db),
}; };
} }
@@ -100,6 +104,8 @@ export interface Container extends Repositories {
agentManager: MultiProviderAgentManager; agentManager: MultiProviderAgentManager;
dispatchManager: DispatchManager; dispatchManager: DispatchManager;
phaseDispatchManager: PhaseDispatchManager; phaseDispatchManager: PhaseDispatchManager;
branchManager: BranchManager;
executionOrchestrator: ExecutionOrchestrator;
/** Extract the subset of deps that CoordinationServer needs. */ /** Extract the subset of deps that CoordinationServer needs. */
toContextDeps(): ServerContextDeps; toContextDeps(): ServerContextDeps;
@@ -153,7 +159,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
repos.accountRepository, repos.accountRepository,
eventBus, eventBus,
credentialManager, credentialManager,
repos.proposalRepository, repos.changeSetRepository,
repos.phaseRepository,
repos.taskRepository,
repos.pageRepository,
repos.logChunkRepository, repos.logChunkRepository,
options?.debug ?? false, options?.debug ?? false,
); );
@@ -163,6 +172,10 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
await agentManager.reconcileAfterRestart(); await agentManager.reconcileAfterRestart();
log.info('agent reconciliation complete'); log.info('agent reconciliation complete');
// Branch manager
const branchManager = new SimpleGitBranchManager();
log.info('branch manager created');
// Dispatch managers // Dispatch managers
const dispatchManager = new DefaultDispatchManager( const dispatchManager = new DefaultDispatchManager(
repos.taskRepository, repos.taskRepository,
@@ -170,15 +183,43 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
agentManager, agentManager,
eventBus, eventBus,
repos.initiativeRepository, repos.initiativeRepository,
repos.phaseRepository,
); );
const phaseDispatchManager = new DefaultPhaseDispatchManager( const phaseDispatchManager = new DefaultPhaseDispatchManager(
repos.phaseRepository, repos.phaseRepository,
repos.taskRepository, repos.taskRepository,
dispatchManager, dispatchManager,
eventBus, eventBus,
repos.initiativeRepository,
repos.projectRepository,
branchManager,
workspaceRoot,
); );
log.info('dispatch managers created'); log.info('dispatch managers created');
// Conflict resolution service (for orchestrator)
const conflictResolutionService = new DefaultConflictResolutionService(
repos.taskRepository,
repos.agentRepository,
repos.messageRepository,
eventBus,
);
// Execution orchestrator
const executionOrchestrator = new ExecutionOrchestrator(
branchManager,
repos.phaseRepository,
repos.taskRepository,
repos.initiativeRepository,
repos.projectRepository,
phaseDispatchManager,
conflictResolutionService,
eventBus,
workspaceRoot,
);
executionOrchestrator.start();
log.info('execution orchestrator started');
return { return {
db, db,
eventBus, eventBus,
@@ -189,6 +230,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
agentManager, agentManager,
dispatchManager, dispatchManager,
phaseDispatchManager, phaseDispatchManager,
branchManager,
executionOrchestrator,
...repos, ...repos,
toContextDeps(): ServerContextDeps { toContextDeps(): ServerContextDeps {
@@ -197,6 +240,8 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
credentialManager, credentialManager,
dispatchManager, dispatchManager,
phaseDispatchManager, phaseDispatchManager,
branchManager,
executionOrchestrator,
workspaceRoot, workspaceRoot,
...repos, ...repos,
}; };

View File

@@ -23,14 +23,23 @@ import type { MessageRepository } from '../db/repositories/message-repository.js
* Service interface for handling merge conflicts. * Service interface for handling merge conflicts.
* This is the PORT - implementations are ADAPTERS. * This is the PORT - implementations are ADAPTERS.
*/ */
/**
* Branch context for merge conflicts from the branch hierarchy.
*/
export interface MergeContext {
sourceBranch: string;
targetBranch: string;
}
export interface ConflictResolutionService { export interface ConflictResolutionService {
/** /**
* Handle a merge conflict by creating resolution task and notifying agent. * Handle a merge conflict by creating resolution task and notifying agent.
* *
* @param taskId - ID of the task that conflicted * @param taskId - ID of the task that conflicted
* @param conflicts - List of conflicting file paths * @param conflicts - List of conflicting file paths
* @param mergeContext - Optional branch context for branch hierarchy merges
*/ */
handleConflict(taskId: string, conflicts: string[]): Promise<void>; handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void>;
} }
// ============================================================================= // =============================================================================
@@ -55,7 +64,7 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
* Handle a merge conflict. * Handle a merge conflict.
* Creates a conflict-resolution task and notifies the agent via message. * Creates a conflict-resolution task and notifies the agent via message.
*/ */
async handleConflict(taskId: string, conflicts: string[]): Promise<void> { async handleConflict(taskId: string, conflicts: string[], mergeContext?: MergeContext): Promise<void> {
// Get original task for context // Get original task for context
const originalTask = await this.taskRepository.findById(taskId); const originalTask = await this.taskRepository.findById(taskId);
if (!originalTask) { if (!originalTask) {
@@ -69,15 +78,28 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
} }
// Build conflict description // Build conflict description
const conflictDescription = [ const descriptionLines = [
'Merge conflicts detected. Resolve conflicts in the following files:', 'Merge conflicts detected. Resolve conflicts in the following files:',
'', '',
...conflicts.map((f) => `- ${f}`), ...conflicts.map((f) => `- ${f}`),
'', '',
`Original task: ${originalTask.name}`, `Original task: ${originalTask.name}`,
'', '',
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.', ];
].join('\n');
if (mergeContext) {
descriptionLines.push(
`Resolve merge conflicts between branch "${mergeContext.sourceBranch}" and "${mergeContext.targetBranch}".`,
`Run: git merge ${mergeContext.sourceBranch} --no-edit`,
'Resolve all conflicts, then: git add . && git commit',
);
} else {
descriptionLines.push(
'Instructions: Resolve merge conflicts in the listed files, then mark task complete.',
);
}
const conflictDescription = descriptionLines.join('\n');
// Create new conflict-resolution task // Create new conflict-resolution task
const conflictTask = await this.taskRepository.create({ const conflictTask = await this.taskRepository.create({
@@ -86,8 +108,9 @@ export class DefaultConflictResolutionService implements ConflictResolutionServi
initiativeId: originalTask.initiativeId, initiativeId: originalTask.initiativeId,
name: `Resolve conflicts: ${originalTask.name}`, name: `Resolve conflicts: ${originalTask.name}`,
description: conflictDescription, description: conflictDescription,
category: mergeContext ? 'merge' : 'execute',
type: 'auto', type: 'auto',
priority: 'high', // Conflicts should be resolved quickly priority: 'high',
status: 'pending', status: 'pending',
order: originalTask.order + 1, order: originalTask.order + 1,
}); });

View File

@@ -0,0 +1,36 @@
/**
* Change Set Repository Port Interface
*
* Port for ChangeSet aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { ChangeSet, ChangeSetEntry } from '../schema.js';
export type CreateChangeSetData = {
agentId: string | null;
agentName: string;
initiativeId: string;
mode: 'breakdown' | 'decompose' | 'refine';
summary?: string | null;
};
export type CreateChangeSetEntryData = {
entityType: 'page' | 'phase' | 'task' | 'phase_dependency';
entityId: string;
action: 'create' | 'update' | 'delete';
previousState?: string | null;
newState?: string | null;
sortOrder?: number;
};
export type ChangeSetWithEntries = ChangeSet & { entries: ChangeSetEntry[] };
export interface ChangeSetRepository {
createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet>;
findById(id: string): Promise<ChangeSet | null>;
findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null>;
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
findByAgentId(agentId: string): Promise<ChangeSet[]>;
markReverted(id: string): Promise<ChangeSet>;
}

View File

@@ -0,0 +1,110 @@
/**
* Drizzle Change Set Repository Adapter
*
* Implements ChangeSetRepository interface using Drizzle ORM.
*/
import { eq, desc, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
import type {
ChangeSetRepository,
CreateChangeSetData,
CreateChangeSetEntryData,
ChangeSetWithEntries,
} from '../change-set-repository.js';
export class DrizzleChangeSetRepository implements ChangeSetRepository {
constructor(private db: DrizzleDatabase) {}
async createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet> {
const id = nanoid();
const now = new Date();
// Use transaction for atomicity
return this.db.transaction(async (tx) => {
const [created] = await tx.insert(changeSets).values({
id,
agentId: data.agentId,
agentName: data.agentName,
initiativeId: data.initiativeId,
mode: data.mode,
summary: data.summary ?? null,
status: 'applied',
createdAt: now,
}).returning();
if (entries.length > 0) {
const entryRows = entries.map((e, i) => ({
id: nanoid(),
changeSetId: id,
entityType: e.entityType,
entityId: e.entityId,
action: e.action,
previousState: e.previousState ?? null,
newState: e.newState ?? null,
sortOrder: e.sortOrder ?? i,
createdAt: now,
}));
await tx.insert(changeSetEntries).values(entryRows);
}
return created;
});
}
async findById(id: string): Promise<ChangeSet | null> {
const result = await this.db
.select()
.from(changeSets)
.where(eq(changeSets.id, id))
.limit(1);
return result[0] ?? null;
}
async findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null> {
const cs = await this.findById(id);
if (!cs) return null;
const entries = await this.db
.select()
.from(changeSetEntries)
.where(eq(changeSetEntries.changeSetId, id))
.orderBy(asc(changeSetEntries.sortOrder));
return { ...cs, entries };
}
async findByInitiativeId(initiativeId: string): Promise<ChangeSet[]> {
return this.db
.select()
.from(changeSets)
.where(eq(changeSets.initiativeId, initiativeId))
.orderBy(desc(changeSets.createdAt));
}
async findByAgentId(agentId: string): Promise<ChangeSet[]> {
return this.db
.select()
.from(changeSets)
.where(eq(changeSets.agentId, agentId))
.orderBy(desc(changeSets.createdAt));
}
async markReverted(id: string): Promise<ChangeSet> {
const [updated] = await this.db
.update(changeSets)
.set({ status: 'reverted', revertedAt: new Date() })
.where(eq(changeSets.id, id))
.returning();
if (!updated) {
throw new Error(`ChangeSet not found: ${id}`);
}
return updated;
}
}

View File

@@ -13,5 +13,5 @@ export { DrizzleMessageRepository } from './message.js';
export { DrizzlePageRepository } from './page.js'; export { DrizzlePageRepository } from './page.js';
export { DrizzleProjectRepository } from './project.js'; export { DrizzleProjectRepository } from './project.js';
export { DrizzleAccountRepository } from './account.js'; export { DrizzleAccountRepository } from './account.js';
export { DrizzleProposalRepository } from './proposal.js'; export { DrizzleChangeSetRepository } from './change-set.js';
export { DrizzleLogChunkRepository } from './log-chunk.js'; export { DrizzleLogChunkRepository } from './log-chunk.js';

View File

@@ -1,133 +0,0 @@
/**
* Drizzle Proposal Repository Adapter
*
* Implements ProposalRepository interface using Drizzle ORM.
*/
import { eq, and, count, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { proposals, type Proposal } from '../../schema.js';
import type {
ProposalRepository,
CreateProposalData,
UpdateProposalData,
} from '../proposal-repository.js';
export class DrizzleProposalRepository implements ProposalRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateProposalData): Promise<Proposal> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(proposals).values({
id,
...data,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async createMany(data: CreateProposalData[]): Promise<Proposal[]> {
if (data.length === 0) return [];
const now = new Date();
const rows = data.map((d) => ({
id: nanoid(),
...d,
createdAt: now,
updatedAt: now,
}));
return this.db.insert(proposals).values(rows).returning();
}
async findById(id: string): Promise<Proposal | null> {
const result = await this.db
.select()
.from(proposals)
.where(eq(proposals.id, id))
.limit(1);
return result[0] ?? null;
}
async findByAgentId(agentId: string): Promise<Proposal[]> {
return this.db
.select()
.from(proposals)
.where(eq(proposals.agentId, agentId))
.orderBy(asc(proposals.sortOrder));
}
async findByInitiativeId(initiativeId: string): Promise<Proposal[]> {
return this.db
.select()
.from(proposals)
.where(eq(proposals.initiativeId, initiativeId))
.orderBy(asc(proposals.sortOrder));
}
async findByAgentIdAndStatus(agentId: string, status: string): Promise<Proposal[]> {
return this.db
.select()
.from(proposals)
.where(
and(
eq(proposals.agentId, agentId),
eq(proposals.status, status as 'pending' | 'accepted' | 'dismissed'),
),
)
.orderBy(asc(proposals.sortOrder));
}
async update(id: string, data: UpdateProposalData): Promise<Proposal> {
const [updated] = await this.db
.update(proposals)
.set({ ...data, updatedAt: new Date() })
.where(eq(proposals.id, id))
.returning();
if (!updated) {
throw new Error(`Proposal not found: ${id}`);
}
return updated;
}
async updateManyByAgentId(agentId: string, data: UpdateProposalData): Promise<void> {
await this.db
.update(proposals)
.set({ ...data, updatedAt: new Date() })
.where(eq(proposals.agentId, agentId));
}
async updateManyByAgentIdAndStatus(agentId: string, currentStatus: string, data: UpdateProposalData): Promise<void> {
await this.db
.update(proposals)
.set({ ...data, updatedAt: new Date() })
.where(
and(
eq(proposals.agentId, agentId),
eq(proposals.status, currentStatus as 'pending' | 'accepted' | 'dismissed'),
),
);
}
async countByAgentIdAndStatus(agentId: string, status: string): Promise<number> {
const result = await this.db
.select({ count: count() })
.from(proposals)
.where(
and(
eq(proposals.agentId, agentId),
eq(proposals.status, status as 'pending' | 'accepted' | 'dismissed'),
),
);
return result[0]?.count ?? 0;
}
}

View File

@@ -58,10 +58,11 @@ export type {
} from './account-repository.js'; } from './account-repository.js';
export type { export type {
ProposalRepository, ChangeSetRepository,
CreateProposalData, CreateChangeSetData,
UpdateProposalData, CreateChangeSetEntryData,
} from './proposal-repository.js'; ChangeSetWithEntries,
} from './change-set-repository.js';
export type { export type {
LogChunkRepository, LogChunkRepository,

View File

@@ -1,35 +0,0 @@
/**
* Proposal Repository Port Interface
*
* Port for Proposal aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Proposal, NewProposal } from '../schema.js';
/**
* Data for creating a new proposal.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreateProposalData = Omit<NewProposal, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a proposal.
*/
export type UpdateProposalData = Partial<Pick<NewProposal, 'status'>>;
/**
* Proposal Repository Port
*/
export interface ProposalRepository {
create(data: CreateProposalData): Promise<Proposal>;
createMany(data: CreateProposalData[]): Promise<Proposal[]>;
findById(id: string): Promise<Proposal | null>;
findByAgentId(agentId: string): Promise<Proposal[]>;
findByInitiativeId(initiativeId: string): Promise<Proposal[]>;
findByAgentIdAndStatus(agentId: string, status: string): Promise<Proposal[]>;
update(id: string, data: UpdateProposalData): Promise<Proposal>;
updateManyByAgentId(agentId: string, data: UpdateProposalData): Promise<void>;
updateManyByAgentIdAndStatus(agentId: string, currentStatus: string, data: UpdateProposalData): Promise<void>;
countByAgentIdAndStatus(agentId: string, status: string): Promise<number>;
}

View File

@@ -26,6 +26,9 @@ export const initiatives = sqliteTable('initiatives', {
.notNull() .notNull()
.default(true), .default(true),
mergeTarget: text('merge_target'), // Target branch for merges (e.g., 'feature/xyz') mergeTarget: text('merge_target'), // Target branch for merges (e.g., 'feature/xyz')
executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] })
.notNull()
.default('review_per_phase'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}); });
@@ -35,7 +38,7 @@ export const initiativesRelations = relations(initiatives, ({ many }) => ({
pages: many(pages), pages: many(pages),
initiativeProjects: many(initiativeProjects), initiativeProjects: many(initiativeProjects),
tasks: many(tasks), tasks: many(tasks),
proposals: many(proposals), changeSets: many(changeSets),
})); }));
export type Initiative = InferSelectModel<typeof initiatives>; export type Initiative = InferSelectModel<typeof initiatives>;
@@ -52,7 +55,7 @@ export const phases = sqliteTable('phases', {
.references(() => initiatives.id, { onDelete: 'cascade' }), .references(() => initiatives.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
content: text('content'), content: text('content'),
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked'] }) status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
.notNull() .notNull()
.default('pending'), .default('pending'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
@@ -286,51 +289,75 @@ export const agentsRelations = relations(agents, ({ one, many }) => ({
fields: [agents.accountId], fields: [agents.accountId],
references: [accounts.id], references: [accounts.id],
}), }),
proposals: many(proposals), changeSets: many(changeSets),
})); }));
export type Agent = InferSelectModel<typeof agents>; export type Agent = InferSelectModel<typeof agents>;
export type NewAgent = InferInsertModel<typeof agents>; export type NewAgent = InferInsertModel<typeof agents>;
// ============================================================================ // ============================================================================
// PROPOSALS // CHANGE SETS
// ============================================================================ // ============================================================================
export const proposals = sqliteTable('proposals', { export const changeSets = sqliteTable('change_sets', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
agentId: text('agent_id') agentId: text('agent_id')
.notNull() .references(() => agents.id, { onDelete: 'set null' }),
.references(() => agents.id, { onDelete: 'cascade' }), agentName: text('agent_name').notNull(),
initiativeId: text('initiative_id') initiativeId: text('initiative_id')
.notNull() .notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }), .references(() => initiatives.id, { onDelete: 'cascade' }),
targetType: text('target_type', { enum: ['page', 'phase', 'task'] }).notNull(), mode: text('mode', { enum: ['breakdown', 'decompose', 'refine'] }).notNull(),
targetId: text('target_id'), // existing entity ID (e.g. pageId for updates), null for creates
title: text('title').notNull(),
summary: text('summary'), summary: text('summary'),
content: text('content'), // markdown body (pages), description (phases/tasks) status: text('status', { enum: ['applied', 'reverted'] })
metadata: text('metadata'), // JSON: type-specific data (phase number, task category, deps)
status: text('status', { enum: ['pending', 'accepted', 'dismissed'] })
.notNull() .notNull()
.default('pending'), .default('applied'),
sortOrder: integer('sort_order').notNull().default(0), revertedAt: integer('reverted_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => [
}); index('change_sets_initiative_id_idx').on(table.initiativeId),
]);
export const proposalsRelations = relations(proposals, ({ one }) => ({ export const changeSetsRelations = relations(changeSets, ({ one, many }) => ({
agent: one(agents, { agent: one(agents, {
fields: [proposals.agentId], fields: [changeSets.agentId],
references: [agents.id], references: [agents.id],
}), }),
initiative: one(initiatives, { initiative: one(initiatives, {
fields: [proposals.initiativeId], fields: [changeSets.initiativeId],
references: [initiatives.id], references: [initiatives.id],
}), }),
entries: many(changeSetEntries),
}));
export type ChangeSet = InferSelectModel<typeof changeSets>;
export type NewChangeSet = InferInsertModel<typeof changeSets>;
export const changeSetEntries = sqliteTable('change_set_entries', {
id: text('id').primaryKey(),
changeSetId: text('change_set_id')
.notNull()
.references(() => changeSets.id, { onDelete: 'cascade' }),
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency'] }).notNull(),
entityId: text('entity_id').notNull(),
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
previousState: text('previous_state'), // JSON snapshot, null for creates
newState: text('new_state'), // JSON snapshot, null for deletes
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('change_set_entries_change_set_id_idx').on(table.changeSetId),
]);
export const changeSetEntriesRelations = relations(changeSetEntries, ({ one }) => ({
changeSet: one(changeSets, {
fields: [changeSetEntries.changeSetId],
references: [changeSets.id],
}),
})); }));
export type Proposal = InferSelectModel<typeof proposals>; export type ChangeSetEntry = InferSelectModel<typeof changeSetEntries>;
export type NewProposal = InferInsertModel<typeof proposals>; export type NewChangeSetEntry = InferInsertModel<typeof changeSetEntries>;
// ============================================================================ // ============================================================================
// MESSAGES // MESSAGES

View File

@@ -19,8 +19,10 @@ import type { AgentManager } from '../agent/types.js';
import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { MessageRepository } from '../db/repositories/message-repository.js'; import type { MessageRepository } from '../db/repositories/message-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { Task } from '../db/schema.js'; import type { Task } from '../db/schema.js';
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js'; import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { createModuleLogger } from '../logger/index.js'; import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('dispatch'); const log = createModuleLogger('dispatch');
@@ -59,7 +61,8 @@ export class DefaultDispatchManager implements DispatchManager {
private messageRepository: MessageRepository, private messageRepository: MessageRepository,
private agentManager: AgentManager, private agentManager: AgentManager,
private eventBus: EventBus, private eventBus: EventBus,
private initiativeRepository?: InitiativeRepository private initiativeRepository?: InitiativeRepository,
private phaseRepository?: PhaseRepository,
) {} ) {}
/** /**
@@ -320,10 +323,39 @@ export class DefaultDispatchManager implements DispatchManager {
}; };
} }
// Compute branch info for branch-aware spawning
let baseBranch: string | undefined;
let branchName: string | undefined;
if (task.phaseId && task.initiativeId && this.initiativeRepository && this.phaseRepository) {
try {
const initiative = await this.initiativeRepository.findById(task.initiativeId);
const phase = await this.phaseRepository.findById(task.phaseId);
if (initiative?.mergeTarget && phase) {
const initBranch = initiativeBranchName(initiative.mergeTarget);
if (task.category === 'merge') {
// Merge tasks work directly on the phase branch
baseBranch = initBranch;
branchName = phaseBranchName(initBranch, phase.name);
} else {
baseBranch = phaseBranchName(initBranch, phase.name);
branchName = taskBranchName(initBranch, task.id);
}
}
} catch {
// Non-fatal: fall back to default branching
}
}
// Spawn agent with task (alias auto-generated by agent manager) // Spawn agent with task (alias auto-generated by agent manager)
const agent = await this.agentManager.spawn({ const agent = await this.agentManager.spawn({
taskId: nextTask.taskId, taskId: nextTask.taskId,
initiativeId: task.initiativeId ?? undefined,
phaseId: task.phaseId ?? undefined,
prompt: task.description || task.name, prompt: task.description || task.name,
baseBranch,
branchName,
}); });
log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched'); log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched');

View File

@@ -16,7 +16,15 @@ import type {
} from '../events/index.js'; } from '../events/index.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js'; import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js'; import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
import { initiativeBranchName, phaseBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('phase-dispatch');
// ============================================================================= // =============================================================================
// Internal Types // Internal Types
@@ -51,7 +59,11 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
private phaseRepository: PhaseRepository, private phaseRepository: PhaseRepository,
private taskRepository: TaskRepository, private taskRepository: TaskRepository,
private dispatchManager: DispatchManager, private dispatchManager: DispatchManager,
private eventBus: EventBus private eventBus: EventBus,
private initiativeRepository?: InitiativeRepository,
private projectRepository?: ProjectRepository,
private branchManager?: BranchManager,
private workspaceRoot?: string,
) {} ) {}
/** /**
@@ -156,6 +168,26 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
// Update phase status to 'in_progress' // Update phase status to 'in_progress'
await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' }); await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' });
// Create phase branch in all linked project clones
if (this.initiativeRepository && this.projectRepository && this.branchManager && this.workspaceRoot) {
try {
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (initiative?.mergeTarget) {
const initBranch = initiativeBranchName(initiative.mergeTarget);
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
await this.branchManager.ensureBranch(clonePath, initBranch, 'main');
await this.branchManager.ensureBranch(clonePath, phBranch, initBranch);
}
log.info({ phaseId: nextPhase.phaseId, phBranch, initBranch }, 'phase branch created');
}
} catch (err) {
log.error({ phaseId: nextPhase.phaseId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase branch');
}
}
// Remove from queue (now being worked on) // Remove from queue (now being worked on)
this.phaseQueue.delete(nextPhase.phaseId); this.phaseQueue.delete(nextPhase.phaseId);

View File

@@ -37,6 +37,9 @@ export type {
PhaseStartedEvent, PhaseStartedEvent,
PhaseCompletedEvent, PhaseCompletedEvent,
PhaseBlockedEvent, PhaseBlockedEvent,
PhasePendingReviewEvent,
PhaseMergedEvent,
TaskMergedEvent,
MergeQueuedEvent, MergeQueuedEvent,
MergeStartedEvent, MergeStartedEvent,
MergeCompletedEvent, MergeCompletedEvent,

View File

@@ -320,6 +320,34 @@ export interface PhaseBlockedEvent extends DomainEvent {
}; };
} }
export interface PhasePendingReviewEvent extends DomainEvent {
type: 'phase:pending_review';
payload: {
phaseId: string;
initiativeId: string;
};
}
export interface PhaseMergedEvent extends DomainEvent {
type: 'phase:merged';
payload: {
phaseId: string;
initiativeId: string;
sourceBranch: string;
targetBranch: string;
};
}
export interface TaskMergedEvent extends DomainEvent {
type: 'task:merged';
payload: {
taskId: string;
phaseId: string;
sourceBranch: string;
targetBranch: string;
};
}
/** /**
* Merge Coordination Events * Merge Coordination Events
*/ */
@@ -395,6 +423,29 @@ export interface PageDeletedEvent extends DomainEvent {
}; };
} }
/**
* Change Set Events
*/
export interface ChangeSetCreatedEvent extends DomainEvent {
type: 'changeset:created';
payload: {
changeSetId: string;
initiativeId: string;
agentId: string;
mode: string;
entryCount: number;
};
}
export interface ChangeSetRevertedEvent extends DomainEvent {
type: 'changeset:reverted';
payload: {
changeSetId: string;
initiativeId: string;
};
}
/** /**
* Account Credential Events * Account Credential Events
*/ */
@@ -458,6 +509,9 @@ export type DomainEventMap =
| PhaseStartedEvent | PhaseStartedEvent
| PhaseCompletedEvent | PhaseCompletedEvent
| PhaseBlockedEvent | PhaseBlockedEvent
| PhasePendingReviewEvent
| PhaseMergedEvent
| TaskMergedEvent
| MergeQueuedEvent | MergeQueuedEvent
| MergeStartedEvent | MergeStartedEvent
| MergeCompletedEvent | MergeCompletedEvent
@@ -465,6 +519,8 @@ export type DomainEventMap =
| PageCreatedEvent | PageCreatedEvent
| PageUpdatedEvent | PageUpdatedEvent
| PageDeletedEvent | PageDeletedEvent
| ChangeSetCreatedEvent
| ChangeSetRevertedEvent
| AccountCredentialsRefreshedEvent | AccountCredentialsRefreshedEvent
| AccountCredentialsExpiredEvent | AccountCredentialsExpiredEvent
| AccountCredentialsValidatedEvent; | AccountCredentialsValidatedEvent;

View File

@@ -0,0 +1,226 @@
/**
* Execution Orchestrator
*
* Subscribes to task:completed events and orchestrates the post-completion
* merge workflow:
* - Task branch → Phase branch (on task completion)
* - Phase branch → Initiative branch (when all phase tasks done)
*
* Supports two execution modes:
* - YOLO: auto-merge everything
* - Review per-phase: pause after each phase for diff review
*/
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseMergedEvent, TaskMergedEvent } from '../events/index.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { TaskRepository } from '../db/repositories/task-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('execution-orchestrator');
export class ExecutionOrchestrator {
/** Serialize merges per phase to avoid concurrent merge conflicts */
private phaseMergeLocks: Map<string, Promise<void>> = new Map();
constructor(
private branchManager: BranchManager,
private phaseRepository: PhaseRepository,
private taskRepository: TaskRepository,
private initiativeRepository: InitiativeRepository,
private projectRepository: ProjectRepository,
private phaseDispatchManager: PhaseDispatchManager,
private conflictResolutionService: ConflictResolutionService,
private eventBus: EventBus,
private workspaceRoot: string,
) {}
/**
* Start listening for task completion events.
*/
start(): void {
this.eventBus.on<TaskCompletedEvent>('task:completed', (event) => {
this.handleTaskCompleted(event).catch((err) => {
log.error({ err: err instanceof Error ? err.message : String(err) }, 'error handling task:completed');
});
});
log.info('execution orchestrator started');
}
/**
* Handle a task:completed event.
* Merges the task branch into the phase branch, then checks if all phase tasks are done.
*/
private async handleTaskCompleted(event: TaskCompletedEvent): Promise<void> {
const { taskId } = event.payload;
const task = await this.taskRepository.findById(taskId);
if (!task?.phaseId || !task.initiativeId) return;
const initiative = await this.initiativeRepository.findById(task.initiativeId);
if (!initiative?.mergeTarget) return;
const phase = await this.phaseRepository.findById(task.phaseId);
if (!phase) return;
// Skip merge tasks — they already work on the phase branch directly
if (task.category === 'merge') return;
const initBranch = initiativeBranchName(initiative.mergeTarget);
const phBranch = phaseBranchName(initBranch, phase.name);
const tBranch = taskBranchName(initBranch, task.id);
// Serialize merges per phase
const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
const mergeOp = lock.then(async () => {
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
});
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
await mergeOp;
// Check if all phase tasks are done
const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId);
const allDone = phaseTasks.every((t) => t.status === 'completed');
if (allDone) {
await this.handlePhaseAllTasksDone(task.phaseId);
}
}
/**
* Merge a task branch into the phase branch for all linked projects.
*/
private async mergeTaskIntoPhase(
taskId: string,
phaseId: string,
taskBranch: string,
phaseBranch: string,
): Promise<void> {
const phase = await this.phaseRepository.findById(phaseId);
if (!phase) return;
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
// Only merge if the task branch actually exists (agents may not have pushed)
const exists = await this.branchManager.branchExists(clonePath, taskBranch);
if (!exists) {
log.debug({ taskId, taskBranch, project: project.name }, 'task branch does not exist, skipping merge');
continue;
}
const result = await this.branchManager.mergeBranch(clonePath, taskBranch, phaseBranch);
if (!result.success && result.conflicts) {
log.warn({ taskId, taskBranch, phaseBranch, conflicts: result.conflicts }, 'task merge conflict');
await this.conflictResolutionService.handleConflict(taskId, result.conflicts, {
sourceBranch: taskBranch,
targetBranch: phaseBranch,
});
return;
}
log.info({ taskId, taskBranch, phaseBranch, project: project.name }, 'task branch merged into phase branch');
}
// Emit task:merged event
const mergedEvent: TaskMergedEvent = {
type: 'task:merged',
timestamp: new Date(),
payload: { taskId, phaseId, sourceBranch: taskBranch, targetBranch: phaseBranch },
};
this.eventBus.emit(mergedEvent);
}
/**
* Handle all tasks in a phase being done.
* YOLO mode: auto-merge phase → initiative.
* Review mode: set phase to pending_review.
*/
private async handlePhaseAllTasksDone(phaseId: string): Promise<void> {
const phase = await this.phaseRepository.findById(phaseId);
if (!phase) return;
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative?.mergeTarget) return;
if (initiative.executionMode === 'yolo') {
await this.mergePhaseIntoInitiative(phaseId);
await this.phaseDispatchManager.completePhase(phaseId);
await this.phaseDispatchManager.dispatchNextPhase();
} else {
// review_per_phase
await this.phaseRepository.update(phaseId, { status: 'pending_review' as any });
const event: PhasePendingReviewEvent = {
type: 'phase:pending_review',
timestamp: new Date(),
payload: { phaseId, initiativeId: phase.initiativeId },
};
this.eventBus.emit(event);
log.info({ phaseId, initiativeId: phase.initiativeId }, 'phase pending review');
}
}
/**
* Merge phase branch into initiative branch for all linked projects.
*/
private async mergePhaseIntoInitiative(phaseId: string): Promise<void> {
const phase = await this.phaseRepository.findById(phaseId);
if (!phase) return;
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative?.mergeTarget) return;
const initBranch = initiativeBranchName(initiative.mergeTarget);
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch);
if (!result.success) {
log.error({ phaseId, phBranch, initBranch, conflicts: result.conflicts }, 'phase merge conflict');
throw new Error(`Phase merge conflict: ${result.message}`);
}
log.info({ phaseId, phBranch, initBranch, project: project.name }, 'phase branch merged into initiative branch');
}
// Emit phase:merged event
const mergedEvent: PhaseMergedEvent = {
type: 'phase:merged',
timestamp: new Date(),
payload: { phaseId, initiativeId: phase.initiativeId, sourceBranch: phBranch, targetBranch: initBranch },
};
this.eventBus.emit(mergedEvent);
}
/**
* Approve and merge a phase that's pending review.
* Called from tRPC when user clicks approve in ReviewTab.
*/
async approveAndMergePhase(phaseId: string): Promise<void> {
const phase = await this.phaseRepository.findById(phaseId);
if (!phase) throw new Error(`Phase not found: ${phaseId}`);
if (phase.status !== 'pending_review') {
throw new Error(`Phase ${phaseId} is not pending review (status: ${phase.status})`);
}
await this.mergePhaseIntoInitiative(phaseId);
await this.phaseDispatchManager.completePhase(phaseId);
await this.phaseDispatchManager.dispatchNextPhase();
log.info({ phaseId }, 'phase review approved and merged');
}
}

41
src/git/branch-manager.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* BranchManager Port Interface
*
* Manages branch-level git operations (create, merge, diff, delete)
* across project clones. Works directly on branches without requiring
* a worktree to be checked out.
*/
import type { MergeResult } from './types.js';
export interface BranchManager {
/**
* Ensure a branch exists. Creates it from baseBranch if it doesn't.
* Idempotent — no-op if the branch already exists.
*/
ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void>;
/**
* Merge sourceBranch into targetBranch.
* Uses an ephemeral worktree for merge safety.
* Returns conflict info if merge fails.
*/
mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult>;
/**
* Get the raw unified diff between two branches.
* Uses three-dot diff (baseBranch...headBranch) to show changes
* introduced by headBranch since it diverged from baseBranch.
*/
diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string>;
/**
* Delete a branch. No-op if the branch doesn't exist.
*/
deleteBranch(repoPath: string, branch: string): Promise<void>;
/**
* Check if a branch exists in the repository.
*/
branchExists(repoPath: string, branch: string): Promise<boolean>;
}

42
src/git/branch-naming.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Branch Naming Utility
*
* Pure functions for computing deterministic branch names
* in the initiative → phase → task branch hierarchy.
*/
/**
* Convert a name to a URL/branch-safe slug.
* Lowercase, replace non-alphanumeric runs with single hyphens, trim hyphens.
*/
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Compute the initiative branch name.
* Returns the mergeTarget as-is (it's already a branch name), or 'main' if unset.
*/
export function initiativeBranchName(mergeTarget: string | null): string {
return mergeTarget ?? 'main';
}
/**
* Compute a phase branch name.
* Format: `<initiativeBranch>-phase-<slugified-phase-name>`
*/
export function phaseBranchName(initiativeBranch: string, phaseName: string): string {
return `${initiativeBranch}-phase-${slugify(phaseName)}`;
}
/**
* Compute a task branch name.
* Format: `<initiativeBranch>-task-<taskId>`
* Uses the raw task ID (already unique) to avoid collisions.
*/
export function taskBranchName(initiativeBranch: string, taskId: string): string {
return `${initiativeBranch}-task-${taskId}`;
}

View File

@@ -0,0 +1,107 @@
/**
* SimpleGit BranchManager Adapter
*
* Implementation of BranchManager port using simple-git.
* Performs branch-level operations (create, merge, diff, delete)
* on project clones without requiring a worktree.
*/
import { join } from 'node:path';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { simpleGit } from 'simple-git';
import type { BranchManager } from './branch-manager.js';
import type { MergeResult } from './types.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('branch-manager');
export class SimpleGitBranchManager implements BranchManager {
async ensureBranch(repoPath: string, branch: string, baseBranch: string): Promise<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);
if (exists) {
log.debug({ repoPath, branch }, 'branch already exists');
return;
}
await git.branch([branch, baseBranch]);
log.info({ repoPath, branch, baseBranch }, 'branch created');
}
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
// Use an ephemeral worktree for merge safety
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
const repoGit = simpleGit(repoPath);
try {
// Create ephemeral worktree on target branch
await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]);
const wtGit = simpleGit(tmpPath);
try {
await wtGit.merge([sourceBranch, '--no-edit']);
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
} catch (mergeErr) {
// Check for merge conflicts
const status = await wtGit.status();
const conflicts = status.conflicted;
if (conflicts.length > 0) {
log.warn({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge conflicts detected');
await wtGit.merge(['--abort']);
return {
success: false,
conflicts,
message: `Merge conflicts in ${conflicts.length} file(s)`,
};
}
// Non-conflict merge failure
throw mergeErr;
}
} finally {
// Clean up ephemeral worktree
try {
await repoGit.raw(['worktree', 'remove', tmpPath, '--force']);
} catch {
// Best-effort cleanup — force-remove the directory if worktree remove fails
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
}
}
}
async diffBranches(repoPath: string, baseBranch: string, headBranch: string): Promise<string> {
const git = simpleGit(repoPath);
const diff = await git.diff([`${baseBranch}...${headBranch}`]);
return diff;
}
async deleteBranch(repoPath: string, branch: string): Promise<void> {
const git = simpleGit(repoPath);
const exists = await this.branchExists(repoPath, branch);
if (!exists) return;
try {
await git.deleteLocalBranch(branch, true);
log.info({ repoPath, branch }, 'branch deleted');
} catch (err) {
log.warn({ repoPath, branch, err: err instanceof Error ? err.message : String(err) }, 'failed to delete branch');
}
}
async branchExists(repoPath: string, branch: string): Promise<boolean> {
const git = simpleGit(repoPath);
try {
const branches = await git.branchLocal();
return branches.all.includes(branch);
} catch {
return false;
}
}
}

View File

@@ -17,11 +17,13 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { AccountRepository } from '../db/repositories/account-repository.js'; import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { ProposalRepository } from '../db/repositories/proposal-repository.js'; import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
/** /**
* Options for creating the tRPC request handler. * Options for creating the tRPC request handler.
@@ -55,12 +57,16 @@ export interface TrpcAdapterOptions {
projectRepository?: ProjectRepository; projectRepository?: ProjectRepository;
/** Account repository for account CRUD and load balancing */ /** Account repository for account CRUD and load balancing */
accountRepository?: AccountRepository; accountRepository?: AccountRepository;
/** Proposal repository for agent proposal CRUD operations */ /** Change set repository for agent change set operations */
proposalRepository?: ProposalRepository; changeSetRepository?: ChangeSetRepository;
/** Log chunk repository for agent output persistence */ /** Log chunk repository for agent output persistence */
logChunkRepository?: LogChunkRepository; logChunkRepository?: LogChunkRepository;
/** Credential manager for account OAuth token management */ /** Credential manager for account OAuth token management */
credentialManager?: AccountCredentialManager; credentialManager?: AccountCredentialManager;
/** Branch manager for git branch operations */
branchManager?: BranchManager;
/** Execution orchestrator for phase merge/review workflow */
executionOrchestrator?: ExecutionOrchestrator;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -135,9 +141,11 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
pageRepository: options.pageRepository, pageRepository: options.pageRepository,
projectRepository: options.projectRepository, projectRepository: options.projectRepository,
accountRepository: options.accountRepository, accountRepository: options.accountRepository,
proposalRepository: options.proposalRepository, changeSetRepository: options.changeSetRepository,
logChunkRepository: options.logChunkRepository, logChunkRepository: options.logChunkRepository,
credentialManager: options.credentialManager, credentialManager: options.credentialManager,
branchManager: options.branchManager,
executionOrchestrator: options.executionOrchestrator,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
}), }),
}); });

View File

@@ -12,7 +12,6 @@ import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { OutputHandler } from '../../agent/output-handler.js'; import { OutputHandler } from '../../agent/output-handler.js';
import type { AgentRepository } from '../../db/repositories/agent-repository.js'; import type { AgentRepository } from '../../db/repositories/agent-repository.js';
import type { ProposalRepository } from '../../db/repositories/proposal-repository.js';
interface TestAgent { interface TestAgent {
id: string; id: string;
@@ -40,7 +39,6 @@ describe('Crash marking race condition', () => {
let testAgent: TestAgent; let testAgent: TestAgent;
let testDir: string; let testDir: string;
let mockRepo: AgentRepository; let mockRepo: AgentRepository;
let mockProposalRepo: ProposalRepository;
// Track all repository calls // Track all repository calls
let updateCalls: Array<{ id: string; data: any }> = []; let updateCalls: Array<{ id: string; data: any }> = [];
@@ -99,9 +97,7 @@ describe('Crash marking race condition', () => {
async delete() { throw new Error('Not implemented'); } async delete() { throw new Error('Not implemented'); }
}; };
mockProposalRepo = {} as any; // Not used in this test outputHandler = new OutputHandler(mockRepo);
outputHandler = new OutputHandler(mockRepo, undefined, mockProposalRepo);
}); });
afterEach(async () => { afterEach(async () => {

View File

@@ -14,11 +14,13 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { PageRepository } from '../db/repositories/page-repository.js'; import type { PageRepository } from '../db/repositories/page-repository.js';
import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { AccountRepository } from '../db/repositories/account-repository.js'; import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { ProposalRepository } from '../db/repositories/proposal-repository.js'; import type { ChangeSetRepository } from '../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js'; import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js'; import type { CoordinationManager } from '../coordination/types.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
// Re-export for convenience // Re-export for convenience
export type { EventBus, DomainEvent }; export type { EventBus, DomainEvent };
@@ -55,12 +57,16 @@ export interface TRPCContext {
projectRepository?: ProjectRepository; projectRepository?: ProjectRepository;
/** Account repository for account CRUD and load balancing */ /** Account repository for account CRUD and load balancing */
accountRepository?: AccountRepository; accountRepository?: AccountRepository;
/** Proposal repository for agent proposal CRUD operations */ /** Change set repository for agent change set operations */
proposalRepository?: ProposalRepository; changeSetRepository?: ChangeSetRepository;
/** Log chunk repository for agent output persistence */ /** Log chunk repository for agent output persistence */
logChunkRepository?: LogChunkRepository; logChunkRepository?: LogChunkRepository;
/** Credential manager for account OAuth token management */ /** Credential manager for account OAuth token management */
credentialManager?: AccountCredentialManager; credentialManager?: AccountCredentialManager;
/** Branch manager for git branch operations */
branchManager?: BranchManager;
/** Execution orchestrator for phase merge/review workflow */
executionOrchestrator?: ExecutionOrchestrator;
/** Absolute path to the workspace root (.cwrc directory) */ /** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -83,9 +89,11 @@ export interface CreateContextOptions {
pageRepository?: PageRepository; pageRepository?: PageRepository;
projectRepository?: ProjectRepository; projectRepository?: ProjectRepository;
accountRepository?: AccountRepository; accountRepository?: AccountRepository;
proposalRepository?: ProposalRepository; changeSetRepository?: ChangeSetRepository;
logChunkRepository?: LogChunkRepository; logChunkRepository?: LogChunkRepository;
credentialManager?: AccountCredentialManager; credentialManager?: AccountCredentialManager;
branchManager?: BranchManager;
executionOrchestrator?: ExecutionOrchestrator;
workspaceRoot?: string; workspaceRoot?: string;
} }
@@ -111,9 +119,11 @@ export function createContext(options: CreateContextOptions): TRPCContext {
pageRepository: options.pageRepository, pageRepository: options.pageRepository,
projectRepository: options.projectRepository, projectRepository: options.projectRepository,
accountRepository: options.accountRepository, accountRepository: options.accountRepository,
proposalRepository: options.proposalRepository, changeSetRepository: options.changeSetRepository,
logChunkRepository: options.logChunkRepository, logChunkRepository: options.logChunkRepository,
credentialManager: options.credentialManager, credentialManager: options.credentialManager,
branchManager: options.branchManager,
executionOrchestrator: options.executionOrchestrator,
workspaceRoot: options.workspaceRoot, workspaceRoot: options.workspaceRoot,
}; };
} }

View File

@@ -19,7 +19,7 @@ import { architectProcedures } from './routers/architect.js';
import { projectProcedures } from './routers/project.js'; import { projectProcedures } from './routers/project.js';
import { pageProcedures } from './routers/page.js'; import { pageProcedures } from './routers/page.js';
import { accountProcedures } from './routers/account.js'; import { accountProcedures } from './routers/account.js';
import { proposalProcedures } from './routers/proposal.js'; import { changeSetProcedures } from './routers/change-set.js';
import { subscriptionProcedures } from './routers/subscription.js'; import { subscriptionProcedures } from './routers/subscription.js';
// Re-export tRPC primitives (preserves existing import paths) // Re-export tRPC primitives (preserves existing import paths)
@@ -55,7 +55,7 @@ export const appRouter = router({
...projectProcedures(publicProcedure), ...projectProcedures(publicProcedure),
...pageProcedures(publicProcedure), ...pageProcedures(publicProcedure),
...accountProcedures(publicProcedure), ...accountProcedures(publicProcedure),
...proposalProcedures(publicProcedure), ...changeSetProcedures(publicProcedure),
...subscriptionProcedures(publicProcedure), ...subscriptionProcedures(publicProcedure),
}); });

View File

@@ -14,10 +14,12 @@ import type { PhaseRepository } from '../../db/repositories/phase-repository.js'
import type { PageRepository } from '../../db/repositories/page-repository.js'; import type { PageRepository } from '../../db/repositories/page-repository.js';
import type { ProjectRepository } from '../../db/repositories/project-repository.js'; import type { ProjectRepository } from '../../db/repositories/project-repository.js';
import type { AccountRepository } from '../../db/repositories/account-repository.js'; import type { AccountRepository } from '../../db/repositories/account-repository.js';
import type { ProposalRepository } from '../../db/repositories/proposal-repository.js'; import type { ChangeSetRepository } from '../../db/repositories/change-set-repository.js';
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js'; import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js'; import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js'; import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
export function requireAgentManager(ctx: TRPCContext) { export function requireAgentManager(ctx: TRPCContext) {
if (!ctx.agentManager) { if (!ctx.agentManager) {
@@ -129,14 +131,14 @@ export function requireAccountRepository(ctx: TRPCContext): AccountRepository {
return ctx.accountRepository; return ctx.accountRepository;
} }
export function requireProposalRepository(ctx: TRPCContext): ProposalRepository { export function requireChangeSetRepository(ctx: TRPCContext): ChangeSetRepository {
if (!ctx.proposalRepository) { if (!ctx.changeSetRepository) {
throw new TRPCError({ throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
message: 'Proposal repository not available', message: 'Change set repository not available',
}); });
} }
return ctx.proposalRepository; return ctx.changeSetRepository;
} }
export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository { export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository {
@@ -148,3 +150,23 @@ export function requireLogChunkRepository(ctx: TRPCContext): LogChunkRepository
} }
return ctx.logChunkRepository; return ctx.logChunkRepository;
} }
export function requireBranchManager(ctx: TRPCContext): BranchManager {
if (!ctx.branchManager) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Branch manager not available',
});
}
return ctx.branchManager;
}
export function requireExecutionOrchestrator(ctx: TRPCContext): ExecutionOrchestrator {
if (!ctx.executionOrchestrator) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Execution orchestrator not available',
});
}
return ctx.executionOrchestrator;
}

View File

@@ -0,0 +1,146 @@
/**
* Change Set Router — list, get, revert workflows
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
requireChangeSetRepository,
requirePhaseRepository,
requireTaskRepository,
requirePageRepository,
} from './_helpers.js';
export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
return {
listChangeSets: publicProcedure
.input(z.object({
initiativeId: z.string().min(1).optional(),
agentId: z.string().min(1).optional(),
}))
.query(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
if (input.agentId) {
return repo.findByAgentId(input.agentId);
}
if (input.initiativeId) {
return repo.findByInitiativeId(input.initiativeId);
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either agentId or initiativeId is required',
});
}),
getChangeSet: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
const cs = await repo.findByIdWithEntries(input.id);
if (!cs) {
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
}
return cs;
}),
revertChangeSet: publicProcedure
.input(z.object({ id: z.string().min(1), force: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const repo = requireChangeSetRepository(ctx);
const cs = await repo.findByIdWithEntries(input.id);
if (!cs) {
throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
}
if (cs.status === 'reverted') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'ChangeSet is already reverted' });
}
const phaseRepo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const pageRepo = requirePageRepository(ctx);
// Conflict detection (unless force)
if (!input.force) {
const conflicts: string[] = [];
for (const entry of cs.entries) {
if (entry.action === 'create') {
if (entry.entityType === 'phase') {
const phase = await phaseRepo.findById(entry.entityId);
if (phase && phase.status === 'in_progress') {
conflicts.push(`Phase "${phase.name}" is in progress`);
}
} else if (entry.entityType === 'task') {
const task = await taskRepo.findById(entry.entityId);
if (task && task.status === 'in_progress') {
conflicts.push(`Task "${task.name}" is in progress`);
}
}
} else if (entry.action === 'update' && entry.entityType === 'page' && entry.newState) {
const page = await pageRepo.findById(entry.entityId);
if (page) {
const expectedContent = JSON.parse(entry.newState).content;
if (page.content !== expectedContent) {
conflicts.push(`Page "${page.title}" was modified since change set was applied`);
}
}
}
}
if (conflicts.length > 0) {
return { success: false as const, conflicts };
}
}
// Apply reverts in reverse entry order
const reversedEntries = [...cs.entries].reverse();
for (const entry of reversedEntries) {
try {
if (entry.action === 'create') {
switch (entry.entityType) {
case 'phase':
try { await phaseRepo.delete(entry.entityId); } catch { /* already deleted */ }
break;
case 'task':
try { await taskRepo.delete(entry.entityId); } catch { /* already deleted */ }
break;
case 'phase_dependency': {
const depData = JSON.parse(entry.newState || '{}');
if (depData.phaseId && depData.dependsOnPhaseId) {
try { await phaseRepo.removeDependency(depData.phaseId, depData.dependsOnPhaseId); } catch { /* already removed */ }
}
break;
}
}
} else if (entry.action === 'update' && entry.previousState) {
const prev = JSON.parse(entry.previousState);
switch (entry.entityType) {
case 'page':
await pageRepo.update(entry.entityId, {
content: prev.content,
title: prev.title,
});
ctx.eventBus.emit({
type: 'page:updated',
timestamp: new Date(),
payload: { pageId: entry.entityId, initiativeId: cs.initiativeId, title: prev.title },
});
break;
}
}
} catch (err) {
// Log but continue reverting other entries
}
}
await repo.markReverted(input.id);
ctx.eventBus.emit({
type: 'changeset:reverted' as const,
timestamp: new Date(),
payload: { changeSetId: cs.id, initiativeId: cs.initiativeId },
});
return { success: true as const };
}),
};
}

View File

@@ -13,6 +13,8 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({ .input(z.object({
name: z.string().min(1), name: z.string().min(1),
projectIds: z.array(z.string().min(1)).min(1).optional(), projectIds: z.array(z.string().min(1)).min(1).optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
mergeTarget: z.string().nullable().optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx); const repo = requireInitiativeRepository(ctx);
@@ -33,6 +35,8 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
const initiative = await repo.create({ const initiative = await repo.create({
name: input.name, name: input.name,
status: 'active', status: 'active',
...(input.executionMode && { executionMode: input.executionMode }),
...(input.mergeTarget !== undefined && { mergeTarget: input.mergeTarget }),
}); });
if (input.projectIds && input.projectIds.length > 0) { if (input.projectIds && input.projectIds.length > 0) {
@@ -103,6 +107,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: z.string().min(1), initiativeId: z.string().min(1),
mergeRequiresApproval: z.boolean().optional(), mergeRequiresApproval: z.boolean().optional(),
mergeTarget: z.string().nullable().optional(), mergeTarget: z.string().nullable().optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx); const repo = requireInitiativeRepository(ctx);

View File

@@ -6,7 +6,9 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { Phase } from '../../db/schema.js'; import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js'; import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository } from './_helpers.js'; import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
import { initiativeBranchName, phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js';
export function phaseProcedures(publicProcedure: ProcedureBuilder) { export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return { return {
@@ -50,7 +52,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
content: z.string().nullable().optional(), content: z.string().nullable().optional(),
status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked']).optional(), status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review']).optional(),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx); const repo = requirePhaseRepository(ctx);
@@ -181,5 +183,56 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
await repo.removeDependency(input.phaseId, input.dependsOnPhaseId); await repo.removeDependency(input.phaseId, input.dependsOnPhaseId);
return { success: true }; return { success: true };
}), }),
getPhaseReviewDiff: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
if (phase.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.mergeTarget) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no merge target' });
}
const initBranch = initiativeBranchName(initiative.mergeTarget);
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = '';
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch);
if (diff) {
rawDiff += diff + '\n';
}
}
return {
phaseName: phase.name,
sourceBranch: phBranch,
targetBranch: initBranch,
rawDiff,
};
}),
approvePhaseReview: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx);
await orchestrator.approveAndMergePhase(input.phaseId);
return { success: true };
}),
}; };
} }

View File

@@ -1,189 +0,0 @@
/**
* Proposal Router — CRUD + accept/dismiss workflows
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import type { TRPCContext } from '../context.js';
import type { Proposal } from '../../db/schema.js';
import {
requireProposalRepository,
requirePageRepository,
requirePhaseRepository,
requireTaskRepository,
requireAgentManager,
} from './_helpers.js';
import { markdownToTiptapJson } from '../../agent/markdown-to-tiptap.js';
/**
* Accept a single proposal: apply side effects based on targetType.
*/
async function applyProposal(proposal: Proposal, ctx: TRPCContext): Promise<string | void> {
switch (proposal.targetType) {
case 'page': {
if (!proposal.targetId || !proposal.content) break;
const pageRepo = requirePageRepository(ctx);
const tiptapJson = markdownToTiptapJson(proposal.content);
await pageRepo.update(proposal.targetId, {
content: JSON.stringify(tiptapJson),
title: proposal.title,
});
ctx.eventBus.emit({
type: 'page:updated',
timestamp: new Date(),
payload: { pageId: proposal.targetId, initiativeId: proposal.initiativeId, title: proposal.title },
});
break;
}
case 'phase': {
const phaseRepo = requirePhaseRepository(ctx);
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
const tiptapContent = proposal.content ? JSON.stringify(markdownToTiptapJson(proposal.content)) : undefined;
const created = await phaseRepo.create({
id: meta.fileId ?? undefined,
initiativeId: proposal.initiativeId,
name: proposal.title,
content: tiptapContent,
});
return created.id;
}
case 'task': {
const taskRepo = requireTaskRepository(ctx);
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
await taskRepo.create({
initiativeId: proposal.initiativeId,
phaseId: meta.phaseId ?? null,
parentTaskId: meta.parentTaskId ?? null,
name: proposal.title,
description: proposal.content ?? undefined,
category: meta.category ?? 'execute',
type: meta.type ?? 'auto',
});
break;
}
}
}
/**
* After every accept/dismiss, check if all proposals for the agent are resolved.
* If so, auto-dismiss the agent.
*/
async function maybeAutoDismiss(agentId: string, ctx: TRPCContext): Promise<void> {
const proposalRepo = requireProposalRepository(ctx);
const pendingCount = await proposalRepo.countByAgentIdAndStatus(agentId, 'pending');
if (pendingCount === 0) {
try {
const agentManager = requireAgentManager(ctx);
await agentManager.dismiss(agentId);
} catch {
// Agent manager not available or agent already dismissed — not critical
}
}
}
export function proposalProcedures(publicProcedure: ProcedureBuilder) {
return {
listProposals: publicProcedure
.input(z.object({
agentId: z.string().min(1).optional(),
initiativeId: z.string().min(1).optional(),
}))
.query(async ({ ctx, input }) => {
const repo = requireProposalRepository(ctx);
if (input.agentId) {
return repo.findByAgentId(input.agentId);
}
if (input.initiativeId) {
return repo.findByInitiativeId(input.initiativeId);
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Either agentId or initiativeId is required',
});
}),
acceptProposal: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireProposalRepository(ctx);
const proposal = await repo.findById(input.id);
if (!proposal) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Proposal '${input.id}' not found` });
}
if (proposal.status !== 'pending') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Proposal is already ${proposal.status}` });
}
await applyProposal(proposal, ctx);
const updated = await repo.update(input.id, { status: 'accepted' });
await maybeAutoDismiss(proposal.agentId, ctx);
return updated;
}),
dismissProposal: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireProposalRepository(ctx);
const proposal = await repo.findById(input.id);
if (!proposal) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Proposal '${input.id}' not found` });
}
if (proposal.status !== 'pending') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Proposal is already ${proposal.status}` });
}
const updated = await repo.update(input.id, { status: 'dismissed' });
await maybeAutoDismiss(proposal.agentId, ctx);
return updated;
}),
acceptAllProposals: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireProposalRepository(ctx);
const pending = await repo.findByAgentIdAndStatus(input.agentId, 'pending');
let successCount = 0;
let failedCount = 0;
const errorMessages: string[] = [];
for (const proposal of pending) {
try {
await applyProposal(proposal, ctx);
await repo.update(proposal.id, { status: 'accepted' });
successCount++;
} catch (err) {
failedCount++;
const message = err instanceof Error ? err.message : String(err);
errorMessages.push(`${proposal.title}: ${message}`);
}
}
// Second pass: create phase dependencies
const phaseProposals = pending.filter(p => p.targetType === 'phase');
if (phaseProposals.length > 0) {
const phaseRepo = requirePhaseRepository(ctx);
for (const proposal of phaseProposals) {
const meta = proposal.metadata ? JSON.parse(proposal.metadata) : {};
const phaseId = meta.fileId;
if (!phaseId || !Array.isArray(meta.dependencies)) continue;
for (const depFileId of meta.dependencies) {
try {
await phaseRepo.createDependency(phaseId, depFileId);
} catch (err) {
errorMessages.push(`Dep ${proposal.title}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
}
await maybeAutoDismiss(input.agentId, ctx);
return { accepted: successCount, failed: failedCount, errors: errorMessages };
}),
dismissAllProposals: publicProcedure
.input(z.object({ agentId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireProposalRepository(ctx);
await repo.updateManyByAgentIdAndStatus(input.agentId, 'pending', { status: 'dismissed' });
await maybeAutoDismiss(input.agentId, ctx);
return { success: true };
}),
};
}

View File

@@ -49,6 +49,9 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
'phase:started', 'phase:started',
'phase:completed', 'phase:completed',
'phase:blocked', 'phase:blocked',
'phase:pending_review',
'phase:merged',
'task:merged',
'merge:queued', 'merge:queued',
'merge:started', 'merge:started',
'merge:completed', 'merge:completed',
@@ -56,6 +59,8 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
'page:created', 'page:created',
'page:updated', 'page:updated',
'page:deleted', 'page:deleted',
'changeset:created',
'changeset:reverted',
]; ];
/** /**
@@ -80,10 +85,13 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
'task:dispatched', 'task:dispatched',
'task:completed', 'task:completed',
'task:blocked', 'task:blocked',
'task:merged',
'phase:queued', 'phase:queued',
'phase:started', 'phase:started',
'phase:completed', 'phase:completed',
'phase:blocked', 'phase:blocked',
'phase:pending_review',
'phase:merged',
]; ];
/** /**