summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
-rw-r--r--Cargo.lock186
-rw-r--r--Cargo.toml2
-rw-r--r--makima/Cargo.toml23
-rw-r--r--makima/daemon/Cargo.toml48
-rw-r--r--makima/daemon/README.md353
-rw-r--r--makima/daemon/src/lib.rs21
-rw-r--r--makima/daemon/src/main.rs313
-rw-r--r--makima/docs/PLAN-task-branching.md197
-rw-r--r--makima/frontend/src/components/JapaneseHoverText.tsx77
-rw-r--r--makima/frontend/src/components/Masthead.tsx6
-rw-r--r--makima/frontend/src/components/NavStrip.tsx2
-rw-r--r--makima/frontend/src/components/contracts/ContractCliInput.tsx974
-rw-r--r--makima/frontend/src/components/contracts/ContractDetail.tsx794
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx176
-rw-r--r--makima/frontend/src/components/contracts/PhaseBadge.tsx54
-rw-r--r--makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx301
-rw-r--r--makima/frontend/src/components/contracts/PhaseHint.tsx90
-rw-r--r--makima/frontend/src/components/contracts/PhaseProgressBar.tsx142
-rw-r--r--makima/frontend/src/components/contracts/QuickActionButtons.tsx217
-rw-r--r--makima/frontend/src/components/contracts/RepositoryPanel.tsx260
-rw-r--r--makima/frontend/src/components/contracts/TaskDerivationPreview.tsx221
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx376
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx2
-rw-r--r--makima/frontend/src/components/files/FileList.tsx179
-rw-r--r--makima/frontend/src/components/files/RepoSyncIndicator.tsx190
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx33
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx12
-rw-r--r--makima/frontend/src/components/mesh/TaskList.tsx215
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx96
-rw-r--r--makima/frontend/src/components/mesh/TaskTree.tsx390
-rw-r--r--makima/frontend/src/components/workflow/PhaseColumn.tsx123
-rw-r--r--makima/frontend/src/components/workflow/WorkflowBoard.tsx54
-rw-r--r--makima/frontend/src/components/workflow/WorkflowContractCard.tsx53
-rw-r--r--makima/frontend/src/hooks/useContracts.ts308
-rw-r--r--makima/frontend/src/hooks/useWebSocket.ts3
-rw-r--r--makima/frontend/src/lib/api.ts542
-rw-r--r--makima/frontend/src/lib/markdown.ts228
-rw-r--r--makima/frontend/src/main.tsx26
-rw-r--r--makima/frontend/src/routes/_index.tsx6
-rw-r--r--makima/frontend/src/routes/contracts.tsx614
-rw-r--r--makima/frontend/src/routes/files.tsx221
-rw-r--r--makima/frontend/src/routes/listen.tsx45
-rw-r--r--makima/frontend/src/routes/mesh.tsx250
-rw-r--r--makima/frontend/src/routes/settings.tsx113
-rw-r--r--makima/frontend/src/routes/workflow.tsx205
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20250110100000_create_contracts.sql71
-rw-r--r--makima/migrations/20250112000000_cascade_delete_contract_children.sql15
-rw-r--r--makima/migrations/20250112100000_create_contract_chat_history.sql33
-rw-r--r--makima/migrations/20250113000000_add_repo_file_path.sql15
-rw-r--r--makima/migrations/20250114000000_task_tree_structure.sql41
-rw-r--r--makima/migrations/20250114000001_task_checkpoints.sql24
-rw-r--r--makima/migrations/20250114000002_daemon_capabilities.sql27
-rw-r--r--makima/migrations/20250114000003_supervisor_state.sql31
-rw-r--r--makima/src/bin/makima.rs564
-rw-r--r--makima/src/bin/server.rs72
-rw-r--r--makima/src/daemon/api/client.rs129
-rw-r--r--makima/src/daemon/api/contract.rs161
-rw-r--r--makima/src/daemon/api/mod.rs7
-rw-r--r--makima/src/daemon/api/supervisor.rs186
-rw-r--r--makima/src/daemon/cli/contract.rs87
-rw-r--r--makima/src/daemon/cli/daemon.rs (renamed from makima/daemon/src/cli.rs)19
-rw-r--r--makima/src/daemon/cli/mod.rs120
-rw-r--r--makima/src/daemon/cli/server.rs43
-rw-r--r--makima/src/daemon/cli/supervisor.rs146
-rw-r--r--makima/src/daemon/config.rs (renamed from makima/daemon/src/config.rs)21
-rw-r--r--makima/src/daemon/db/local.rs (renamed from makima/daemon/src/db/local.rs)4
-rw-r--r--makima/src/daemon/db/mod.rs (renamed from makima/daemon/src/db/mod.rs)0
-rw-r--r--makima/src/daemon/error.rs (renamed from makima/daemon/src/error.rs)4
-rw-r--r--makima/src/daemon/mod.rs22
-rw-r--r--makima/src/daemon/process/claude.rs (renamed from makima/daemon/src/process/claude.rs)28
-rw-r--r--makima/src/daemon/process/claude_protocol.rs (renamed from makima/daemon/src/process/claude_protocol.rs)2
-rw-r--r--makima/src/daemon/process/mod.rs (renamed from makima/daemon/src/process/mod.rs)0
-rw-r--r--makima/src/daemon/task/manager.rs (renamed from makima/daemon/src/task/manager.rs)1525
-rw-r--r--makima/src/daemon/task/mod.rs (renamed from makima/daemon/src/task/mod.rs)0
-rw-r--r--makima/src/daemon/task/state.rs (renamed from makima/daemon/src/task/state.rs)2
-rw-r--r--makima/src/daemon/temp.rs (renamed from makima/daemon/src/temp.rs)2
-rw-r--r--makima/src/daemon/worktree/manager.rs (renamed from makima/daemon/src/worktree/manager.rs)2
-rw-r--r--makima/src/daemon/worktree/mod.rs (renamed from makima/daemon/src/worktree/mod.rs)0
-rw-r--r--makima/src/daemon/ws/client.rs (renamed from makima/daemon/src/ws/client.rs)4
-rw-r--r--makima/src/daemon/ws/mod.rs (renamed from makima/daemon/src/ws/mod.rs)0
-rw-r--r--makima/src/daemon/ws/protocol.rs (renamed from makima/daemon/src/ws/protocol.rs)149
-rw-r--r--makima/src/db/models.rs603
-rw-r--r--makima/src/db/repository.rs1400
-rw-r--r--makima/src/lib.rs1
-rw-r--r--makima/src/llm/contract_tools.rs1091
-rw-r--r--makima/src/llm/markdown.rs334
-rw-r--r--makima/src/llm/mesh_tools.rs331
-rw-r--r--makima/src/llm/mod.rs20
-rw-r--r--makima/src/llm/phase_guidance.rs594
-rw-r--r--makima/src/llm/task_output.rs461
-rw-r--r--makima/src/llm/templates.rs1011
-rw-r--r--makima/src/llm/tools.rs170
-rw-r--r--makima/src/server/handlers/chat.rs45
-rw-r--r--makima/src/server/handlers/contract_chat.rs2592
-rw-r--r--makima/src/server/handlers/contract_daemon.rs960
-rw-r--r--makima/src/server/handlers/contracts.rs1284
-rw-r--r--makima/src/server/handlers/files.rs219
-rw-r--r--makima/src/server/handlers/listen.rs108
-rw-r--r--makima/src/server/handlers/mesh.rs105
-rw-r--r--makima/src/server/handlers/mesh_chat.rs124
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs211
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs1153
-rw-r--r--makima/src/server/handlers/mod.rs5
-rw-r--r--makima/src/server/handlers/templates.rs107
-rw-r--r--makima/src/server/messages.rs6
-rw-r--r--makima/src/server/mod.rs118
-rw-r--r--makima/src/server/openapi.rs52
-rw-r--r--makima/src/server/state.rs364
109 files changed, 24240 insertions, 1528 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 63ac8ff..c59f241 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -788,6 +788,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -899,6 +905,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
+[[package]]
name = "filetime"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1579,6 +1596,15 @@ dependencies = [
]
[[package]]
+name = "ioctl-rs"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1830,14 +1856,20 @@ version = "0.1.0"
dependencies = [
"ahash",
"anyhow",
+ "async-trait",
"axum",
+ "backoff",
"base64 0.22.1",
"bytes",
"chrono",
+ "clap",
+ "config",
"dashmap",
+ "dirs 5.0.1",
"futures",
"hex",
"hf-hub",
+ "hostname",
"indexmap",
"jaq-core",
"jaq-interpret",
@@ -1848,16 +1880,21 @@ dependencies = [
"once_cell",
"ort",
"parakeet-rs",
+ "portable-pty",
"rand 0.8.5",
+ "regex",
"reqwest",
+ "rusqlite",
"serde",
"serde_json",
"sha2",
+ "shell-escape",
"sqlx",
"symphonia",
"thiserror 2.0.17",
"tokenizers 0.21.4",
"tokio",
+ "tokio-tungstenite 0.24.0",
"tower-http",
"tracing",
"tracing-subscriber",
@@ -1867,35 +1904,6 @@ dependencies = [
]
[[package]]
-name = "makima-daemon"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "async-trait",
- "backoff",
- "chrono",
- "clap",
- "config",
- "dashmap",
- "dirs 5.0.1",
- "futures",
- "hex",
- "hostname",
- "rand 0.9.2",
- "rusqlite",
- "serde",
- "serde_json",
- "sha2",
- "shell-escape",
- "thiserror 2.0.17",
- "tokio",
- "tokio-tungstenite 0.24.0",
- "tracing",
- "tracing-subscriber",
- "uuid",
-]
-
-[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1937,6 +1945,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
+name = "memoffset"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2051,6 +2068,20 @@ dependencies = [
]
[[package]]
+name = "nix"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset",
+ "pin-utils",
+]
+
+[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2454,6 +2485,27 @@ dependencies = [
]
[[package]]
+name = "portable-pty"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
+dependencies = [
+ "anyhow",
+ "bitflags 1.3.2",
+ "downcast-rs",
+ "filedescriptor",
+ "lazy_static",
+ "libc",
+ "log",
+ "nix",
+ "serial",
+ "shared_library",
+ "shell-words",
+ "winapi",
+ "winreg",
+]
+
+[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3030,6 +3082,48 @@ dependencies = [
]
[[package]]
+name = "serial"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
+dependencies = [
+ "serial-core",
+ "serial-unix",
+ "serial-windows",
+]
+
+[[package]]
+name = "serial-core"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "serial-unix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
+dependencies = [
+ "ioctl-rs",
+ "libc",
+ "serial-core",
+ "termios",
+]
+
+[[package]]
+name = "serial-windows"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
+dependencies = [
+ "libc",
+ "serial-core",
+]
+
+[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3061,12 +3155,28 @@ dependencies = [
]
[[package]]
+name = "shared_library"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
+dependencies = [
+ "lazy_static",
+ "libc",
+]
+
+[[package]]
name = "shell-escape"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3669,6 +3779,15 @@ dependencies = [
]
[[package]]
+name = "termios"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4908,6 +5027,15 @@ dependencies = [
]
[[package]]
+name = "winreg"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index b8248e4..377d0bb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,3 @@
[workspace]
-members = ["makima", "makima/daemon", "tools/stt-client", "vendor/parakeet-rs"]
+members = ["makima", "tools/stt-client", "vendor/parakeet-rs"]
resolver = "2"
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 5d8c44e..a850d4a 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024"
[[bin]]
-name = "makima-server"
-path = "src/bin/server.rs"
+name = "makima"
+path = "src/bin/makima.rs"
[dependencies]
# ML/Audio (existing)
@@ -19,16 +19,28 @@ ndarray = "0.16"
# Web server
axum = { version = "0.8", features = ["ws", "multipart"] }
-tokio = { version = "1.0", features = ["full", "signal"] }
+tokio = { version = "1.0", features = ["full", "signal", "process"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
futures = "0.3"
tracing = "0.1"
-tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
bytes = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
+# CLI/Daemon specific
+clap = { version = "4.4", features = ["derive", "env"] }
+config = "0.14"
+tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
+rusqlite = { version = "0.32", features = ["bundled"] }
+backoff = { version = "0.4", features = ["tokio"] }
+hostname = "0.4"
+shell-escape = "0.1"
+dirs = "5.0"
+portable-pty = "0.8"
+async-trait = "0.1"
+
# OpenAPI
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
@@ -50,6 +62,9 @@ reqwest = { version = "0.12", features = ["json"] }
# Lazy statics
once_cell = "1.19"
+# Regex for text parsing
+regex = "1.10"
+
# Cryptographic hashing for API keys
sha2 = "0.10"
rand = { version = "0.8", features = ["std", "std_rng"] }
diff --git a/makima/daemon/Cargo.toml b/makima/daemon/Cargo.toml
deleted file mode 100644
index 02ecbb3..0000000
--- a/makima/daemon/Cargo.toml
+++ /dev/null
@@ -1,48 +0,0 @@
-[package]
-name = "makima-daemon"
-version = "0.1.0"
-edition = "2024"
-
-[[bin]]
-name = "makima-daemon"
-path = "src/main.rs"
-
-[dependencies]
-# Async runtime
-tokio = { version = "1.0", features = ["full", "signal", "process"] }
-futures = "0.3"
-async-trait = "0.1"
-
-# WebSocket client
-tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
-
-# Serialization
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-
-# Configuration
-config = "0.14"
-clap = { version = "4.4", features = ["derive", "env"] }
-
-# Database (local state)
-rusqlite = { version = "0.32", features = ["bundled"] }
-
-# Error handling
-thiserror = "2.0"
-anyhow = "1.0"
-
-# Logging
-tracing = "0.1"
-tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
-
-# Utilities
-uuid = { version = "1.0", features = ["v4", "serde"] }
-chrono = { version = "0.4", features = ["serde"] }
-dashmap = "6.0"
-backoff = { version = "0.4", features = ["tokio"] }
-hostname = "0.4"
-sha2 = "0.10"
-hex = "0.4"
-shell-escape = "0.1"
-dirs = "5.0"
-rand = "0.9"
diff --git a/makima/daemon/README.md b/makima/daemon/README.md
deleted file mode 100644
index 7c577c5..0000000
--- a/makima/daemon/README.md
+++ /dev/null
@@ -1,353 +0,0 @@
-# Makima Daemon
-
-The Makima daemon connects to the Makima server and executes tasks using Claude Code in isolated git worktrees.
-
-## Installation
-
-```bash
-cd makima/daemon
-cargo build --release
-```
-
-The binary will be at `target/release/makima-daemon`.
-
-## Quick Start
-
-```bash
-# Set required environment variables
-export MAKIMA_API_KEY="your-api-key"
-export MAKIMA_DAEMON_SERVER_URL="ws://localhost:8080"
-
-# Run the daemon
-makima-daemon
-```
-
-## Configuration
-
-Configuration is loaded from multiple sources in order of precedence (highest first):
-
-1. CLI arguments
-2. Environment variables
-3. `./makima-daemon.toml` (current directory)
-4. `~/.config/makima-daemon/config.toml` (user config)
-5. `/etc/makima-daemon/config.toml` (system config, Linux only)
-
-### Environment Variables
-
-| Variable | Description |
-|----------|-------------|
-| `MAKIMA_API_KEY` | API key for authentication (preferred) |
-| `MAKIMA_DAEMON_SERVER_URL` | WebSocket URL of the Makima server |
-| `MAKIMA_DAEMON_SERVER_APIKEY` | Alternative to MAKIMA_API_KEY |
-| `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` | Max concurrent tasks |
-
-### CLI Arguments
-
-```
-makima-daemon [OPTIONS]
-
-Options:
- -c, --config <PATH> Path to config file
- -s, --server-url <URL> WebSocket URL of makima server
- -k, --api-key <KEY> API key for authentication
- -m, --max-tasks <N> Maximum concurrent tasks
- -l, --log-level <LEVEL> Log level (trace, debug, info, warn, error)
- --repos-dir <PATH> Directory for cloned repositories
- --worktrees-dir <PATH> Directory for worktrees
- -h, --help Print help
- -V, --version Print version
-```
-
----
-
-## Configuration File Reference
-
-Below is a complete configuration file with all options and their defaults.
-
-```toml
-# =============================================================================
-# Server Connection
-# =============================================================================
-[server]
-# WebSocket URL of the Makima server (required)
-url = "ws://localhost:8080"
-
-# API key for authentication (required)
-# Can also be set via MAKIMA_API_KEY environment variable
-api_key = "your-api-key"
-
-# Heartbeat interval in seconds (default: 30)
-heartbeat_interval_secs = 30
-
-# Reconnect interval after connection loss in seconds (default: 5)
-reconnect_interval_secs = 5
-
-# Maximum reconnect attempts before giving up (default: 0 = infinite)
-max_reconnect_attempts = 0
-
-# =============================================================================
-# Worktree Settings
-# =============================================================================
-[worktree]
-# Base directory for task worktrees (default: ~/.makima/worktrees)
-base_dir = "/home/user/.makima/worktrees"
-
-# Directory for cached repository clones (default: ~/.makima/repos)
-repos_dir = "/home/user/.makima/repos"
-
-# Branch prefix for task branches (default: "makima/task-")
-branch_prefix = "makima/task-"
-
-# Clean up old worktrees on daemon start (default: false)
-cleanup_on_start = false
-
-# Default target repository for pushing completed branches
-# Used when task.target_repo_path is not set
-default_target_repo = "/home/user/projects/my-repo"
-
-# =============================================================================
-# Process Settings (Claude Code)
-# =============================================================================
-[process]
-# Path or command for Claude Code CLI (default: "claude")
-claude_command = "claude"
-
-# Additional arguments to pass to Claude Code (after default arguments)
-# Default arguments are: --output-format=stream-json --input-format=stream-json
-# --verbose --dangerously-skip-permissions
-claude_args = ["--model", "opus"]
-
-# Arguments to pass before default arguments (for overriding defaults)
-claude_pre_args = []
-
-# Enable Claude's permission system (default: false)
-# When true, removes --dangerously-skip-permissions flag
-enable_permissions = false
-
-# Disable verbose output (default: false)
-# When true, removes --verbose flag
-disable_verbose = false
-
-# Maximum concurrent tasks (default: 4)
-max_concurrent_tasks = 4
-
-# Default timeout for tasks in seconds (default: 0 = no timeout)
-default_timeout_secs = 0
-
-# Additional environment variables to pass to Claude Code
-[process.env_vars]
-ANTHROPIC_API_KEY = "sk-ant-..."
-CUSTOM_VAR = "value"
-
-# =============================================================================
-# Repository Auto-Clone
-# =============================================================================
-[repos]
-# Directory to clone repositories into (default: ~/.makima/home)
-home_dir = "/home/user/.makima/home"
-
-# List of repositories to auto-clone on startup
-# Repositories that already exist are skipped
-
-# Simple format - just URLs
-auto_clone = [
- "https://github.com/user/repo1.git",
- "https://github.com/user/repo2.git",
-]
-
-# Shorthand format supported:
-# github:user/repo -> https://github.com/user/repo.git
-# gitlab:user/repo -> https://gitlab.com/user/repo.git
-auto_clone = [
- "github:anthropics/claude-code",
- "gitlab:company/project",
-]
-
-# Detailed format with options (use [[repos.auto_clone]] for each entry)
-[[repos.auto_clone]]
-url = "github:user/repo"
-name = "custom-directory-name" # Optional: override directory name
-branch = "develop" # Optional: checkout specific branch
-shallow = true # Optional: shallow clone (--depth 1)
-
-[[repos.auto_clone]]
-url = "https://github.com/org/large-repo.git"
-shallow = true # Faster clone for large repos
-
-# =============================================================================
-# Local Database
-# =============================================================================
-[local_db]
-# Path to local SQLite database (default: ~/.makima/daemon.db)
-path = "/home/user/.makima/daemon.db"
-
-# =============================================================================
-# Logging
-# =============================================================================
-[logging]
-# Log level: trace, debug, info, warn, error (default: "info")
-level = "info"
-
-# Log format: "pretty" or "json" (default: "pretty")
-format = "pretty"
-```
-
----
-
-## Examples
-
-### Minimal Configuration
-
-```toml
-[server]
-url = "ws://localhost:8080"
-api_key = "your-api-key"
-```
-
-### Production Configuration
-
-```toml
-[server]
-url = "wss://api.makima.example.com/daemon"
-api_key = "prod-api-key"
-heartbeat_interval_secs = 30
-reconnect_interval_secs = 10
-max_reconnect_attempts = 0
-
-[worktree]
-base_dir = "/var/lib/makima/worktrees"
-repos_dir = "/var/lib/makima/repos"
-cleanup_on_start = true
-
-[process]
-max_concurrent_tasks = 8
-default_timeout_secs = 3600 # 1 hour timeout
-
-[logging]
-level = "info"
-format = "json"
-```
-
-### Development with Custom Claude
-
-```toml
-[server]
-url = "ws://localhost:8080"
-api_key = "dev-key"
-
-[process]
-# Use a specific claude binary
-claude_command = "/usr/local/bin/claude-dev"
-
-# Add custom arguments
-claude_args = ["--model", "sonnet", "--max-turns", "50"]
-
-# Enable permission prompts for testing
-enable_permissions = true
-
-[process.env_vars]
-ANTHROPIC_API_KEY = "sk-ant-dev-..."
-DEBUG = "1"
-
-[logging]
-level = "debug"
-```
-
-### Auto-Clone Team Repositories
-
-```toml
-[server]
-url = "ws://localhost:8080"
-api_key = "team-key"
-
-[repos]
-home_dir = "/home/dev/.makima/projects"
-
-# Clone all team repos on startup
-[[repos.auto_clone]]
-url = "github:myteam/frontend"
-branch = "main"
-
-[[repos.auto_clone]]
-url = "github:myteam/backend"
-branch = "main"
-
-[[repos.auto_clone]]
-url = "github:myteam/shared-libs"
-shallow = true # Only need latest commit
-```
-
----
-
-## Directory Structure
-
-After running, the daemon creates the following directories:
-
-```
-~/.makima/
-├── daemon.db # Local state database
-├── worktrees/ # Task worktrees (temporary)
-│ └── task-abc123/ # Individual task worktree
-├── repos/ # Cached repository clones
-│ └── repo-name/ # Bare clone for worktree creation
-└── home/ # Auto-cloned repositories
- └── my-repo/ # Full repository clone
-```
-
----
-
-## Troubleshooting
-
-### Connection Issues
-
-```bash
-# Check server connectivity
-curl -I http://localhost:8080/health
-
-# Run with debug logging
-makima-daemon --log-level debug
-```
-
-### Claude Code Not Found
-
-```bash
-# Verify claude is installed and in PATH
-which claude
-claude --version
-
-# Or specify full path in config
-[process]
-claude_command = "/full/path/to/claude"
-```
-
-### Permission Errors
-
-If Claude Code requires permissions, either:
-
-1. Use `--dangerously-skip-permissions` (default behavior)
-2. Set `enable_permissions = true` and handle permission prompts
-3. Pre-configure Claude Code permissions in `~/.claude/`
-
-### Task Timeouts
-
-Set an appropriate timeout for long-running tasks:
-
-```toml
-[process]
-default_timeout_secs = 7200 # 2 hours
-```
-
----
-
-## Environment Variable Reference
-
-All configuration options can be set via environment variables using the pattern:
-`MAKIMA_DAEMON_<SECTION>_<KEY>`
-
-Examples:
-- `MAKIMA_DAEMON_SERVER_URL` -> `server.url`
-- `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` -> `process.max_concurrent_tasks`
-- `MAKIMA_DAEMON_LOGGING_LEVEL` -> `logging.level`
-
-Special case:
-- `MAKIMA_API_KEY` -> `server.api_key` (preferred method)
diff --git a/makima/daemon/src/lib.rs b/makima/daemon/src/lib.rs
deleted file mode 100644
index 9555681..0000000
--- a/makima/daemon/src/lib.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-//! Makima Daemon - Git worktree orchestration for Claude Code.
-//!
-//! This daemon runs on worker machines and:
-//! - Connects to the makima server via WebSocket
-//! - Creates git worktrees for task isolation
-//! - Runs Claude Code CLI as subprocesses in worktrees
-//! - Streams JSON output back to server
-
-pub mod cli;
-pub mod config;
-pub mod db;
-pub mod error;
-pub mod process;
-pub mod task;
-pub mod temp;
-pub mod worktree;
-pub mod ws;
-
-pub use cli::Cli;
-pub use config::DaemonConfig;
-pub use error::{DaemonError, Result};
diff --git a/makima/daemon/src/main.rs b/makima/daemon/src/main.rs
deleted file mode 100644
index e4ca5d4..0000000
--- a/makima/daemon/src/main.rs
+++ /dev/null
@@ -1,313 +0,0 @@
-//! Makima Daemon - Git worktree orchestration for Claude Code.
-
-use std::sync::Arc;
-
-use std::path::Path;
-
-use clap::Parser;
-use makima_daemon::cli::Cli;
-use makima_daemon::config::{DaemonConfig, RepoEntry};
-use makima_daemon::db::LocalDb;
-use makima_daemon::error::DaemonError;
-use makima_daemon::task::{TaskConfig, TaskManager};
-use makima_daemon::ws::{DaemonCommand, WsClient};
-use tokio::process::Command;
-use tokio::sync::mpsc;
-use tracing_subscriber::{fmt, prelude::*, EnvFilter};
-
-#[tokio::main]
-async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- eprintln!("=== Makima Daemon Starting ===");
-
- // Parse command-line arguments
- let cli = Cli::parse();
-
- // Load configuration with CLI overrides
- eprintln!("[1/5] Loading configuration...");
- let config = match DaemonConfig::load_with_cli(&cli) {
- Ok(cfg) => {
- eprintln!(" Config loaded: server={}", cfg.server.url);
- cfg
- }
- Err(e) => {
- eprintln!("Failed to load configuration: {}", e);
- eprintln!();
- eprintln!("Use CLI flags:");
- eprintln!(" makima-daemon --server-url ws://localhost:8080 --api-key your-api-key");
- eprintln!();
- eprintln!("Or set environment variables:");
- eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080");
- eprintln!(" MAKIMA_API_KEY=your-api-key");
- eprintln!();
- eprintln!("Or create a config file: makima-daemon.toml");
- eprintln!();
- eprintln!("Run 'makima-daemon --help' for all options.");
- std::process::exit(1);
- }
- };
-
- // Initialize logging
- init_logging(&config.logging.level, &config.logging.format);
- eprintln!("[2/5] Logging initialized");
-
- // Initialize local database
- eprintln!("[3/5] Opening local database: {}", config.local_db.path.display());
- let _local_db = LocalDb::open(&config.local_db.path)?;
- eprintln!(" Database opened");
-
- // Initialize worktree directories
- eprintln!("[4/5] Setting up directories...");
- tokio::fs::create_dir_all(&config.worktree.base_dir).await?;
- tokio::fs::create_dir_all(&config.worktree.repos_dir).await?;
- tokio::fs::create_dir_all(&config.repos.home_dir).await?;
- eprintln!(" Worktree base: {}", config.worktree.base_dir.display());
- eprintln!(" Repos cache: {}", config.worktree.repos_dir.display());
- eprintln!(" Home dir: {}", config.repos.home_dir.display());
-
- // Auto-clone repositories if configured
- if !config.repos.auto_clone.is_empty() {
- eprintln!(" Auto-cloning {} repositories...", config.repos.auto_clone.len());
- for repo_entry in &config.repos.auto_clone {
- if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await {
- eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e);
- }
- }
- }
-
- // Create channels for communication
- let (command_tx, mut command_rx) = mpsc::channel::<DaemonCommand>(64);
-
- // Get machine info
- let machine_id = get_machine_id();
- let hostname = get_hostname();
- eprintln!(" Machine ID: {}", machine_id);
- eprintln!(" Hostname: {}", hostname);
-
- // Create WebSocket client
- eprintln!("[5/5] Connecting to server: {}", config.server.url);
- let mut ws_client = WsClient::new(
- config.server.clone(),
- machine_id,
- hostname,
- config.process.max_concurrent_tasks as i32,
- command_tx,
- );
-
- // Get sender for task manager
- let ws_tx = ws_client.sender();
-
- // Create task configuration
- let task_config = TaskConfig {
- max_concurrent_tasks: config.process.max_concurrent_tasks,
- worktree_base_dir: config.worktree.base_dir.clone(),
- env_vars: config.process.env_vars.clone(),
- claude_command: config.process.claude_command.clone(),
- claude_args: config.process.claude_args.clone(),
- claude_pre_args: config.process.claude_pre_args.clone(),
- enable_permissions: config.process.enable_permissions,
- disable_verbose: config.process.disable_verbose,
- };
-
- // Create task manager
- let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone()));
-
- // Spawn command handler
- let task_manager_clone = task_manager.clone();
- tokio::spawn(async move {
- tracing::info!("Command handler started, waiting for commands...");
- while let Some(command) = command_rx.recv().await {
- tracing::info!("Received command from channel: {:?}", command);
- if let Err(e) = task_manager_clone.handle_command(command).await {
- tracing::error!("Failed to handle command: {}", e);
- }
- }
- tracing::info!("Command handler stopped");
- });
-
- // Handle shutdown signals
- let shutdown_signal = async {
- tokio::signal::ctrl_c()
- .await
- .expect("Failed to install Ctrl+C handler");
- eprintln!("\nReceived shutdown signal");
- };
-
- eprintln!("=== Daemon running (Ctrl+C to stop) ===");
-
- // Run WebSocket client with shutdown handling
- tokio::select! {
- result = ws_client.run() => {
- match result {
- Ok(()) => eprintln!("WebSocket client exited cleanly"),
- Err(DaemonError::AuthFailed(msg)) => {
- eprintln!("ERROR: Authentication failed: {}", msg);
- std::process::exit(1);
- }
- Err(e) => {
- eprintln!("ERROR: WebSocket client error: {}", e);
- std::process::exit(1);
- }
- }
- }
- _ = shutdown_signal => {
- eprintln!("Shutting down...");
- }
- }
-
- // Cleanup
- tracing::info!("Daemon stopped");
-
- Ok(())
-}
-
-fn init_logging(level: &str, format: &str) {
- let filter = EnvFilter::try_from_default_env()
- .or_else(|_| EnvFilter::try_new(level))
- .unwrap_or_else(|_| EnvFilter::new("info"));
-
- let subscriber = tracing_subscriber::registry().with(filter);
-
- if format == "json" {
- subscriber.with(fmt::layer().json()).init();
- } else {
- subscriber.with(fmt::layer()).init();
- }
-}
-
-fn get_machine_id() -> String {
- // Try to read machine-id from standard locations
- #[cfg(target_os = "linux")]
- {
- if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
- return id.trim().to_string();
- }
- if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
- return id.trim().to_string();
- }
- }
-
- #[cfg(target_os = "macos")]
- {
- // Use IOPlatformSerialNumber
- if let Ok(output) = std::process::Command::new("ioreg")
- .args(["-rd1", "-c", "IOPlatformExpertDevice"])
- .output()
- {
- let stdout = String::from_utf8_lossy(&output.stdout);
- for line in stdout.lines() {
- if line.contains("IOPlatformUUID") {
- if let Some(uuid) = line.split('"').nth(3) {
- return uuid.to_string();
- }
- }
- }
- }
- }
-
- // Fallback: generate a random ID and persist it
- let state_dir = dirs_next::data_local_dir()
- .unwrap_or_else(|| std::path::PathBuf::from("."))
- .join("makima-daemon");
- let machine_id_file = state_dir.join("machine-id");
-
- if let Ok(id) = std::fs::read_to_string(&machine_id_file) {
- return id.trim().to_string();
- }
-
- // Generate new ID
- let new_id = uuid::Uuid::new_v4().to_string();
- std::fs::create_dir_all(&state_dir).ok();
- std::fs::write(&machine_id_file, &new_id).ok();
- new_id
-}
-
-fn get_hostname() -> String {
- hostname::get()
- .map(|h| h.to_string_lossy().to_string())
- .unwrap_or_else(|_| "unknown".to_string())
-}
-
-/// Auto-clone a repository to the home directory if it doesn't exist.
-async fn auto_clone_repo(
- repo_entry: &RepoEntry,
- home_dir: &Path,
-) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- let dir_name = repo_entry
- .dir_name()
- .ok_or("Could not determine directory name from URL")?;
- let target_dir = home_dir.join(&dir_name);
-
- // Check if already cloned
- if target_dir.exists() {
- eprintln!(" [skip] {} (already exists)", dir_name);
- return Ok(());
- }
-
- let url = repo_entry.expanded_url();
- eprintln!(" [clone] {} -> {}", url, target_dir.display());
-
- // Build git clone command
- let mut args = vec!["clone".to_string()];
-
- // Add shallow clone if requested
- if repo_entry.shallow() {
- args.push("--depth".to_string());
- args.push("1".to_string());
- }
-
- // Add branch if specified
- if let Some(branch) = repo_entry.branch() {
- args.push("--branch".to_string());
- args.push(branch.to_string());
- }
-
- args.push(url.clone());
- args.push(target_dir.to_string_lossy().to_string());
-
- // Run git clone
- let output = Command::new("git")
- .args(&args)
- .output()
- .await?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!("git clone failed: {}", stderr).into());
- }
-
- eprintln!(" [done] {}", dir_name);
- Ok(())
-}
-
-/// dirs_next minimal replacement
-mod dirs_next {
- use std::path::PathBuf;
-
- pub fn data_local_dir() -> Option<PathBuf> {
- #[cfg(target_os = "macos")]
- {
- std::env::var("HOME")
- .ok()
- .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
- }
- #[cfg(target_os = "linux")]
- {
- std::env::var("XDG_DATA_HOME")
- .ok()
- .map(PathBuf::from)
- .or_else(|| {
- std::env::var("HOME")
- .ok()
- .map(|h| PathBuf::from(h).join(".local").join("share"))
- })
- }
- #[cfg(target_os = "windows")]
- {
- std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
- }
- #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
- {
- None
- }
- }
-}
diff --git a/makima/docs/PLAN-task-branching.md b/makima/docs/PLAN-task-branching.md
new file mode 100644
index 0000000..cd35bc6
--- /dev/null
+++ b/makima/docs/PLAN-task-branching.md
@@ -0,0 +1,197 @@
+# Task Branching Feature for Makima
+
+## Overview
+
+Add the ability to branch/spin off tasks that resume from where another task ended, preserving both worktree state (file changes) and conversation context (what Claude discussed/discovered).
+
+**Key requirement:** The branched task should "remember" what was previously discussed, enabling exploration of alternative approaches from the same starting point.
+
+## Integration with Contracts
+
+Tasks are grouped under **contracts** - organizational units with phases (research → specify → plan → execute → review). When branching:
+
+- **Inherit contract**: Branched tasks stay in the same contract as the source task by default
+- **Optional override**: User can choose to place the branch in a different contract
+- **Phase awareness**: The branch inherits the contract's current phase context
+
+## Architecture Summary
+
+Since Claude Code is stateless (each invocation is fresh), we cannot replay the actual conversation. Instead, we:
+1. Capture and store conversation turns (user messages + assistant responses)
+2. When branching, inject a formatted transcript as context in the new task's plan
+3. Copy the worktree state using existing `continue_from_task_id` mechanism
+
+## Implementation Plan
+
+### Phase 1: Capture Conversation History
+
+**1.1 Database Migration**
+
+Create `makima/migrations/20250111000000_add_task_conversations.sql`:
+
+```sql
+CREATE TABLE IF NOT EXISTS task_conversations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ turn_index INTEGER NOT NULL,
+ role VARCHAR(32) NOT NULL, -- 'user', 'assistant'
+ content TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX idx_task_conversations_task_id ON task_conversations(task_id);
+```
+
+**1.2 Capture User Messages**
+
+Modify `makima/daemon/src/task/manager.rs`:
+- When sending initial plan to Claude, emit a `ConversationTurn` message to server
+- When forwarding user messages from `input_rx`, emit a `ConversationTurn` message
+
+**1.3 Capture Assistant Responses**
+
+Modify `makima/src/server/handlers/mesh_daemon.rs`:
+- Aggregate `TaskOutputEntry` messages with `message_type='assistant'` into conversation turns
+- Store in `task_conversations` table when a user message arrives (marks end of assistant turn)
+
+**1.4 New Protocol Message**
+
+Add to `makima/daemon/src/ws/protocol.rs` and `makima/src/server/protocol.rs`:
+```rust
+ConversationTurn {
+ task_id: Uuid,
+ turn_index: i32,
+ role: String, // "user" or "assistant"
+ content: String,
+}
+```
+
+### Phase 2: Branching Infrastructure
+
+**2.1 Database Migration**
+
+Create `makima/migrations/20250111000001_add_branching_fields.sql`:
+
+```sql
+ALTER TABLE tasks ADD COLUMN branched_from_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL;
+CREATE INDEX idx_tasks_branched_from ON tasks(branched_from_task_id) WHERE branched_from_task_id IS NOT NULL;
+```
+
+**2.2 Update Task Model**
+
+Modify `makima/src/db/models.rs`:
+```rust
+pub struct Task {
+ // ... existing fields ...
+ pub branched_from_task_id: Option<Uuid>,
+}
+
+pub struct BranchTaskRequest {
+ pub name: Option<String>,
+ pub plan: String,
+ pub include_conversation: bool, // default true
+ pub include_worktree: bool, // default true
+ pub contract_id: Option<Uuid>, // if None, inherit from source task
+}
+```
+
+**2.3 New Branch Endpoint**
+
+Add to `makima/src/server/handlers/mesh.rs`:
+- `POST /api/v1/mesh/tasks/{id}/branch` handler
+- Validates source task exists and user has permission
+- Fetches conversation history from `task_conversations`
+- Creates new task with:
+ - `branched_from_task_id` = source task ID
+ - `continue_from_task_id` = source task ID (for worktree copying)
+ - `contract_id` = request.contract_id OR source task's contract_id
+ - Plan = formatted conversation context + new instructions
+
+### Phase 3: Context Injection
+
+**3.1 Format Conversation Transcript**
+
+Add helper function in `makima/src/server/handlers/mesh.rs`:
+```rust
+fn format_conversation_context(turns: Vec<ConversationTurn>) -> String {
+ // Format as:
+ // ## Previous Conversation Context
+ //
+ // **User:** {message}
+ // **Assistant:** {response}
+ // ...
+ //
+ // ---
+}
+```
+
+**3.2 Prepend to Branched Task Plan**
+
+When creating a branched task, construct the plan as:
+```
+{formatted_conversation_context}
+
+## New Instructions
+
+{user's new plan}
+```
+
+### Phase 4: Frontend Integration
+
+**4.1 API Client**
+
+Add to `makima/frontend/src/lib/api.ts`:
+```typescript
+branchTask(id: string, request: BranchTaskRequest): Promise<Task>
+```
+
+**4.2 Branch Button**
+
+Add to `makima/frontend/src/components/mesh/TaskDetail.tsx`:
+- "Branch" button in task actions
+- Opens dialog to enter new instructions
+- Checkboxes for "Include conversation" and "Include file changes"
+- Contract selector (defaults to current contract, allows choosing another)
+
+**4.3 Tree Visualization (Optional)**
+
+Show `branched_from_task_id` relationships in task list to visualize the branch tree.
+
+**4.4 Contract Tasks View**
+
+Update `makima/frontend/src/components/contracts/ContractDetail.tsx`:
+- Show branched tasks in the Tasks tab with visual indicators
+- Display branch relationships (e.g., "Branched from: Task X")
+
+## Files to Modify
+
+### Backend (Server)
+- `makima/src/db/models.rs` - Add `BranchTaskRequest`, extend `Task`
+- `makima/src/db/repository.rs` - Add conversation CRUD, branch query
+- `makima/src/server/handlers/mesh.rs` - Add `branch_task` handler
+- `makima/src/server/handlers/mesh_daemon.rs` - Store conversation turns
+- `makima/src/server/protocol.rs` - Add `ConversationTurn` message
+
+### Backend (Daemon)
+- `makima/daemon/src/ws/protocol.rs` - Add `ConversationTurn` message
+- `makima/daemon/src/task/manager.rs` - Emit user messages as conversation turns
+
+### Migrations
+- `makima/migrations/20250111000000_add_task_conversations.sql`
+- `makima/migrations/20250111000001_add_branching_fields.sql`
+
+### Frontend
+- `makima/frontend/src/lib/api.ts` - Add `branchTask()` method and types
+- `makima/frontend/src/components/mesh/TaskDetail.tsx` - Add Branch button/dialog
+- `makima/frontend/src/components/contracts/ContractDetail.tsx` - Show branch relationships in Tasks tab
+
+## Key Design Decisions
+
+1. **Conversation as context, not replay** - We prepend a transcript to the plan rather than trying to inject messages into Claude Code (which only accepts user messages)
+
+2. **Separate from parent-child hierarchy** - Using `branched_from_task_id` instead of `parent_task_id` to distinguish from orchestrator/subtask relationships
+
+3. **Reuse existing worktree mechanism** - Set `continue_from_task_id` to copy files from source task
+
+4. **Opt-in conversation/worktree** - Both are configurable per-branch request
+
+5. **Contract inheritance** - Branched tasks inherit the source task's contract by default, but can be placed in a different contract if needed (e.g., branching research findings into a new execute-phase contract)
diff --git a/makima/frontend/src/components/JapaneseHoverText.tsx b/makima/frontend/src/components/JapaneseHoverText.tsx
new file mode 100644
index 0000000..3e60ee2
--- /dev/null
+++ b/makima/frontend/src/components/JapaneseHoverText.tsx
@@ -0,0 +1,77 @@
+import { useState, useCallback, useRef } from "react";
+
+const GLYPHS = "▒▓░█#@*+:-/[]{}<>_";
+
+interface JapaneseHoverTextProps {
+ japanese: string;
+ english: string;
+ className?: string;
+}
+
+/**
+ * Displays Japanese text, transitions to English on hover with scramble effect
+ */
+export function JapaneseHoverText({
+ japanese,
+ english,
+ className = "",
+}: JapaneseHoverTextProps) {
+ const [isHovered, setIsHovered] = useState(false);
+ const [displayText, setDisplayText] = useState(english);
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+ const iterationRef = useRef(0);
+
+ const scrambleToEnglish = useCallback(() => {
+ setIsHovered(true);
+
+ // Clear any existing animation
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+
+ iterationRef.current = 0;
+
+ timerRef.current = setInterval(() => {
+ const text = english;
+ const iteration = iterationRef.current;
+
+ const display = text
+ .split("")
+ .map((char, index) => {
+ if (index < iteration) return char;
+ return GLYPHS.charAt(Math.floor(Math.random() * GLYPHS.length));
+ })
+ .join("");
+
+ setDisplayText(display);
+ iterationRef.current += 1;
+
+ if (iteration > text.length + 2) {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ setDisplayText(english);
+ }
+ }, 26);
+ }, [english]);
+
+ const resetToJapanese = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ setIsHovered(false);
+ setDisplayText(english);
+ }, [english]);
+
+ return (
+ <span
+ className={`cursor-default ${className}`}
+ onMouseEnter={scrambleToEnglish}
+ onMouseLeave={resetToJapanese}
+ >
+ {isHovered ? displayText : japanese}
+ </span>
+ );
+}
diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx
index afe385e..bc184bd 100644
--- a/makima/frontend/src/components/Masthead.tsx
+++ b/makima/frontend/src/components/Masthead.tsx
@@ -1,6 +1,7 @@
import { Link } from "react-router";
import { LogoMark } from "./Logo";
import { NavStrip } from "./NavStrip";
+import { JapaneseHoverText } from "./JapaneseHoverText";
interface MastheadProps {
showTicker?: boolean;
@@ -18,7 +19,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
makima.jp
</h1>
<small className="block text-[#dbe7ff] text-xs tracking-wide">
- Control System
+ <JapaneseHoverText
+ japanese="支配する"
+ english="Control System"
+ />
</small>
</div>
</Link>
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 642e9a3..48abe09 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -11,6 +11,8 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Files", href: "/files", requiresAuth: true },
+ { label: "Contracts", href: "/contracts", requiresAuth: true },
+ { label: "Board", href: "/workflow", requiresAuth: true },
{ label: "Mesh", href: "/mesh", requiresAuth: true },
];
diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx
new file mode 100644
index 0000000..821d03c
--- /dev/null
+++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx
@@ -0,0 +1,974 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import {
+ getContractChatHistory,
+ clearContractChatHistory,
+ startTask,
+ sendTaskMessage,
+ type UserQuestion,
+ type ContractWithRelations,
+ type TaskStatus,
+} from "../../lib/api";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import {
+ QuickActionButtons,
+ type QuickAction,
+} from "./QuickActionButtons";
+import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview";
+import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription";
+
+interface ContractCliInputProps {
+ contractId: string;
+ contract: ContractWithRelations;
+ onUpdate: () => void;
+}
+
+interface Message {
+ id: string;
+ type: "user" | "assistant" | "error" | "question";
+ content: string;
+ toolCalls?: { name: string; success: boolean; message: string }[];
+ questions?: UserQuestion[];
+ quickActions?: QuickAction[];
+}
+
+export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) {
+ const [input, setInput] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [historyLoading, setHistoryLoading] = useState(true);
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [expanded, setExpanded] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null);
+ const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map());
+ const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map());
+
+ // Task derivation state
+ const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null);
+ const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]);
+ const [parsedTasksFileName, setParsedTasksFileName] = useState<string>("");
+ const [creatingTasks, setCreatingTasks] = useState(false);
+
+ // Supervisor state
+ const [supervisorStarting, setSupervisorStarting] = useState(false);
+ const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]);
+ const [supervisorQuestion, setSupervisorQuestion] = useState<{
+ id: string;
+ question: string;
+ options: string[];
+ allowMultiple?: boolean;
+ allowCustom?: boolean;
+ } | null>(null);
+
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(null);
+
+ // Find the supervisor task for this contract
+ // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag
+ const supervisorTask = useMemo(() => {
+ // Use contract.supervisorTaskId if available (most reliable)
+ if (contract.supervisorTaskId) {
+ const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId);
+ if (taskById) return taskById;
+ }
+ // Fallback to finding by isSupervisor flag
+ return contract.tasks.find((t) => t.isSupervisor);
+ }, [contract.tasks, contract.supervisorTaskId]);
+
+ // Log for debugging
+ useEffect(() => {
+ console.log("Supervisor lookup:", {
+ contractId: contract.id,
+ supervisorTaskId: contract.supervisorTaskId,
+ tasksCount: contract.tasks.length,
+ foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null,
+ allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor }))
+ });
+ }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]);
+
+ const supervisorTaskId = supervisorTask?.id ?? null;
+ const supervisorStatus = supervisorTask?.status as TaskStatus | undefined;
+ const isSupervisorRunning = supervisorStatus === "running";
+ const isSupervisorPending = supervisorStatus === "pending";
+
+ // Subscribe to supervisor output when it's running
+ const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => {
+ // Check for question pattern in output
+ // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}}
+ if (!event.isPartial && event.content) {
+ const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/);
+ if (questionMatch) {
+ try {
+ const questionData = JSON.parse(questionMatch[1]);
+ if (questionData.id && questionData.question && questionData.options) {
+ setSupervisorQuestion({
+ id: questionData.id,
+ question: questionData.question,
+ options: questionData.options,
+ allowMultiple: questionData.allowMultiple ?? false,
+ allowCustom: questionData.allowCustom ?? true,
+ });
+ // Don't add this to output since it's a control message
+ return;
+ }
+ } catch {
+ // Not valid JSON, continue as normal output
+ }
+ }
+ }
+
+ setSupervisorOutput((prev) => {
+ // If it's a partial message, update the last message
+ if (event.isPartial && prev.length > 0) {
+ const lastEvent = prev[prev.length - 1];
+ if (lastEvent.messageType === event.messageType && lastEvent.isPartial) {
+ return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }];
+ }
+ }
+ return [...prev, event];
+ });
+ }, []);
+
+ useTaskSubscription({
+ taskId: supervisorTaskId,
+ subscribeOutput: isSupervisorRunning,
+ onOutput: handleSupervisorOutput,
+ });
+
+ // Auto-start supervisor function - starts and waits for it to be running
+ const ensureSupervisorStarted = useCallback(async (): Promise<boolean> => {
+ if (!supervisorTask) {
+ console.warn("No supervisor task found for contract");
+ return false;
+ }
+
+ if (isSupervisorRunning) {
+ return true; // Already running
+ }
+
+ if (isSupervisorPending) {
+ try {
+ setSupervisorStarting(true);
+ await startTask(supervisorTask.id);
+
+ // Poll for the task to be running (up to 10 seconds)
+ for (let i = 0; i < 20; i++) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ onUpdate(); // Refresh contract to get updated task status
+ // Note: We can't check the new status here directly since state updates are async
+ // The UI will update when onUpdate triggers a re-render
+ }
+
+ // Return true - the caller should check if supervisor is running after this
+ return true;
+ } catch (err) {
+ console.error("Failed to start supervisor:", err);
+ return false;
+ } finally {
+ setSupervisorStarting(false);
+ }
+ }
+
+ // Supervisor exists but is in some other state (paused, done, failed, etc.)
+ // Can still send messages to paused tasks
+ return supervisorStatus === "paused";
+ }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]);
+
+ // Handle answering supervisor questions
+ const [supervisorAnswers, setSupervisorAnswers] = useState<string[]>([]);
+ const [supervisorCustomInput, setSupervisorCustomInput] = useState("");
+
+ const handleSupervisorOptionToggle = useCallback((option: string) => {
+ setSupervisorAnswers((prev) => {
+ if (supervisorQuestion?.allowMultiple) {
+ if (prev.includes(option)) {
+ return prev.filter((a) => a !== option);
+ }
+ return [...prev, option];
+ }
+ return [option];
+ });
+ }, [supervisorQuestion?.allowMultiple]);
+
+ const handleSubmitSupervisorAnswer = useCallback(async () => {
+ if (!supervisorQuestion || !supervisorTask) return;
+
+ const customAnswer = supervisorCustomInput.trim();
+ const allAnswers = customAnswer
+ ? [...supervisorAnswers, customAnswer]
+ : supervisorAnswers;
+
+ if (allAnswers.length === 0) return;
+
+ // Format answer message for supervisor
+ const answerMessage = `__supervisor_answer__ ${JSON.stringify({
+ id: supervisorQuestion.id,
+ answers: allAnswers,
+ })}`;
+
+ try {
+ await sendTaskMessage(supervisorTask.id, answerMessage);
+
+ // Add user message to chat
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: userMsgId,
+ type: "user",
+ content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`,
+ },
+ ]);
+ } catch (err) {
+ console.error("Failed to send supervisor answer:", err);
+ } finally {
+ setSupervisorQuestion(null);
+ setSupervisorAnswers([]);
+ setSupervisorCustomInput("");
+ }
+ }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]);
+
+ const handleCancelSupervisorQuestion = useCallback(() => {
+ setSupervisorQuestion(null);
+ setSupervisorAnswers([]);
+ setSupervisorCustomInput("");
+ }, []);
+
+ // Load chat history on mount
+ useEffect(() => {
+ let mounted = true;
+
+ async function loadHistory() {
+ try {
+ const history = await getContractChatHistory(contractId);
+ if (!mounted) return;
+
+ // Convert saved messages to display messages
+ const displayMessages: Message[] = history.messages.map((msg) => ({
+ id: msg.id,
+ type: msg.role as "user" | "assistant" | "error",
+ content: msg.content,
+ toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined,
+ }));
+
+ setMessages(displayMessages);
+
+ // Auto-expand if there's history
+ if (displayMessages.length > 0) {
+ setExpanded(true);
+ }
+ } catch (err) {
+ console.error("Failed to load contract chat history:", err);
+ } finally {
+ if (mounted) {
+ setHistoryLoading(false);
+ }
+ }
+ }
+
+ loadHistory();
+
+ return () => {
+ mounted = false;
+ };
+ }, [contractId]);
+
+ // Auto-scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesRef.current) {
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ // Auto-start supervisor when component mounts if it's pending
+ useEffect(() => {
+ if (supervisorTask && isSupervisorPending && !supervisorStarting) {
+ console.log("Auto-starting supervisor task on mount...");
+ ensureSupervisorStarted().then((started) => {
+ if (started) {
+ console.log("Supervisor started successfully");
+ }
+ });
+ }
+ }, [supervisorTask?.id]); // Only run when task ID changes, not on every render
+
+ // Convert supervisor output events to messages
+ useEffect(() => {
+ if (supervisorOutput.length === 0) return;
+
+ // Get the latest event
+ const latestEvent = supervisorOutput[supervisorOutput.length - 1];
+
+ // Only add complete messages (not partials) to the message history
+ if (!latestEvent.isPartial && latestEvent.content.trim()) {
+ const msgId = `supervisor-${Date.now()}`;
+ let msgType: "assistant" | "error" = "assistant";
+ let content = latestEvent.content;
+
+ // Format based on message type
+ switch (latestEvent.messageType) {
+ case "assistant":
+ content = latestEvent.content;
+ break;
+ case "tool_use":
+ content = `_Using tool: ${latestEvent.toolName}_`;
+ break;
+ case "tool_result":
+ content = latestEvent.isError
+ ? `Tool error: ${latestEvent.content}`
+ : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`;
+ msgType = latestEvent.isError ? "error" : "assistant";
+ break;
+ case "error":
+ msgType = "error";
+ break;
+ case "result":
+ // Final result - show cost info if available
+ if (latestEvent.costUsd) {
+ content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`;
+ }
+ break;
+ default:
+ // system, raw, etc.
+ break;
+ }
+
+ setMessages((prev) => {
+ // Don't add duplicate messages
+ if (prev.some((m) => m.content === content)) return prev;
+ return [
+ ...prev,
+ { id: msgId, type: msgType, content },
+ ];
+ });
+ }
+ }, [supervisorOutput]);
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!input.trim() || loading) return;
+
+ const userMessage = input.trim();
+ setInput("");
+ setExpanded(true);
+
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ { id: userMsgId, type: "user", content: userMessage },
+ ]);
+
+ setLoading(true);
+
+ try {
+ // Supervisor is the ONLY way to interact with contracts
+ if (!supervisorTask) {
+ throw new Error("No supervisor task found. Please create a contract with a supervisor.");
+ }
+
+ // Ensure supervisor is started (this will start it if pending)
+ await ensureSupervisorStarted();
+
+ // Send message to supervisor task stdin
+ await sendTaskMessage(supervisorTask.id, userMessage);
+
+ // Response will come through WebSocket subscription
+ // No need for a placeholder message - output will stream in
+ } catch (err) {
+ const errorMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: errorMsgId,
+ type: "error",
+ content: err instanceof Error ? err.message : "An error occurred",
+ },
+ ]);
+ } finally {
+ setLoading(false);
+ inputRef.current?.focus();
+ }
+ },
+ [input, loading, supervisorTask, ensureSupervisorStarted]
+ );
+
+ const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => {
+ setUserAnswers((prev) => {
+ const newMap = new Map(prev);
+ const currentAnswers = newMap.get(questionId) || [];
+
+ if (allowMultiple) {
+ if (currentAnswers.includes(option)) {
+ newMap.set(questionId, currentAnswers.filter((a) => a !== option));
+ } else {
+ newMap.set(questionId, [...currentAnswers, option]);
+ }
+ } else {
+ newMap.set(questionId, [option]);
+ }
+
+ return newMap;
+ });
+ }, []);
+
+ const handleCustomInputChange = useCallback((questionId: string, value: string) => {
+ setCustomInputs((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(questionId, value);
+ return newMap;
+ });
+ }, []);
+
+ const handleSubmitAnswers = useCallback(async () => {
+ if (!pendingQuestions || loading) return;
+
+ const answers = pendingQuestions.map((q) => {
+ const selectedOptions = userAnswers.get(q.id) || [];
+ const customInput = customInputs.get(q.id)?.trim();
+ const finalAnswers = customInput
+ ? [...selectedOptions, customInput]
+ : selectedOptions;
+
+ return {
+ id: q.id,
+ answers: finalAnswers,
+ };
+ });
+
+ const answerText = answers
+ .map((a) => {
+ const question = pendingQuestions.find((q) => q.id === a.id);
+ return `${question?.question || a.id}: ${a.answers.join(", ")}`;
+ })
+ .join("\n");
+
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` },
+ ]);
+
+ setLoading(true);
+
+ try {
+ if (!supervisorTask) {
+ throw new Error("No supervisor task found");
+ }
+ await ensureSupervisorStarted();
+ await sendTaskMessage(supervisorTask.id, answerText);
+ // Response will come through WebSocket
+ } catch (err) {
+ const errorMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: errorMsgId,
+ type: "error",
+ content: err instanceof Error ? err.message : "An error occurred",
+ },
+ ]);
+ } finally {
+ setLoading(false);
+ }
+ }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]);
+
+ const handleCancelQuestions = useCallback(() => {
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, []);
+
+ const clearMessages = useCallback(() => {
+ setMessages([]);
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ setParsedTasks(null);
+ setParsedTaskGroups([]);
+ setParsedTasksFileName("");
+ setSupervisorOutput([]);
+ setSupervisorQuestion(null);
+ setSupervisorAnswers([]);
+ setSupervisorCustomInput("");
+ }, []);
+
+ // Handle creating tasks from the preview
+ const handleCreateDerivedTasks = useCallback(
+ async (selectedTasks: ParsedTask[]) => {
+ if (selectedTasks.length === 0) {
+ setParsedTasks(null);
+ return;
+ }
+
+ setCreatingTasks(true);
+
+ // Build a message asking the supervisor to create these tasks
+ const taskList = selectedTasks
+ .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`)
+ .join("\n");
+
+ const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`;
+
+ // Add user message
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ { id: userMsgId, type: "user", content: message },
+ ]);
+
+ try {
+ if (!supervisorTask) {
+ throw new Error("No supervisor task found");
+ }
+ await ensureSupervisorStarted();
+ await sendTaskMessage(supervisorTask.id, message);
+ // Response will come through WebSocket
+ } catch (err) {
+ const errorMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: errorMsgId,
+ type: "error",
+ content: err instanceof Error ? err.message : "An error occurred",
+ },
+ ]);
+ } finally {
+ setCreatingTasks(false);
+ setParsedTasks(null);
+ setParsedTaskGroups([]);
+ setParsedTasksFileName("");
+ }
+ },
+ [supervisorTask, ensureSupervisorStarted]
+ );
+
+ const handleCancelTaskDerivation = useCallback(() => {
+ setParsedTasks(null);
+ setParsedTaskGroups([]);
+ setParsedTasksFileName("");
+ }, []);
+
+ const handleQuickAction = useCallback(
+ async (action: QuickAction) => {
+ // Convert the action into a chat message that triggers the appropriate behavior
+ let message = "";
+ switch (action.type) {
+ case "create_file":
+ message = "Create the suggested file from the template.";
+ break;
+ case "create_task":
+ message = "Yes, create the tasks.";
+ break;
+ case "derive_tasks":
+ message = "Show me the tasks to review and create them.";
+ break;
+ case "run_task":
+ message = "Run the next task.";
+ break;
+ case "advance_phase":
+ if (action.data?.phase) {
+ message = `Advance to the ${action.data.phase} phase.`;
+ } else {
+ message = "Advance to the next phase.";
+ }
+ break;
+ case "update_file":
+ message = "Update the file with the task output.";
+ break;
+ default:
+ return;
+ }
+
+ setExpanded(true);
+
+ // Submit the message
+ const userMsgId = Date.now().toString();
+ setMessages((prev) => [
+ ...prev,
+ { id: userMsgId, type: "user", content: message },
+ ]);
+
+ setLoading(true);
+ try {
+ if (!supervisorTask) {
+ throw new Error("No supervisor task found");
+ }
+ await ensureSupervisorStarted();
+ await sendTaskMessage(supervisorTask.id, message);
+ // Response will come through WebSocket
+ } catch (err) {
+ const errorMsgId = (Date.now() + 1).toString();
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: errorMsgId,
+ type: "error",
+ content: err instanceof Error ? err.message : "An error occurred",
+ },
+ ]);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [supervisorTask, ensureSupervisorStarted]
+ );
+
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Header bar with supervisor status and toggle */}
+ <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]">
+ <div className="flex items-center gap-3">
+ <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide">
+ Supervisor
+ </span>
+ {supervisorTask && (
+ <span className={`font-mono text-[10px] px-2 py-0.5 border ${
+ isSupervisorRunning
+ ? "text-green-400 border-green-400/30 bg-green-400/10"
+ : isSupervisorPending || supervisorStarting
+ ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10"
+ : "text-[#555] border-[rgba(117,170,252,0.2)]"
+ }`}>
+ {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"}
+ </span>
+ )}
+ {!supervisorTask && (
+ <span className="font-mono text-[10px] text-red-400">
+ No supervisor
+ </span>
+ )}
+ </div>
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={() => setExpanded(!expanded)}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ {expanded ? "Hide Messages" : `Show Messages (${messages.length})`}
+ </button>
+ )}
+ </div>
+
+ {/* History loading indicator */}
+ {historyLoading && (
+ <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]">
+ <span className="animate-pulse">Loading history...</span>
+ </div>
+ )}
+
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && !historyLoading && (
+ <div className="relative border-b border-[rgba(117,170,252,0.2)]">
+ {/* Expand/Collapse button */}
+ <div className="absolute top-2 right-2 z-10 flex gap-1">
+ <button
+ type="button"
+ onClick={() => setFullscreen(!fullscreen)}
+ className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
+ title={fullscreen ? "Collapse" : "Expand"}
+ >
+ {fullscreen ? "▼ Collapse" : "▲ Expand"}
+ </button>
+ </div>
+ <div
+ ref={messagesRef}
+ className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${
+ fullscreen ? "max-h-[60vh]" : "max-h-48"
+ }`}
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.type === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span>
+ </div>
+ )}
+ {(msg.type === "assistant" || msg.type === "question") && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown content={msg.content} className="text-[#75aafc]" />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.success ? "text-green-500" : "text-red-400"
+ }
+ >
+ {tc.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.message}
+ </div>
+ ))}
+ </div>
+ )}
+ {msg.quickActions && msg.quickActions.length > 0 && (
+ <QuickActionButtons
+ actions={msg.quickActions}
+ onAction={handleQuickAction}
+ loading={loading}
+ />
+ )}
+ </div>
+ )}
+ {msg.type === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">{q.question}</div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(option);
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)}
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Supervisor Question UI */}
+ {supervisorQuestion && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]">
+ <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2">
+ <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
+ Question from Supervisor
+ </div>
+ <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div>
+ <div className="flex flex-wrap gap-2">
+ {supervisorQuestion.options.map((option) => {
+ const isSelected = supervisorAnswers.includes(option);
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() => handleSupervisorOptionToggle(option)}
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-green-500/30 border-green-400 text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400"
+ }`}
+ >
+ {supervisorQuestion.allowMultiple && (
+ <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {supervisorQuestion.allowCustom && (
+ <input
+ type="text"
+ value={supervisorCustomInput}
+ onChange={(e) => setSupervisorCustomInput(e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]"
+ />
+ )}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitSupervisorAnswer}
+ disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ Send Answer
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelSupervisorQuestion}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Contract Context Badge */}
+ <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]">
+ <span className="text-[#75aafc]">{contract.phase}</span>
+ <span>|</span>
+ {supervisorTask && (
+ <>
+ <span
+ className={
+ isSupervisorRunning
+ ? "text-green-400"
+ : isSupervisorPending
+ ? "text-yellow-400"
+ : supervisorStarting
+ ? "text-cyan-400 animate-pulse"
+ : "text-[#555]"
+ }
+ >
+ Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status}
+ </span>
+ <span>|</span>
+ </>
+ )}
+ <span>{contract.files.length} files</span>
+ <span>|</span>
+ <span>{contract.tasks.length} tasks</span>
+ <span>|</span>
+ <span>{contract.repositories.length} repos</span>
+ <span>|</span>
+ <button
+ type="button"
+ onClick={() => {
+ const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps.";
+ setInput(prompt);
+ // Auto-submit the prompt
+ setTimeout(() => {
+ const form = document.querySelector('form');
+ if (form) form.requestSubmit();
+ }, 0);
+ }}
+ disabled={loading || !!pendingQuestions}
+ className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer"
+ >
+ Progress →
+ </button>
+ {messages.length > 0 && (
+ <>
+ <span>|</span>
+ <button
+ type="button"
+ onClick={async () => {
+ if (window.confirm("Clear all chat history for this contract?")) {
+ try {
+ await clearContractChatHistory(contractId);
+ setMessages([]);
+ } catch (err) {
+ console.error("Failed to clear history:", err);
+ }
+ }
+ }}
+ disabled={loading}
+ className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer"
+ >
+ Clear
+ </button>
+ </>
+ )}
+ </div>
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3">
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ placeholder={
+ loading
+ ? "Processing..."
+ : supervisorStarting
+ ? "Starting supervisor..."
+ : supervisorQuestion
+ ? "Answer supervisor question above..."
+ : pendingQuestions
+ ? "Answer questions above first..."
+ : isSupervisorRunning
+ ? "Message supervisor..."
+ : "Create a task, add a file, or ask about the contract..."
+ }
+ disabled={loading || !!pendingQuestions || !!supervisorQuestion}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={clearMessages}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+
+ {/* Task Derivation Preview Modal */}
+ {parsedTasks && parsedTasks.length > 0 && (
+ <TaskDerivationPreview
+ tasks={parsedTasks}
+ groups={parsedTaskGroups}
+ fileName={parsedTasksFileName}
+ onCreateTasks={handleCreateDerivedTasks}
+ onCancel={handleCancelTaskDerivation}
+ loading={creatingTasks}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx
new file mode 100644
index 0000000..cf5f8f2
--- /dev/null
+++ b/makima/frontend/src/components/contracts/ContractDetail.tsx
@@ -0,0 +1,794 @@
+import { useState, useEffect, useCallback } from "react";
+import type {
+ ContractWithRelations,
+ ContractPhase,
+ ContractStatus,
+ ContractRepository,
+ FileSummary,
+ TaskSummary,
+ TemplateSummary,
+} from "../../lib/api";
+import {
+ listTemplates,
+ getTemplate,
+ createFile,
+} from "../../lib/api";
+import { PhaseProgressBar } from "./PhaseProgressBar";
+import { PhaseHint } from "./PhaseHint";
+import { RepositoryPanel } from "./RepositoryPanel";
+import { ContractCliInput } from "./ContractCliInput";
+import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel";
+import { TaskTree } from "../mesh/TaskTree";
+
+type Tab = "overview" | "repos" | "files" | "tasks";
+
+interface ContractDetailProps {
+ contract: ContractWithRelations;
+ loading: boolean;
+ onBack: () => void;
+ onUpdate: (name: string, description: string) => void;
+ onDelete: () => void;
+ onPhaseChange: (phase: ContractPhase) => void;
+ onStatusChange: (status: ContractStatus) => void;
+ onFileSelect: (id: string) => void;
+ onTaskSelect: (id: string) => void;
+ onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void;
+ onRefresh: () => void;
+ // Repository callbacks
+ onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void;
+ onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void;
+ onCreateManagedRepo: (name: string, isPrimary: boolean) => void;
+ onDeleteRepo: (repoId: string) => void;
+ onSetRepoPrimary: (repoId: string) => void;
+ // File creation callback for phase deliverables
+ onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void;
+}
+
+const statusConfig: Record<ContractStatus, { label: string; color: string }> = {
+ active: { label: "Active", color: "text-green-400" },
+ completed: { label: "Completed", color: "text-blue-400" },
+ archived: { label: "Archived", color: "text-[#555]" },
+};
+
+export function ContractDetail({
+ contract,
+ loading,
+ onBack,
+ onUpdate,
+ onDelete,
+ onPhaseChange,
+ onStatusChange,
+ onFileSelect,
+ onTaskSelect,
+ onTaskCreate,
+ onRefresh,
+ onAddRemoteRepo,
+ onAddLocalRepo,
+ onCreateManagedRepo,
+ onDeleteRepo,
+ onSetRepoPrimary,
+ onCreateFileFromTemplate,
+}: ContractDetailProps) {
+ const [activeTab, setActiveTab] = useState<Tab>("overview");
+ const [isEditing, setIsEditing] = useState(false);
+ const [name, setName] = useState(contract.name);
+ const [description, setDescription] = useState(contract.description || "");
+
+ const handleSave = () => {
+ onUpdate(name, description);
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setName(contract.name);
+ setDescription(contract.description || "");
+ setIsEditing(false);
+ };
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ );
+ }
+
+ const tabs: { key: Tab; label: string; count?: number }[] = [
+ { key: "overview", label: "Overview" },
+ { key: "repos", label: "Repositories", count: contract.repositories.length },
+ { key: "files", label: "Files", count: contract.files.length },
+ { key: "tasks", label: "Tasks", count: contract.tasks.length },
+ ];
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center justify-between mb-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &larr; Back to list
+ </button>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={onDelete}
+ className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isEditing ? (
+ <div className="space-y-3">
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ placeholder="Contract name"
+ />
+ <textarea
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ rows={2}
+ placeholder="Description (optional)"
+ />
+ </div>
+ ) : (
+ <>
+ <div className="flex items-center gap-3 mb-2">
+ <h2 className="font-mono text-lg text-[#dbe7ff]">
+ {contract.name}
+ </h2>
+ <span
+ className={`font-mono text-xs uppercase ${
+ statusConfig[contract.status].color
+ }`}
+ >
+ {statusConfig[contract.status].label}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="font-mono text-sm text-[#9bc3ff] mb-3">
+ {contract.description}
+ </p>
+ )}
+ </>
+ )}
+
+ {/* Phase progress */}
+ <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]">
+ <PhaseProgressBar
+ currentPhase={contract.phase}
+ onPhaseClick={onPhaseChange}
+ />
+ </div>
+ </div>
+
+ {/* Tabs */}
+ <div className="flex border-b border-[rgba(117,170,252,0.2)]">
+ {tabs.map((tab) => (
+ <button
+ key={tab.key}
+ onClick={() => setActiveTab(tab.key)}
+ className={`
+ px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors
+ ${
+ activeTab === tab.key
+ ? "text-[#dbe7ff] border-b-2 border-[#75aafc]"
+ : "text-[#555] hover:text-[#9bc3ff]"
+ }
+ `}
+ >
+ {tab.label}
+ {tab.count !== undefined && tab.count > 0 && (
+ <span className="ml-1 text-[10px]">({tab.count})</span>
+ )}
+ </button>
+ ))}
+ </div>
+
+ {/* Tab content */}
+ <div className="flex-1 overflow-y-auto p-4">
+ {activeTab === "overview" && (
+ <OverviewTab
+ contract={contract}
+ onStatusChange={onStatusChange}
+ onPhaseChange={onPhaseChange}
+ onCreateFile={onCreateFileFromTemplate}
+ />
+ )}
+
+ {activeTab === "repos" && (
+ <RepositoryPanel
+ repositories={contract.repositories}
+ onAddRemote={onAddRemoteRepo}
+ onAddLocal={onAddLocalRepo}
+ onCreateManaged={onCreateManagedRepo}
+ onDelete={onDeleteRepo}
+ onSetPrimary={onSetRepoPrimary}
+ />
+ )}
+
+ {activeTab === "files" && (
+ <FilesTab
+ files={contract.files}
+ contractId={contract.id}
+ contractPhase={contract.phase}
+ onSelect={onFileSelect}
+ onRefresh={onRefresh}
+ />
+ )}
+
+ {activeTab === "tasks" && (
+ <TasksTab
+ tasks={contract.tasks}
+ repositories={contract.repositories}
+ supervisorTaskId={contract.supervisorTaskId}
+ onSelect={onTaskSelect}
+ onCreate={onTaskCreate}
+ />
+ )}
+ </div>
+
+ {/* Chat Input */}
+ <ContractCliInput
+ contractId={contract.id}
+ contract={contract}
+ onUpdate={onRefresh}
+ />
+ </div>
+ );
+}
+
+// Overview tab
+function OverviewTab({
+ contract,
+ onStatusChange,
+ onPhaseChange,
+ onCreateFile,
+}: {
+ contract: ContractWithRelations;
+ onStatusChange: (status: ContractStatus) => void;
+ onPhaseChange: (phase: ContractPhase) => void;
+ onCreateFile?: (templateId: string, suggestedName: string) => void;
+}) {
+ return (
+ <div className="space-y-6">
+ {/* Phase deliverables checklist */}
+ <PhaseDeliverablesPanel
+ contract={contract}
+ onCreateFile={onCreateFile}
+ />
+
+ {/* Phase hint */}
+ <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} />
+
+ {/* Task progress summary */}
+ <TaskStatusSummary tasks={contract.tasks} />
+
+ {/* Stats */}
+ <div className="grid grid-cols-3 gap-4">
+ <StatCard label="Repositories" value={contract.repositories.length} />
+ <StatCard label="Files" value={contract.files.length} />
+ <StatCard label="Tasks" value={contract.tasks.length} />
+ </div>
+
+ {/* Status change */}
+ <div>
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">
+ Status
+ </h3>
+ <div className="flex gap-2">
+ {(["active", "completed", "archived"] as ContractStatus[]).map(
+ (status) => (
+ <button
+ key={status}
+ onClick={() => onStatusChange(status)}
+ className={`
+ px-3 py-1.5 font-mono text-xs uppercase transition-colors
+ ${
+ contract.status === status
+ ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
+ : "text-[#555] border border-transparent hover:text-[#75aafc]"
+ }
+ `}
+ >
+ {status}
+ </button>
+ )
+ )}
+ </div>
+ </div>
+
+ {/* Metadata */}
+ <div>
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">
+ Details
+ </h3>
+ <div className="space-y-1 font-mono text-xs text-[#555]">
+ <p>Created: {new Date(contract.createdAt).toLocaleString()}</p>
+ <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p>
+ <p>Version: {contract.version}</p>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function StatCard({ label, value }: { label: string; value: number }) {
+ return (
+ <div className="p-3 border border-[rgba(117,170,252,0.2)]">
+ <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div>
+ <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div>
+ </div>
+ );
+}
+
+// Task status summary with progress bar
+function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) {
+ if (tasks.length === 0) return null;
+
+ // Count tasks by status
+ const statusCounts = {
+ done: 0,
+ merged: 0,
+ running: 0,
+ pending: 0,
+ failed: 0,
+ other: 0,
+ };
+
+ for (const task of tasks) {
+ switch (task.status) {
+ case "done":
+ statusCounts.done++;
+ break;
+ case "merged":
+ statusCounts.merged++;
+ break;
+ case "running":
+ case "initializing":
+ case "starting":
+ statusCounts.running++;
+ break;
+ case "pending":
+ statusCounts.pending++;
+ break;
+ case "failed":
+ statusCounts.failed++;
+ break;
+ default:
+ statusCounts.other++;
+ }
+ }
+
+ const completedCount = statusCounts.done + statusCounts.merged;
+ const progressPercent = (completedCount / tasks.length) * 100;
+
+ // Build summary parts
+ const parts: string[] = [];
+ if (completedCount > 0) parts.push(`${completedCount} done`);
+ if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`);
+ if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`);
+ if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`);
+
+ return (
+ <div className="space-y-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Task Progress
+ </h3>
+
+ {/* Progress bar */}
+ <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden">
+ <div
+ className="h-full bg-green-400 transition-all duration-300"
+ style={{ width: `${progressPercent}%` }}
+ />
+ </div>
+
+ {/* Summary text */}
+ <div className="flex items-center justify-between">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {parts.join(", ")}
+ </span>
+ <span className="font-mono text-xs text-[#555]">
+ {completedCount}/{tasks.length} completed
+ </span>
+ </div>
+ </div>
+ );
+}
+
+// Phase color mapping for badges
+const phaseColors: Record<ContractPhase, string> = {
+ research: "bg-purple-500/20 text-purple-400 border-purple-400/30",
+ specify: "bg-blue-500/20 text-blue-400 border-blue-400/30",
+ plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30",
+ execute: "bg-green-500/20 text-green-400 border-green-400/30",
+ review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30",
+};
+
+// Files tab with template creation
+function FilesTab({
+ files,
+ contractId,
+ contractPhase,
+ onSelect,
+ onRefresh,
+}: {
+ files: FileSummary[];
+ contractId: string;
+ contractPhase: ContractPhase;
+ onSelect: (id: string) => void;
+ onRefresh: () => void;
+}) {
+ const [showTemplateModal, setShowTemplateModal] = useState(false);
+ const [templates, setTemplates] = useState<TemplateSummary[]>([]);
+ const [loadingTemplates, setLoadingTemplates] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [fileName, setFileName] = useState("");
+ const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
+
+ // Load templates when modal opens
+ useEffect(() => {
+ if (showTemplateModal) {
+ setLoadingTemplates(true);
+ listTemplates(contractPhase)
+ .then((res) => setTemplates(res.templates))
+ .catch((err) => console.error("Failed to load templates:", err))
+ .finally(() => setLoadingTemplates(false));
+ }
+ }, [showTemplateModal, contractPhase]);
+
+ const handleCreateFromTemplate = useCallback(async () => {
+ if (!fileName.trim() || !selectedTemplateId) return;
+
+ setCreating(true);
+ try {
+ // Get the full template with body
+ const template = await getTemplate(selectedTemplateId);
+
+ // Create the file with contract (files must belong to contracts)
+ await createFile({
+ contractId,
+ name: fileName.trim(),
+ description: template.description,
+ body: template.suggestedBody,
+ });
+
+ // Reset and close
+ setShowTemplateModal(false);
+ setFileName("");
+ setSelectedTemplateId(null);
+ onRefresh();
+ } catch (err) {
+ console.error("Failed to create file from template:", err);
+ } finally {
+ setCreating(false);
+ }
+ }, [fileName, selectedTemplateId, contractId, onRefresh]);
+
+ const handleCloseModal = () => {
+ setShowTemplateModal(false);
+ setFileName("");
+ setSelectedTemplateId(null);
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* Create from template button */}
+ <button
+ onClick={() => setShowTemplateModal(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + Create from Template
+ </button>
+
+ {/* Template Selection Modal */}
+ {showTemplateModal && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col">
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase">
+ Create File from Template
+ </h3>
+ <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}>
+ {contractPhase} phase
+ </span>
+ </div>
+
+ <div className="space-y-4 flex-1 overflow-y-auto">
+ {/* File name input */}
+ <div>
+ <label className="block font-mono text-xs text-[#555] uppercase mb-1">
+ File Name
+ </label>
+ <input
+ type="text"
+ value={fileName}
+ onChange={(e) => setFileName(e.target.value)}
+ placeholder="e.g., Project Requirements"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ autoFocus
+ />
+ </div>
+
+ {/* Template selection */}
+ <div>
+ <label className="block font-mono text-xs text-[#555] uppercase mb-2">
+ Select Template
+ </label>
+ {loadingTemplates ? (
+ <p className="font-mono text-xs text-[#555]">Loading templates...</p>
+ ) : templates.length === 0 ? (
+ <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p>
+ ) : (
+ <div className="space-y-2 max-h-60 overflow-y-auto">
+ {templates.map((template) => (
+ <button
+ key={template.id}
+ onClick={() => setSelectedTemplateId(template.id)}
+ className={`w-full text-left p-3 border transition-colors ${
+ selectedTemplateId === template.id
+ ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]"
+ : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]"
+ }`}
+ >
+ <div className="flex items-center justify-between mb-1">
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {template.name}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ {template.elementCount} elements
+ </span>
+ </div>
+ <p className="font-mono text-xs text-[#555]">
+ {template.description}
+ </p>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]">
+ <button
+ onClick={handleCloseModal}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateFromTemplate}
+ disabled={!fileName.trim() || !selectedTemplateId || creating}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {creating ? "Creating..." : "Create File"}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* File list */}
+ {files.length === 0 ? (
+ <p className="font-mono text-xs text-[#555]">
+ No files in this contract. Create one from a template above.
+ </p>
+ ) : (
+ <div className="space-y-2">
+ {files.map((file) => (
+ <button
+ key={file.id}
+ onClick={() => onSelect(file.id)}
+ className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {file.name}
+ </span>
+ {file.contractPhase && (
+ <span
+ className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${
+ phaseColors[file.contractPhase]
+ }`}
+ title={`Added during ${file.contractPhase} phase`}
+ >
+ {file.contractPhase}
+ </span>
+ )}
+ </div>
+ <span className="font-mono text-[10px] text-[#555]">
+ v{file.version}
+ </span>
+ </div>
+ {file.description && (
+ <p className="font-mono text-xs text-[#555] mt-1 truncate">
+ {file.description}
+ </p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+// Tasks tab - now using TaskTree for supervisor view
+function TasksTab({
+ tasks,
+ repositories,
+ supervisorTaskId,
+ onSelect,
+ onCreate,
+}: {
+ tasks: TaskSummary[];
+ repositories: ContractRepository[];
+ supervisorTaskId: string | null;
+ onSelect: (id: string) => void;
+ onCreate: (name: string, plan: string, repositoryUrl?: string) => void;
+}) {
+ const [isCreating, setIsCreating] = useState(false);
+ const [taskName, setTaskName] = useState("");
+ const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish...");
+
+ // Find primary repository or first ready one
+ const readyRepos = repositories.filter((r) => r.status === "ready");
+ const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0];
+ const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || "");
+
+ const handleCreate = () => {
+ if (!taskName.trim()) return;
+ const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
+ // Get the URL - for remote repos it's repositoryUrl, for local it's the local path
+ const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath;
+ onCreate(taskName.trim(), taskPlan, repoUrl || undefined);
+ setIsCreating(false);
+ setTaskName("");
+ setTaskPlan("# Plan\n\nDescribe what this task should accomplish...");
+ setSelectedRepoId(primaryRepo?.id || "");
+ };
+
+ const handleCancel = () => {
+ setIsCreating(false);
+ setTaskName("");
+ setTaskPlan("# Plan\n\nDescribe what this task should accomplish...");
+ setSelectedRepoId(primaryRepo?.id || "");
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* TaskTree with supervisor view */}
+ <TaskTree
+ tasks={tasks}
+ supervisorTaskId={supervisorTaskId}
+ onSelect={onSelect}
+ />
+
+ {/* Manual task creation (hidden when supervisor exists - supervisor creates tasks) */}
+ {!supervisorTaskId && (
+ <>
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + Create Task Manually
+ </button>
+ </div>
+
+ {/* Create Task Modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Task
+ </h3>
+ <div className="space-y-4">
+ <div>
+ <label className="block font-mono text-xs text-[#555] uppercase mb-1">
+ Name
+ </label>
+ <input
+ type="text"
+ value={taskName}
+ onChange={(e) => setTaskName(e.target.value)}
+ placeholder="Task name"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ autoFocus
+ />
+ </div>
+
+ {/* Repository selection */}
+ {readyRepos.length > 0 && (
+ <div>
+ <label className="block font-mono text-xs text-[#555] uppercase mb-1">
+ Repository
+ </label>
+ <select
+ value={selectedRepoId}
+ onChange={(e) => setSelectedRepoId(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ >
+ <option value="">No repository</option>
+ {readyRepos.map((repo) => (
+ <option key={repo.id} value={repo.id}>
+ {repo.name}
+ {repo.isPrimary ? " (Primary)" : ""}
+ {" - "}
+ {repo.sourceType}
+ </option>
+ ))}
+ </select>
+ </div>
+ )}
+
+ <div>
+ <label className="block font-mono text-xs text-[#555] uppercase mb-1">
+ Plan
+ </label>
+ <textarea
+ value={taskPlan}
+ onChange={(e) => setTaskPlan(e.target.value)}
+ rows={6}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ />
+ </div>
+
+ <div className="flex gap-2 justify-end">
+ <button
+ onClick={handleCancel}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreate}
+ disabled={!taskName.trim()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx
new file mode 100644
index 0000000..3a7b163
--- /dev/null
+++ b/makima/frontend/src/components/contracts/ContractList.tsx
@@ -0,0 +1,176 @@
+import { useState } from "react";
+import type { ContractSummary, ContractStatus } from "../../lib/api";
+import { PhaseBadge } from "./PhaseBadge";
+import { PhaseProgressBarCompact } from "./PhaseProgressBar";
+
+interface ContractListProps {
+ contracts: ContractSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onCreate: () => void;
+ selectedId?: string;
+}
+
+const statusColors: Record<ContractStatus, string> = {
+ active: "text-green-400",
+ completed: "text-blue-400",
+ archived: "text-[#555]",
+};
+
+export function ContractList({
+ contracts,
+ loading,
+ onSelect,
+ onCreate,
+ selectedId,
+}: ContractListProps) {
+ const [filter, setFilter] = useState<ContractStatus | "all">("all");
+
+ const filteredContracts =
+ filter === "all"
+ ? contracts
+ : contracts.filter((c) => c.status === filter);
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center justify-between mb-3">
+ <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
+ Contracts
+ </h2>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New
+ </button>
+ </div>
+
+ {/* Filter tabs */}
+ <div className="flex gap-1">
+ {(["all", "active", "completed", "archived"] as const).map((status) => (
+ <button
+ key={status}
+ onClick={() => setFilter(status)}
+ className={`
+ px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors
+ ${
+ filter === status
+ ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
+ : "text-[#555] hover:text-[#75aafc]"
+ }
+ `}
+ >
+ {status}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Contract list */}
+ <div className="flex-1 overflow-y-auto">
+ {filteredContracts.length === 0 ? (
+ <div className="p-4 text-center">
+ <p className="font-mono text-sm text-[#555]">
+ {filter === "all"
+ ? "No contracts yet"
+ : `No ${filter} contracts`}
+ </p>
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {filteredContracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => onSelect(contract.id)}
+ className={`
+ w-full text-left p-4 transition-colors
+ ${
+ selectedId === contract.id
+ ? "bg-[rgba(117,170,252,0.1)]"
+ : "hover:bg-[rgba(117,170,252,0.05)]"
+ }
+ `}
+ >
+ <div className="flex items-start justify-between gap-2 mb-2">
+ <h3 className="font-mono text-sm text-[#dbe7ff] truncate">
+ {contract.name}
+ </h3>
+ <span
+ className={`text-[10px] font-mono uppercase ${
+ statusColors[contract.status]
+ }`}
+ >
+ {contract.status}
+ </span>
+ </div>
+
+ {contract.description && (
+ <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2">
+ {contract.description}
+ </p>
+ )}
+
+ <div className="flex items-center justify-between">
+ <PhaseProgressBarCompact currentPhase={contract.phase} />
+ <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]">
+ {contract.fileCount > 0 && (
+ <span>{contract.fileCount} files</span>
+ )}
+ {contract.taskCount > 0 && (
+ <span>{contract.taskCount} tasks</span>
+ )}
+ {contract.repositoryCount > 0 && (
+ <span>{contract.repositoryCount} repos</span>
+ )}
+ </div>
+ </div>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function ContractCard({
+ contract,
+ onClick,
+}: {
+ contract: ContractSummary;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ className="w-full text-left p-4 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-2 mb-2">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">{contract.name}</h3>
+ <PhaseBadge phase={contract.phase} />
+ </div>
+
+ {contract.description && (
+ <p className="font-mono text-xs text-[#555] mb-3 line-clamp-2">
+ {contract.description}
+ </p>
+ )}
+
+ <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]">
+ <span>{contract.fileCount} files</span>
+ <span>{contract.taskCount} tasks</span>
+ <span>{contract.repositoryCount} repos</span>
+ </div>
+ </button>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/PhaseBadge.tsx b/makima/frontend/src/components/contracts/PhaseBadge.tsx
new file mode 100644
index 0000000..0f46b9b
--- /dev/null
+++ b/makima/frontend/src/components/contracts/PhaseBadge.tsx
@@ -0,0 +1,54 @@
+import type { ContractPhase } from "../../lib/api";
+
+interface PhaseBadgeProps {
+ phase: ContractPhase;
+ size?: "sm" | "md";
+}
+
+const phaseConfig: Record<
+ ContractPhase,
+ { label: string; color: string; bgColor: string }
+> = {
+ research: {
+ label: "Research",
+ color: "text-purple-400",
+ bgColor: "bg-purple-400/10 border-purple-400/30",
+ },
+ specify: {
+ label: "Specify",
+ color: "text-blue-400",
+ bgColor: "bg-blue-400/10 border-blue-400/30",
+ },
+ plan: {
+ label: "Plan",
+ color: "text-cyan-400",
+ bgColor: "bg-cyan-400/10 border-cyan-400/30",
+ },
+ execute: {
+ label: "Execute",
+ color: "text-yellow-400",
+ bgColor: "bg-yellow-400/10 border-yellow-400/30",
+ },
+ review: {
+ label: "Review",
+ color: "text-green-400",
+ bgColor: "bg-green-400/10 border-green-400/30",
+ },
+};
+
+export function PhaseBadge({ phase, size = "sm" }: PhaseBadgeProps) {
+ const config = phaseConfig[phase];
+ const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
+
+ return (
+ <span
+ className={`${sizeClasses} ${config.color} ${config.bgColor} border font-mono uppercase tracking-wider`}
+ >
+ {config.label}
+ </span>
+ );
+}
+
+export function getPhaseLabel(phase: ContractPhase): string {
+ return phaseConfig[phase].label;
+}
diff --git a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx
new file mode 100644
index 0000000..da5025b
--- /dev/null
+++ b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx
@@ -0,0 +1,301 @@
+import { useMemo } from "react";
+import type { ContractWithRelations, ContractPhase } from "../../lib/api";
+
+// Phase deliverables configuration (mirrors backend phase_guidance.rs)
+interface RecommendedFile {
+ templateId: string;
+ name: string;
+ priority: "required" | "recommended" | "optional";
+ description: string;
+}
+
+interface PhaseDeliverables {
+ phase: ContractPhase;
+ files: RecommendedFile[];
+ requiresRepository: boolean;
+ requiresTasks: boolean;
+ guidance: string;
+}
+
+const PHASE_DELIVERABLES: Record<ContractPhase, PhaseDeliverables> = {
+ research: {
+ phase: "research",
+ files: [
+ { templateId: "research-notes", name: "Research Notes", priority: "recommended", description: "Document findings and insights" },
+ { templateId: "competitor-analysis", name: "Competitor Analysis", priority: "recommended", description: "Analyze competitors" },
+ { templateId: "user-research", name: "User Research", priority: "optional", description: "User interviews and personas" },
+ ],
+ requiresRepository: false,
+ requiresTasks: false,
+ guidance: "Gather information and document findings before moving to Specify.",
+ },
+ specify: {
+ phase: "specify",
+ files: [
+ { templateId: "requirements", name: "Requirements Document", priority: "required", description: "Functional and non-functional requirements" },
+ { templateId: "user-stories", name: "User Stories", priority: "recommended", description: "Features from user perspective" },
+ { templateId: "acceptance-criteria", name: "Acceptance Criteria", priority: "recommended", description: "Testable conditions for completion" },
+ ],
+ requiresRepository: false,
+ requiresTasks: false,
+ guidance: "Define clear requirements and acceptance criteria.",
+ },
+ plan: {
+ phase: "plan",
+ files: [
+ { templateId: "architecture", name: "Architecture Document", priority: "recommended", description: "System architecture and design" },
+ { templateId: "task-breakdown", name: "Task Breakdown", priority: "required", description: "Work broken into tasks" },
+ { templateId: "technical-design", name: "Technical Design", priority: "optional", description: "Detailed technical specs" },
+ ],
+ requiresRepository: true,
+ requiresTasks: false,
+ guidance: "Design the solution and create a task breakdown. Configure a repository.",
+ },
+ execute: {
+ phase: "execute",
+ files: [
+ { templateId: "dev-notes", name: "Development Notes", priority: "recommended", description: "Implementation details" },
+ { templateId: "test-plan", name: "Test Plan", priority: "optional", description: "Testing strategy" },
+ { templateId: "implementation-log", name: "Implementation Log", priority: "optional", description: "Progress log" },
+ ],
+ requiresRepository: true,
+ requiresTasks: true,
+ guidance: "Execute tasks and track implementation progress.",
+ },
+ review: {
+ phase: "review",
+ files: [
+ { templateId: "release-notes", name: "Release Notes", priority: "required", description: "Changes for release" },
+ { templateId: "review-checklist", name: "Review Checklist", priority: "recommended", description: "Code and feature review" },
+ { templateId: "retrospective", name: "Retrospective", priority: "optional", description: "Project learnings" },
+ ],
+ requiresRepository: false,
+ requiresTasks: false,
+ guidance: "Review work and document the release.",
+ },
+};
+
+interface DeliverableStatus {
+ templateId: string;
+ name: string;
+ priority: "required" | "recommended" | "optional";
+ description: string;
+ completed: boolean;
+ fileId?: string;
+ actualName?: string;
+}
+
+interface PhaseDeliverablesProps {
+ contract: ContractWithRelations;
+ onCreateFile?: (templateId: string, suggestedName: string) => void;
+}
+
+export function PhaseDeliverablesPanel({ contract, onCreateFile }: PhaseDeliverablesProps) {
+ const deliverables = PHASE_DELIVERABLES[contract.phase];
+
+ // Calculate deliverable status
+ const fileStatuses = useMemo((): DeliverableStatus[] => {
+ return deliverables.files.map((rec) => {
+ // Find matching file by name similarity
+ const matchedFile = contract.files.find((f) => {
+ const nameLower = f.name.toLowerCase();
+ const recLower = rec.name.toLowerCase();
+ return (
+ f.contractPhase === contract.phase &&
+ (nameLower.includes(recLower) || recLower.includes(nameLower) || nameLower.includes(rec.templateId.replace("-", " ")))
+ );
+ });
+
+ return {
+ ...rec,
+ completed: !!matchedFile,
+ fileId: matchedFile?.id,
+ actualName: matchedFile?.name,
+ };
+ });
+ }, [contract.files, contract.phase, deliverables.files]);
+
+ // Check repository status
+ const hasRepository = contract.repositories.length > 0;
+
+ // Check task status
+ const taskStats = useMemo(() => {
+ const total = contract.tasks.length;
+ const done = contract.tasks.filter((t) => t.status === "done" || t.status === "merged").length;
+ const pending = contract.tasks.filter((t) => t.status === "pending").length;
+ const running = contract.tasks.filter((t) => ["running", "initializing", "starting"].includes(t.status)).length;
+ const failed = contract.tasks.filter((t) => t.status === "failed").length;
+ return { total, done, pending, running, failed };
+ }, [contract.tasks]);
+
+ // Calculate completion percentage
+ const completionPercent = useMemo(() => {
+ let completed = 0;
+ let total = 0;
+
+ // Count required and recommended files
+ fileStatuses.forEach((s) => {
+ if (s.priority !== "optional") {
+ total++;
+ if (s.completed) completed++;
+ }
+ });
+
+ // Count repository if required
+ if (deliverables.requiresRepository) {
+ total++;
+ if (hasRepository) completed++;
+ }
+
+ // Count tasks if in execute phase
+ if (deliverables.requiresTasks && taskStats.total > 0) {
+ total++;
+ if (taskStats.done === taskStats.total) completed++;
+ }
+
+ return total > 0 ? Math.round((completed / total) * 100) : 100;
+ }, [fileStatuses, hasRepository, deliverables, taskStats]);
+
+ const priorityColors = {
+ required: "text-red-400",
+ recommended: "text-yellow-400",
+ optional: "text-[#555]",
+ };
+
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Phase Deliverables
+ </h3>
+ <div className="flex items-center gap-2">
+ <div className="w-24 h-1.5 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden">
+ <div
+ className={`h-full transition-all duration-300 ${
+ completionPercent === 100 ? "bg-green-400" : "bg-[#75aafc]"
+ }`}
+ style={{ width: `${completionPercent}%` }}
+ />
+ </div>
+ <span className="font-mono text-[10px] text-[#555]">{completionPercent}%</span>
+ </div>
+ </div>
+
+ {/* Guidance text */}
+ <p className="font-mono text-xs text-[#555] italic">{deliverables.guidance}</p>
+
+ {/* File deliverables */}
+ <div className="space-y-2">
+ {fileStatuses.map((status) => (
+ <div
+ key={status.templateId}
+ className={`flex items-center justify-between p-2 border ${
+ status.completed
+ ? "border-green-400/20 bg-green-400/5"
+ : "border-[rgba(117,170,252,0.15)]"
+ }`}
+ >
+ <div className="flex items-center gap-2">
+ <span
+ className={`font-mono text-xs ${
+ status.completed ? "text-green-400" : "text-[#555]"
+ }`}
+ >
+ {status.completed ? "[+]" : "[ ]"}
+ </span>
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-xs text-[#dbe7ff]">
+ {status.completed ? status.actualName : status.name}
+ </span>
+ {!status.completed && (
+ <span className={`font-mono text-[9px] uppercase ${priorityColors[status.priority]}`}>
+ {status.priority}
+ </span>
+ )}
+ </div>
+ <span className="font-mono text-[10px] text-[#555]">
+ {status.description}
+ </span>
+ </div>
+ </div>
+ {!status.completed && onCreateFile && (
+ <button
+ onClick={() => onCreateFile(status.templateId, status.name)}
+ className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Create
+ </button>
+ )}
+ </div>
+ ))}
+ </div>
+
+ {/* Repository status */}
+ {deliverables.requiresRepository && (
+ <div
+ className={`flex items-center gap-2 p-2 border ${
+ hasRepository
+ ? "border-green-400/20 bg-green-400/5"
+ : "border-[rgba(117,170,252,0.15)]"
+ }`}
+ >
+ <span
+ className={`font-mono text-xs ${
+ hasRepository ? "text-green-400" : "text-[#555]"
+ }`}
+ >
+ {hasRepository ? "[+]" : "[ ]"}
+ </span>
+ <div>
+ <span className="font-mono text-xs text-[#dbe7ff]">
+ Repository Configured
+ </span>
+ {!hasRepository && (
+ <span className="font-mono text-[9px] uppercase text-red-400 ml-2">
+ required
+ </span>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Task status (execute phase) */}
+ {deliverables.requiresTasks && (
+ <div
+ className={`flex items-center justify-between p-2 border ${
+ taskStats.total > 0 && taskStats.done === taskStats.total
+ ? "border-green-400/20 bg-green-400/5"
+ : "border-[rgba(117,170,252,0.15)]"
+ }`}
+ >
+ <div className="flex items-center gap-2">
+ <span
+ className={`font-mono text-xs ${
+ taskStats.total > 0 && taskStats.done === taskStats.total
+ ? "text-green-400"
+ : "text-[#555]"
+ }`}
+ >
+ {taskStats.total > 0 && taskStats.done === taskStats.total ? "[+]" : "[ ]"}
+ </span>
+ <span className="font-mono text-xs text-[#dbe7ff]">
+ Tasks Completed
+ </span>
+ </div>
+ {taskStats.total > 0 ? (
+ <span className="font-mono text-[10px] text-[#9bc3ff]">
+ {taskStats.done}/{taskStats.total}
+ {taskStats.running > 0 && ` (${taskStats.running} running)`}
+ {taskStats.failed > 0 && (
+ <span className="text-red-400"> ({taskStats.failed} failed)</span>
+ )}
+ </span>
+ ) : (
+ <span className="font-mono text-[10px] text-[#555]">No tasks yet</span>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/PhaseHint.tsx b/makima/frontend/src/components/contracts/PhaseHint.tsx
new file mode 100644
index 0000000..95573ed
--- /dev/null
+++ b/makima/frontend/src/components/contracts/PhaseHint.tsx
@@ -0,0 +1,90 @@
+import type { ContractPhase, ContractWithRelations } from "../../lib/api";
+
+interface PhaseHintProps {
+ contract: ContractWithRelations;
+ onAdvancePhase: (phase: ContractPhase) => void;
+}
+
+const phaseOrder: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
+
+interface HintConfig {
+ condition: (contract: ContractWithRelations) => boolean;
+ message: (contract: ContractWithRelations) => string;
+ nextPhase: ContractPhase;
+}
+
+const phaseHints: Record<ContractPhase, HintConfig | null> = {
+ research: {
+ condition: (c) => c.files.length >= 1,
+ message: (c) =>
+ `You have ${c.files.length} file${c.files.length === 1 ? "" : "s"}. Ready to specify requirements?`,
+ nextPhase: "specify",
+ },
+ specify: {
+ condition: (c) => c.files.length >= 2,
+ message: () => "Spec files ready. Create implementation plan?",
+ nextPhase: "plan",
+ },
+ plan: {
+ condition: (c) => c.files.length >= 1 && c.repositories.length >= 1,
+ message: () => "Plan documented. Ready to create tasks?",
+ nextPhase: "execute",
+ },
+ execute: {
+ condition: (c) => {
+ // Show hint only when all tasks are complete
+ const doneTasks = c.tasks.filter(
+ (t) => t.status === "done" || t.status === "merged"
+ );
+ return c.tasks.length > 0 && doneTasks.length === c.tasks.length;
+ },
+ message: (c) => {
+ const doneTasks = c.tasks.filter(
+ (t) => t.status === "done" || t.status === "merged"
+ );
+ return `All ${doneTasks.length} task${doneTasks.length === 1 ? "" : "s"} complete. Ready for review?`;
+ },
+ nextPhase: "review",
+ },
+ review: null, // No hint for review phase - it's the final phase
+};
+
+export function PhaseHint({ contract, onAdvancePhase }: PhaseHintProps) {
+ const hintConfig = phaseHints[contract.phase];
+
+ // No hint for this phase
+ if (!hintConfig) return null;
+
+ // Condition not met
+ if (!hintConfig.condition(contract)) return null;
+
+ return (
+ <div className="flex items-center gap-3 p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {hintConfig.message(contract)}
+ </span>
+ <button
+ onClick={() => onAdvancePhase(hintConfig.nextPhase)}
+ className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase whitespace-nowrap"
+ >
+ Advance to {hintConfig.nextPhase}
+ </button>
+ </div>
+ );
+}
+
+export function getNextPhase(currentPhase: ContractPhase): ContractPhase | null {
+ const currentIndex = phaseOrder.indexOf(currentPhase);
+ if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) {
+ return null;
+ }
+ return phaseOrder[currentIndex + 1];
+}
+
+export function getPreviousPhase(currentPhase: ContractPhase): ContractPhase | null {
+ const currentIndex = phaseOrder.indexOf(currentPhase);
+ if (currentIndex <= 0) {
+ return null;
+ }
+ return phaseOrder[currentIndex - 1];
+}
diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx
new file mode 100644
index 0000000..5ee7999
--- /dev/null
+++ b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx
@@ -0,0 +1,142 @@
+import type { ContractPhase } from "../../lib/api";
+
+interface PhaseProgressBarProps {
+ currentPhase: ContractPhase;
+ onPhaseClick?: (phase: ContractPhase) => void;
+ readonly?: boolean;
+}
+
+const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
+
+const phaseLabels: Record<ContractPhase, string> = {
+ research: "Research",
+ specify: "Specify",
+ plan: "Plan",
+ execute: "Execute",
+ review: "Review",
+};
+
+const phaseColors: Record<ContractPhase, { active: string; inactive: string; completed: string }> = {
+ research: {
+ active: "bg-purple-400 border-purple-400",
+ inactive: "bg-transparent border-purple-400/30",
+ completed: "bg-purple-400/50 border-purple-400/50",
+ },
+ specify: {
+ active: "bg-blue-400 border-blue-400",
+ inactive: "bg-transparent border-blue-400/30",
+ completed: "bg-blue-400/50 border-blue-400/50",
+ },
+ plan: {
+ active: "bg-cyan-400 border-cyan-400",
+ inactive: "bg-transparent border-cyan-400/30",
+ completed: "bg-cyan-400/50 border-cyan-400/50",
+ },
+ execute: {
+ active: "bg-yellow-400 border-yellow-400",
+ inactive: "bg-transparent border-yellow-400/30",
+ completed: "bg-yellow-400/50 border-yellow-400/50",
+ },
+ review: {
+ active: "bg-green-400 border-green-400",
+ inactive: "bg-transparent border-green-400/30",
+ completed: "bg-green-400/50 border-green-400/50",
+ },
+};
+
+export function PhaseProgressBar({
+ currentPhase,
+ onPhaseClick,
+ readonly = false,
+}: PhaseProgressBarProps) {
+ const currentIndex = phases.indexOf(currentPhase);
+
+ return (
+ <div className="flex items-center gap-1">
+ {phases.map((phase, index) => {
+ const isActive = phase === currentPhase;
+ const isCompleted = index < currentIndex;
+ const colors = phaseColors[phase];
+ const colorClass = isActive
+ ? colors.active
+ : isCompleted
+ ? colors.completed
+ : colors.inactive;
+
+ const canClick = !readonly && onPhaseClick;
+
+ return (
+ <div key={phase} className="flex items-center">
+ {/* Phase node */}
+ <button
+ onClick={() => canClick && onPhaseClick(phase)}
+ disabled={readonly}
+ className={`
+ relative group flex flex-col items-center
+ ${canClick ? "cursor-pointer" : "cursor-default"}
+ `}
+ >
+ {/* Circle */}
+ <div
+ className={`
+ w-3 h-3 rounded-full border-2 transition-all
+ ${colorClass}
+ ${canClick && !isActive ? "hover:scale-110" : ""}
+ `}
+ />
+ {/* Label */}
+ <span
+ className={`
+ absolute top-4 font-mono text-[9px] uppercase tracking-wide whitespace-nowrap
+ ${isActive ? "text-[#dbe7ff]" : "text-[#555]"}
+ ${canClick && !isActive ? "group-hover:text-[#75aafc]" : ""}
+ `}
+ >
+ {phaseLabels[phase]}
+ </span>
+ </button>
+
+ {/* Connector line */}
+ {index < phases.length - 1 && (
+ <div
+ className={`
+ w-8 h-0.5 mx-1
+ ${index < currentIndex ? "bg-[#3f6fb3]" : "bg-[rgba(117,170,252,0.15)]"}
+ `}
+ />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ );
+}
+
+export function PhaseProgressBarCompact({
+ currentPhase,
+}: {
+ currentPhase: ContractPhase;
+}) {
+ const currentIndex = phases.indexOf(currentPhase);
+
+ return (
+ <div className="flex items-center gap-0.5">
+ {phases.map((phase, index) => {
+ const isActive = phase === currentPhase;
+ const isCompleted = index < currentIndex;
+ const colors = phaseColors[phase];
+
+ return (
+ <div
+ key={phase}
+ className={`
+ w-2 h-2 rounded-full border
+ ${isActive ? colors.active : isCompleted ? colors.completed : colors.inactive}
+ `}
+ title={phaseLabels[phase]}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/QuickActionButtons.tsx b/makima/frontend/src/components/contracts/QuickActionButtons.tsx
new file mode 100644
index 0000000..4dbb90c
--- /dev/null
+++ b/makima/frontend/src/components/contracts/QuickActionButtons.tsx
@@ -0,0 +1,217 @@
+import { useCallback } from "react";
+
+export type QuickActionType =
+ | "create_file"
+ | "create_task"
+ | "run_task"
+ | "advance_phase"
+ | "derive_tasks"
+ | "update_file";
+
+export interface QuickAction {
+ type: QuickActionType;
+ label: string;
+ description?: string;
+ data?: Record<string, unknown>;
+}
+
+interface QuickActionButtonsProps {
+ actions: QuickAction[];
+ onAction: (action: QuickAction) => void;
+ loading?: boolean;
+}
+
+const ACTION_ICONS: Record<QuickActionType, string> = {
+ create_file: "[+]",
+ create_task: "[T]",
+ run_task: "[>]",
+ advance_phase: "[→]",
+ derive_tasks: "[≡]",
+ update_file: "[*]",
+};
+
+const ACTION_COLORS: Record<QuickActionType, string> = {
+ create_file: "border-blue-400/30 hover:border-blue-400/60 text-blue-400",
+ create_task: "border-green-400/30 hover:border-green-400/60 text-green-400",
+ run_task: "border-yellow-400/30 hover:border-yellow-400/60 text-yellow-400",
+ advance_phase: "border-purple-400/30 hover:border-purple-400/60 text-purple-400",
+ derive_tasks: "border-cyan-400/30 hover:border-cyan-400/60 text-cyan-400",
+ update_file: "border-orange-400/30 hover:border-orange-400/60 text-orange-400",
+};
+
+export function QuickActionButtons({
+ actions,
+ onAction,
+ loading = false,
+}: QuickActionButtonsProps) {
+ const handleClick = useCallback(
+ (action: QuickAction) => {
+ if (!loading) {
+ onAction(action);
+ }
+ },
+ [onAction, loading]
+ );
+
+ if (actions.length === 0) return null;
+
+ return (
+ <div className="flex flex-wrap gap-2 mt-2">
+ {actions.map((action, index) => (
+ <button
+ key={`${action.type}-${index}`}
+ onClick={() => handleClick(action)}
+ disabled={loading}
+ className={`
+ flex items-center gap-1.5 px-2 py-1
+ font-mono text-[10px] uppercase
+ border transition-colors
+ disabled:opacity-50 disabled:cursor-not-allowed
+ ${ACTION_COLORS[action.type]}
+ `}
+ title={action.description}
+ >
+ <span>{ACTION_ICONS[action.type]}</span>
+ <span>{action.label}</span>
+ </button>
+ ))}
+ </div>
+ );
+}
+
+/**
+ * Parse tool call results to extract suggested quick actions.
+ * This is used by ContractCliInput to detect actionable results.
+ */
+export function parseActionsFromToolCalls(
+ toolCalls: { name: string; success: boolean; message: string }[]
+): QuickAction[] {
+ const actions: QuickAction[] = [];
+
+ for (const tc of toolCalls) {
+ if (!tc.success) continue;
+
+ switch (tc.name) {
+ case "derive_tasks_from_file":
+ // When tasks are parsed, offer to create them
+ if (tc.message.includes("task") || tc.message.includes("Found")) {
+ actions.push({
+ type: "derive_tasks",
+ label: "Review & Create Tasks",
+ description: "Review parsed tasks and create them with chaining",
+ });
+ }
+ break;
+
+ case "process_task_completion":
+ // Check for suggested actions in the result
+ if (tc.message.includes("next task")) {
+ actions.push({
+ type: "run_task",
+ label: "Run Next Task",
+ description: "Continue with the next chained task",
+ });
+ }
+ if (tc.message.includes("advance") || tc.message.includes("phase")) {
+ actions.push({
+ type: "advance_phase",
+ label: "Advance Phase",
+ description: "Move to the next contract phase",
+ });
+ }
+ break;
+
+ case "get_phase_checklist":
+ // When checklist shows missing items, offer to create them
+ if (tc.message.includes("missing") || tc.message.includes("not created")) {
+ actions.push({
+ type: "create_file",
+ label: "Create Missing Files",
+ description: "Create files from recommended templates",
+ });
+ }
+ break;
+
+ case "advance_phase":
+ // After phase transition, suggest creating files
+ actions.push({
+ type: "create_file",
+ label: "Create Phase Files",
+ description: "Create recommended files for this phase",
+ });
+ break;
+ }
+ }
+
+ return actions;
+}
+
+/**
+ * Parse LLM response text to detect suggested actions.
+ * Used as a fallback when structured action data isn't available.
+ */
+export function parseActionsFromText(text: string): QuickAction[] {
+ const actions: QuickAction[] = [];
+ const lower = text.toLowerCase();
+
+ // Detect file creation suggestions
+ if (
+ lower.includes("create a file") ||
+ lower.includes("create the file") ||
+ lower.includes("should i create")
+ ) {
+ actions.push({
+ type: "create_file",
+ label: "Create File",
+ description: "Create the suggested file",
+ });
+ }
+
+ // Detect task creation suggestions
+ if (
+ lower.includes("create tasks") ||
+ lower.includes("create these tasks") ||
+ lower.includes("create chained tasks")
+ ) {
+ actions.push({
+ type: "create_task",
+ label: "Create Tasks",
+ description: "Create the suggested tasks",
+ });
+ }
+
+ // Detect phase advancement suggestions
+ if (
+ lower.includes("advance to") ||
+ lower.includes("ready to move to") ||
+ lower.includes("transition to")
+ ) {
+ const phases = ["specify", "plan", "execute", "review"];
+ for (const phase of phases) {
+ if (lower.includes(phase)) {
+ actions.push({
+ type: "advance_phase",
+ label: `Advance to ${phase.charAt(0).toUpperCase() + phase.slice(1)}`,
+ description: `Move to the ${phase} phase`,
+ data: { phase },
+ });
+ break;
+ }
+ }
+ }
+
+ // Detect run task suggestions
+ if (
+ lower.includes("run the task") ||
+ lower.includes("start the task") ||
+ lower.includes("run task")
+ ) {
+ actions.push({
+ type: "run_task",
+ label: "Run Task",
+ description: "Start the suggested task",
+ });
+ }
+
+ return actions;
+}
diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx
new file mode 100644
index 0000000..4170cfb
--- /dev/null
+++ b/makima/frontend/src/components/contracts/RepositoryPanel.tsx
@@ -0,0 +1,260 @@
+import { useState, useEffect } from "react";
+import type {
+ ContractRepository,
+ RepositorySourceType,
+ RepositoryStatus,
+ DaemonDirectory,
+} from "../../lib/api";
+import { getDaemonDirectories } from "../../lib/api";
+import { DirectoryInput } from "../mesh/DirectoryInput";
+
+interface RepositoryPanelProps {
+ repositories: ContractRepository[];
+ onAddRemote: (name: string, url: string, isPrimary: boolean) => void;
+ onAddLocal: (name: string, path: string, isPrimary: boolean) => void;
+ onCreateManaged: (name: string, isPrimary: boolean) => void;
+ onDelete: (repoId: string) => void;
+ onSetPrimary: (repoId: string) => void;
+}
+
+type AddMode = "remote" | "local" | "managed" | null;
+
+const sourceTypeLabels: Record<RepositorySourceType, string> = {
+ remote: "Remote",
+ local: "Local",
+ managed: "Managed",
+};
+
+const sourceTypeIcons: Record<RepositorySourceType, string> = {
+ remote: "GH",
+ local: "FS",
+ managed: "MK",
+};
+
+const statusColors: Record<RepositoryStatus, string> = {
+ ready: "text-green-400",
+ pending: "text-yellow-400",
+ creating: "text-cyan-400",
+ failed: "text-red-400",
+};
+
+export function RepositoryPanel({
+ repositories,
+ onAddRemote,
+ onAddLocal,
+ onCreateManaged,
+ onDelete,
+ onSetPrimary,
+}: RepositoryPanelProps) {
+ const [addMode, setAddMode] = useState<AddMode>(null);
+ const [name, setName] = useState("");
+ const [url, setUrl] = useState("");
+ const [path, setPath] = useState("");
+ const [isPrimary, setIsPrimary] = useState(false);
+ // Daemon directory suggestions for local repositories
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+
+ // Fetch daemon directories when "local" mode is selected
+ useEffect(() => {
+ if (addMode === "local") {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [addMode]);
+
+ const handleAdd = () => {
+ if (!name.trim()) return;
+
+ if (addMode === "remote" && url.trim()) {
+ onAddRemote(name.trim(), url.trim(), isPrimary);
+ } else if (addMode === "local" && path.trim()) {
+ onAddLocal(name.trim(), path.trim(), isPrimary);
+ } else if (addMode === "managed") {
+ onCreateManaged(name.trim(), isPrimary);
+ }
+
+ // Reset form
+ setAddMode(null);
+ setName("");
+ setUrl("");
+ setPath("");
+ setIsPrimary(false);
+ };
+
+ const handleCancel = () => {
+ setAddMode(null);
+ setName("");
+ setUrl("");
+ setPath("");
+ setIsPrimary(false);
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* Repository list */}
+ {repositories.length === 0 ? (
+ <p className="font-mono text-xs text-[#555]">
+ No repositories configured
+ </p>
+ ) : (
+ <div className="space-y-2">
+ {repositories.map((repo) => (
+ <div
+ key={repo.id}
+ className="flex items-center gap-3 p-3 border border-[rgba(117,170,252,0.2)]"
+ >
+ {/* Type icon */}
+ <span className="font-mono text-[10px] text-[#555] uppercase w-6">
+ {sourceTypeIcons[repo.sourceType]}
+ </span>
+
+ {/* Name and details */}
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-sm text-[#dbe7ff] truncate">
+ {repo.name}
+ </span>
+ {repo.isPrimary && (
+ <span className="px-1 py-0.5 text-[8px] font-mono uppercase bg-[rgba(117,170,252,0.1)] text-[#75aafc] border border-[rgba(117,170,252,0.3)]">
+ Primary
+ </span>
+ )}
+ </div>
+ <div className="font-mono text-[10px] text-[#555] truncate">
+ {repo.repositoryUrl || repo.localPath || "(pending creation)"}
+ </div>
+ </div>
+
+ {/* Status */}
+ <span
+ className={`font-mono text-[10px] uppercase ${
+ statusColors[repo.status]
+ }`}
+ >
+ {repo.status}
+ </span>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1">
+ {!repo.isPrimary && repo.status === "ready" && (
+ <button
+ onClick={() => onSetPrimary(repo.id)}
+ className="p-1 font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Set as primary"
+ >
+ *
+ </button>
+ )}
+ <button
+ onClick={() => onDelete(repo.id)}
+ className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors"
+ title="Remove"
+ >
+ x
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Add repository form */}
+ {addMode ? (
+ <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3">
+ <div className="flex items-center gap-2 mb-2">
+ <span className="font-mono text-xs text-[#75aafc] uppercase">
+ Add {sourceTypeLabels[addMode]} Repository
+ </span>
+ </div>
+
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ placeholder="Repository name"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
+ />
+
+ {addMode === "remote" && (
+ <input
+ type="text"
+ value={url}
+ onChange={(e) => setUrl(e.target.value)}
+ placeholder="https://github.com/owner/repo"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]"
+ />
+ )}
+
+ {addMode === "local" && (
+ <DirectoryInput
+ value={path}
+ onChange={setPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/repository"
+ />
+ )}
+
+ {addMode === "managed" && (
+ <p className="font-mono text-xs text-[#555]">
+ Makima will create this repository via the daemon.
+ </p>
+ )}
+
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={isPrimary}
+ onChange={(e) => setIsPrimary(e.target.checked)}
+ className="w-3 h-3"
+ />
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ Set as primary repository
+ </span>
+ </label>
+
+ <div className="flex gap-2">
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1.5 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleAdd}
+ disabled={
+ !name.trim() ||
+ (addMode === "remote" && !url.trim()) ||
+ (addMode === "local" && !path.trim())
+ }
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Add Repository
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className="flex gap-2">
+ <button
+ onClick={() => setAddMode("remote")}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + Remote
+ </button>
+ <button
+ onClick={() => setAddMode("local")}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + Local
+ </button>
+ <button
+ onClick={() => setAddMode("managed")}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + Managed
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx
new file mode 100644
index 0000000..07421ef
--- /dev/null
+++ b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx
@@ -0,0 +1,221 @@
+import { useState, useCallback } from "react";
+
+export interface ParsedTask {
+ name: string;
+ description?: string;
+ group?: string;
+ order: number;
+ completed: boolean;
+ dependencies: string[];
+}
+
+interface TaskDerivationPreviewProps {
+ tasks: ParsedTask[];
+ groups: string[];
+ fileName: string;
+ onCreateTasks: (selectedTasks: ParsedTask[]) => void;
+ onCancel: () => void;
+ loading?: boolean;
+}
+
+export function TaskDerivationPreview({
+ tasks,
+ groups,
+ fileName,
+ onCreateTasks,
+ onCancel,
+ loading = false,
+}: TaskDerivationPreviewProps) {
+ const [selectedIndices, setSelectedIndices] = useState<Set<number>>(
+ new Set(tasks.map((_, i) => i)) // Select all by default
+ );
+
+ const toggleTask = useCallback((index: number) => {
+ setSelectedIndices((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(index)) {
+ newSet.delete(index);
+ } else {
+ newSet.add(index);
+ }
+ return newSet;
+ });
+ }, []);
+
+ const selectAll = useCallback(() => {
+ setSelectedIndices(new Set(tasks.map((_, i) => i)));
+ }, [tasks]);
+
+ const selectNone = useCallback(() => {
+ setSelectedIndices(new Set());
+ }, []);
+
+ const handleCreate = useCallback(() => {
+ const selectedTasks = tasks.filter((_, i) => selectedIndices.has(i));
+ onCreateTasks(selectedTasks);
+ }, [tasks, selectedIndices, onCreateTasks]);
+
+ // Group tasks by their group property
+ const tasksByGroup = tasks.reduce((acc, task, index) => {
+ const groupKey = task.group || "Ungrouped";
+ if (!acc[groupKey]) {
+ acc[groupKey] = [];
+ }
+ acc[groupKey].push({ task, index });
+ return acc;
+ }, {} as Record<string, { task: ParsedTask; index: number }[]>);
+
+ const selectedCount = selectedIndices.size;
+ const totalCount = tasks.length;
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-2xl p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col">
+ {/* Header */}
+ <div className="flex items-center justify-between mb-4">
+ <div>
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase">
+ Create Tasks from Document
+ </h3>
+ <p className="font-mono text-xs text-[#555] mt-1">
+ Source: {fileName}
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={selectAll}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Select All
+ </button>
+ <span className="text-[#555]">|</span>
+ <button
+ onClick={selectNone}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Select None
+ </button>
+ </div>
+ </div>
+
+ {/* Task List */}
+ <div className="flex-1 overflow-y-auto space-y-4 mb-4">
+ {groups.length > 0 ? (
+ // Grouped view
+ Object.entries(tasksByGroup).map(([groupName, groupTasks]) => (
+ <div key={groupName} className="space-y-2">
+ <h4 className="font-mono text-xs text-[#9bc3ff] uppercase border-b border-[rgba(117,170,252,0.2)] pb-1">
+ {groupName}
+ </h4>
+ {groupTasks.map(({ task, index }) => (
+ <TaskItem
+ key={index}
+ task={task}
+ index={index}
+ selected={selectedIndices.has(index)}
+ onToggle={() => toggleTask(index)}
+ />
+ ))}
+ </div>
+ ))
+ ) : (
+ // Flat view
+ tasks.map((task, index) => (
+ <TaskItem
+ key={index}
+ task={task}
+ index={index}
+ selected={selectedIndices.has(index)}
+ onToggle={() => toggleTask(index)}
+ />
+ ))
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="flex items-center justify-between pt-4 border-t border-[rgba(117,170,252,0.2)]">
+ <span className="font-mono text-xs text-[#555]">
+ {selectedCount} of {totalCount} tasks selected
+ </span>
+ <div className="flex gap-2">
+ <button
+ onClick={onCancel}
+ disabled={loading}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreate}
+ disabled={loading || selectedCount === 0}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {loading ? "Creating..." : `Create ${selectedCount} Task${selectedCount !== 1 ? "s" : ""}`}
+ </button>
+ </div>
+ </div>
+
+ {/* Chaining info */}
+ {selectedCount > 1 && (
+ <p className="font-mono text-[10px] text-[#555] mt-2 text-center">
+ Tasks will be chained: each task continues from the previous one's work
+ </p>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function TaskItem({
+ task,
+ index,
+ selected,
+ onToggle,
+}: {
+ task: ParsedTask;
+ index: number;
+ selected: boolean;
+ onToggle: () => void;
+}) {
+ return (
+ <button
+ onClick={onToggle}
+ className={`w-full text-left p-3 border transition-colors ${
+ selected
+ ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]"
+ : "border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)]"
+ }`}
+ >
+ <div className="flex items-start gap-2">
+ <span
+ className={`font-mono text-xs mt-0.5 ${
+ selected ? "text-[#75aafc]" : "text-[#555]"
+ }`}
+ >
+ {selected ? "[x]" : "[ ]"}
+ </span>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-[10px] text-[#555]">#{index + 1}</span>
+ <span className="font-mono text-sm text-[#dbe7ff]">{task.name}</span>
+ {task.completed && (
+ <span className="font-mono text-[9px] text-green-400 uppercase">
+ done in source
+ </span>
+ )}
+ </div>
+ {task.description && (
+ <p className="font-mono text-xs text-[#555] mt-1 truncate">
+ {task.description}
+ </p>
+ )}
+ {task.dependencies.length > 0 && (
+ <p className="font-mono text-[10px] text-[#75aafc] mt-1">
+ Depends on: {task.dependencies.join(", ")}
+ </p>
+ )}
+ </div>
+ </div>
+ </button>
+ );
+}
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index cf99fde..c3f402e 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
import { ElementContextMenu } from "./ElementContextMenu";
+import { copyMarkdownToClipboard } from "../../lib/markdown";
interface BodyRendererProps {
elements: BodyElement[];
@@ -42,6 +43,15 @@ export function BodyRenderer({
elementIndex: number;
selectedText?: string;
} | null>(null);
+ const [copiedMarkdown, setCopiedMarkdown] = useState(false);
+
+ const handleCopyMarkdown = async () => {
+ const success = await copyMarkdownToClipboard(elements);
+ if (success) {
+ setCopiedMarkdown(true);
+ setTimeout(() => setCopiedMarkdown(false), 2000);
+ }
+ };
const handleContextMenu = (index: number) => (e: React.MouseEvent) => {
e.preventDefault();
@@ -104,6 +114,31 @@ export function BodyRenderer({
return (
<div className="space-y-1">
+ {/* Markdown Export Toolbar */}
+ <div className="flex justify-end mb-2">
+ <button
+ onClick={handleCopyMarkdown}
+ className="flex items-center gap-1 px-2 py-1 text-[10px] font-mono text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Copy content as markdown"
+ >
+ {copiedMarkdown ? (
+ <>
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ Copied!
+ </>
+ ) : (
+ <>
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
+ <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
+ </svg>
+ Copy as Markdown
+ </>
+ )}
+ </button>
+ </div>
{elements.map((element, index) => (
<div
key={index}
@@ -250,6 +285,20 @@ function BodyElementRenderer({
caption={element.caption}
/>
);
+ case "markdown":
+ return (
+ <MarkdownElement
+ content={element.content}
+ onUpdate={
+ onUpdate
+ ? (content) => onUpdate({ ...element, content })
+ : undefined
+ }
+ onEditingChange={onEditingChange}
+ hasPendingRemoteUpdate={hasPendingRemoteUpdate}
+ onOverwrite={onOverwrite}
+ />
+ );
default:
return null;
}
@@ -621,3 +670,330 @@ function ListElement({
</ListTag>
);
}
+
+/**
+ * Simple inline markdown renderer.
+ * Renders basic markdown syntax to HTML elements.
+ */
+function renderMarkdown(content: string): React.ReactNode {
+ const lines = content.split('\n');
+ const elements: React.ReactNode[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Code blocks
+ if (line.startsWith('```')) {
+ const lang = line.slice(3).trim();
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i].startsWith('```')) {
+ codeLines.push(lines[i]);
+ i++;
+ }
+ i++; // skip closing ```
+ elements.push(
+ <div key={elements.length} className="relative my-2">
+ {lang && (
+ <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]">
+ {lang}
+ </div>
+ )}
+ <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
+ <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
+ {codeLines.join('\n')}
+ </code>
+ </pre>
+ </div>
+ );
+ continue;
+ }
+
+ // Headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ const level = headingMatch[1].length;
+ const text = headingMatch[2];
+ const HeadingTag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
+ const sizeClasses: Record<number, string> = {
+ 1: "text-2xl font-bold",
+ 2: "text-xl font-bold",
+ 3: "text-lg font-semibold",
+ 4: "text-base font-semibold",
+ 5: "text-sm font-semibold",
+ 6: "text-xs font-semibold",
+ };
+ elements.push(
+ <HeadingTag key={elements.length} className={`font-mono text-[#9bc3ff] ${sizeClasses[level]} my-2`}>
+ {renderInlineMarkdown(text)}
+ </HeadingTag>
+ );
+ i++;
+ continue;
+ }
+
+ // Unordered lists
+ if (line.match(/^[-*]\s+/)) {
+ const items: string[] = [];
+ while (i < lines.length && lines[i].match(/^[-*]\s+/)) {
+ items.push(lines[i].replace(/^[-*]\s+/, ''));
+ i++;
+ }
+ elements.push(
+ <ul key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-disc my-2">
+ {items.map((item, idx) => (
+ <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+ ))}
+ </ul>
+ );
+ continue;
+ }
+
+ // Ordered lists
+ if (line.match(/^\d+\.\s+/)) {
+ const items: string[] = [];
+ while (i < lines.length && lines[i].match(/^\d+\.\s+/)) {
+ items.push(lines[i].replace(/^\d+\.\s+/, ''));
+ i++;
+ }
+ elements.push(
+ <ol key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 list-decimal my-2">
+ {items.map((item, idx) => (
+ <li key={idx} className="pl-1">{renderInlineMarkdown(item)}</li>
+ ))}
+ </ol>
+ );
+ continue;
+ }
+
+ // Empty lines
+ if (line.trim() === '') {
+ i++;
+ continue;
+ }
+
+ // Regular paragraphs
+ elements.push(
+ <p key={elements.length} className="font-mono text-sm text-white/80 leading-relaxed my-2">
+ {renderInlineMarkdown(line)}
+ </p>
+ );
+ i++;
+ }
+
+ return <>{elements}</>;
+}
+
+/**
+ * Render inline markdown (bold, italic, code, links).
+ */
+function renderInlineMarkdown(text: string): React.ReactNode {
+ // Process inline elements: **bold**, *italic*, `code`, [link](url)
+ const parts: React.ReactNode[] = [];
+ let remaining = text;
+ let keyCounter = 0;
+
+ while (remaining.length > 0) {
+ // Check for inline code
+ const codeMatch = remaining.match(/^`([^`]+)`/);
+ if (codeMatch) {
+ parts.push(
+ <code key={keyCounter++} className="bg-[#1a1a1a] px-1 py-0.5 text-[#9bc3ff] border border-[#333] text-xs">
+ {codeMatch[1]}
+ </code>
+ );
+ remaining = remaining.slice(codeMatch[0].length);
+ continue;
+ }
+
+ // Check for bold
+ const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
+ if (boldMatch) {
+ parts.push(<strong key={keyCounter++} className="font-bold">{boldMatch[1]}</strong>);
+ remaining = remaining.slice(boldMatch[0].length);
+ continue;
+ }
+
+ // Check for italic
+ const italicMatch = remaining.match(/^\*([^*]+)\*/);
+ if (italicMatch) {
+ parts.push(<em key={keyCounter++} className="italic">{italicMatch[1]}</em>);
+ remaining = remaining.slice(italicMatch[0].length);
+ continue;
+ }
+
+ // Check for links
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
+ if (linkMatch) {
+ parts.push(
+ <a key={keyCounter++} href={linkMatch[2]} className="text-[#75aafc] hover:underline" target="_blank" rel="noopener noreferrer">
+ {linkMatch[1]}
+ </a>
+ );
+ remaining = remaining.slice(linkMatch[0].length);
+ continue;
+ }
+
+ // Find next special character or end
+ const nextSpecial = remaining.search(/[`*\[]/);
+ if (nextSpecial === -1) {
+ parts.push(remaining);
+ break;
+ } else if (nextSpecial === 0) {
+ // Special char at start but didn't match a pattern - treat as text
+ parts.push(remaining[0]);
+ remaining = remaining.slice(1);
+ } else {
+ parts.push(remaining.slice(0, nextSpecial));
+ remaining = remaining.slice(nextSpecial);
+ }
+ }
+
+ return <>{parts}</>;
+}
+
+function MarkdownElement({
+ content,
+ onUpdate,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
+}: {
+ content: string;
+ onUpdate?: (content: string) => void;
+ onEditingChange?: (isEditing: boolean) => void;
+ hasPendingRemoteUpdate?: boolean;
+ onOverwrite?: () => void;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(content);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setEditContent(content);
+ }
+ }, [content, isEditing]);
+
+ useEffect(() => {
+ if (isEditing && textareaRef.current) {
+ textareaRef.current.focus();
+ textareaRef.current.style.height = "auto";
+ textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
+ }
+ }, [isEditing]);
+
+ const startEditing = () => {
+ setIsEditing(true);
+ onEditingChange?.(true);
+ };
+
+ const stopEditing = () => {
+ setIsEditing(false);
+ onEditingChange?.(false);
+ };
+
+ const handleSave = () => {
+ if (hasPendingRemoteUpdate) return;
+ if (onUpdate && editContent !== content) {
+ onUpdate(editContent);
+ }
+ stopEditing();
+ };
+
+ const handleOverwrite = () => {
+ if (onUpdate && editContent !== content) {
+ onUpdate(editContent);
+ }
+ onOverwrite?.();
+ stopEditing();
+ };
+
+ const handleCancel = () => {
+ setEditContent(content);
+ stopEditing();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ handleCancel();
+ }
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !hasPendingRemoteUpdate) {
+ handleSave();
+ }
+ };
+
+ const handleBlur = () => {
+ if (!hasPendingRemoteUpdate) {
+ handleSave();
+ }
+ };
+
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setEditContent(e.target.value);
+ e.target.style.height = "auto";
+ e.target.style.height = e.target.scrollHeight + "px";
+ };
+
+ if (isEditing && onUpdate) {
+ return (
+ <div className="relative">
+ <div className="text-[10px] text-[#555] font-mono mb-1 flex items-center gap-2">
+ <span className="text-[#75aafc]">Editing Markdown</span>
+ </div>
+ <textarea
+ ref={textareaRef}
+ value={editContent}
+ onChange={handleInput}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ className="font-mono text-sm text-white/80 leading-relaxed w-full bg-[#0d0d0d] border border-[#3f6fb3] outline-none p-3 resize-none min-h-[120px]"
+ placeholder="Enter markdown content..."
+ />
+ {hasPendingRemoteUpdate ? (
+ <div className="flex items-center gap-2 mt-2">
+ <span className="text-yellow-500 text-xs font-mono">Remote update pending</span>
+ <button
+ onClick={handleOverwrite}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-yellow-500 border border-yellow-500/50 hover:border-yellow-500 transition-colors"
+ >
+ Overwrite
+ </button>
+ <button
+ onClick={handleCancel}
+ onMouseDown={(e) => e.preventDefault()}
+ className="px-2 py-1 font-mono text-xs text-[#555] hover:text-white/70 transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ ) : (
+ <div className="text-[10px] text-[#555] font-mono mt-1">
+ Ctrl+Enter to save, Esc to cancel
+ </div>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className={`border border-[#333] bg-[#0a0a0a] p-4 rounded ${onUpdate ? "cursor-text hover:border-[#3f6fb3] transition-colors" : ""}`}
+ onClick={() => onUpdate && startEditing()}
+ >
+ <div className="text-[10px] text-[#555] font-mono mb-2 flex items-center gap-1">
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
+ <path d="M14 2v6h6" />
+ <path d="M16 13H8" />
+ <path d="M16 17H8" />
+ <path d="M10 9H8" />
+ </svg>
+ <span>Markdown</span>
+ </div>
+ {renderMarkdown(content)}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index 60458e9..a030c57 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -87,6 +87,8 @@ export function FileDetail({
return element.title || `${element.chartType} chart`;
case "image":
return element.alt || element.caption || "Image";
+ case "markdown":
+ return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : "");
default:
return "Element";
}
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
index c537846..188a1df 100644
--- a/makima/frontend/src/components/files/FileList.tsx
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -1,5 +1,7 @@
import { useRef } from "react";
-import type { FileSummary, BodyElement } from "../../lib/api";
+import { useNavigate } from "react-router";
+import type { FileSummary, BodyElement, ContractPhase } from "../../lib/api";
+import { markdownToBody } from "../../lib/markdown";
interface FileListProps {
files: FileSummary[];
@@ -10,153 +12,6 @@ interface FileListProps {
onUploadMarkdown?: (name: string, body: BodyElement[]) => void;
}
-/**
- * Parse markdown text into BodyElements.
- * Converts image embeds to links instead of images.
- */
-function parseMarkdown(markdown: string): BodyElement[] {
- const elements: BodyElement[] = [];
- const lines = markdown.split('\n');
- let currentParagraph: string[] = [];
- let inCodeBlock = false;
- let codeBlockLanguage: string | undefined;
- let codeBlockContent: string[] = [];
- let currentList: { ordered: boolean; items: string[] } | null = null;
-
- const flushParagraph = () => {
- if (currentParagraph.length > 0) {
- const text = currentParagraph.join('\n').trim();
- if (text) {
- elements.push({ type: "paragraph", text });
- }
- currentParagraph = [];
- }
- };
-
- const flushCodeBlock = () => {
- if (codeBlockContent.length > 0 || inCodeBlock) {
- elements.push({
- type: "code",
- language: codeBlockLanguage || undefined,
- content: codeBlockContent.join('\n'),
- });
- codeBlockContent = [];
- codeBlockLanguage = undefined;
- }
- };
-
- const flushList = () => {
- if (currentList && currentList.items.length > 0) {
- elements.push({
- type: "list",
- ordered: currentList.ordered,
- items: currentList.items,
- });
- currentList = null;
- }
- };
-
- // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
- const convertImagesToLinks = (text: string): string => {
- return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
- const linkText = alt || 'image';
- return `[${linkText}](${url})`;
- });
- };
-
- for (const rawLine of lines) {
- // Check for code block fence (``` or ~~~)
- const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
- if (codeFenceMatch) {
- if (!inCodeBlock) {
- // Starting a code block
- flushParagraph();
- flushList();
- inCodeBlock = true;
- codeBlockLanguage = codeFenceMatch[2] || undefined;
- codeBlockContent = [];
- } else {
- // Ending a code block
- flushCodeBlock();
- inCodeBlock = false;
- }
- continue;
- }
-
- // If inside a code block, add line as-is
- if (inCodeBlock) {
- codeBlockContent.push(rawLine);
- continue;
- }
-
- // Convert images to links in all lines
- const line = convertImagesToLinks(rawLine);
-
- // Check for headings
- const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
- if (headingMatch) {
- flushParagraph();
- flushList();
- const level = headingMatch[1].length;
- const text = headingMatch[2].trim();
- elements.push({ type: "heading", level, text });
- continue;
- }
-
- // Check for unordered list items (-, *, +)
- const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
- if (unorderedMatch) {
- flushParagraph();
- const itemText = unorderedMatch[1].trim();
- if (currentList && currentList.ordered) {
- // Switch from ordered to unordered
- flushList();
- }
- if (!currentList) {
- currentList = { ordered: false, items: [] };
- }
- currentList.items.push(itemText);
- continue;
- }
-
- // Check for ordered list items (1. 2. etc)
- const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
- if (orderedMatch) {
- flushParagraph();
- const itemText = orderedMatch[1].trim();
- if (currentList && !currentList.ordered) {
- // Switch from unordered to ordered
- flushList();
- }
- if (!currentList) {
- currentList = { ordered: true, items: [] };
- }
- currentList.items.push(itemText);
- continue;
- }
-
- // Empty line - flush everything
- if (line.trim() === '') {
- flushParagraph();
- flushList();
- continue;
- }
-
- // Regular text - flush list first, then add to paragraph
- flushList();
- currentParagraph.push(line);
- }
-
- // Flush any remaining content
- if (inCodeBlock) {
- flushCodeBlock();
- }
- flushParagraph();
- flushList();
-
- return elements;
-}
-
function formatDuration(seconds: number | null): string {
if (seconds === null) return "-";
const mins = Math.floor(seconds / 60);
@@ -175,6 +30,14 @@ function formatDate(dateStr: string): string {
});
}
+const phaseColors: Record<ContractPhase, string> = {
+ research: "bg-purple-500/20 text-purple-400 border-purple-400/30",
+ specify: "bg-blue-500/20 text-blue-400 border-blue-400/30",
+ plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30",
+ execute: "bg-green-500/20 text-green-400 border-green-400/30",
+ review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30",
+};
+
export function FileList({
files,
loading,
@@ -183,6 +46,7 @@ export function FileList({
onCreate,
onUploadMarkdown,
}: FileListProps) {
+ const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -193,7 +57,7 @@ export function FileList({
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
- const body = parseMarkdown(content);
+ const body = markdownToBody(content);
// Use filename without extension as the name
const name = file.name.replace(/\.md$/i, '') || 'Imported Document';
onUploadMarkdown(name, body);
@@ -273,10 +137,25 @@ export function FileList({
{file.description}
</p>
)}
- <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ <div className="flex items-center gap-4 font-mono text-[10px] text-[#75aafc]">
<span>{file.transcriptCount} segments</span>
<span>{formatDuration(file.duration)}</span>
<span>{formatDate(file.createdAt)}</span>
+ {file.contractId && file.contractName && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ navigate(`/contracts/${file.contractId}`);
+ }}
+ className={`px-2 py-0.5 text-[9px] font-mono uppercase border rounded ${
+ file.contractPhase ? phaseColors[file.contractPhase] : "bg-[#21262d] text-[#8b949e] border-[#30363d]"
+ } hover:opacity-80 transition-opacity`}
+ title={`View contract: ${file.contractName}`}
+ >
+ {file.contractName}
+ {file.contractPhase && ` · ${file.contractPhase}`}
+ </button>
+ )}
</div>
</button>
<button
diff --git a/makima/frontend/src/components/files/RepoSyncIndicator.tsx b/makima/frontend/src/components/files/RepoSyncIndicator.tsx
new file mode 100644
index 0000000..82d79f7
--- /dev/null
+++ b/makima/frontend/src/components/files/RepoSyncIndicator.tsx
@@ -0,0 +1,190 @@
+import { useState, useCallback } from "react";
+import { syncFileFromRepo } from "../../lib/api";
+
+interface RepoSyncIndicatorProps {
+ fileId: string;
+ repoFilePath: string | null | undefined;
+ repoSyncStatus: string | null | undefined;
+ repoSyncedAt: string | null | undefined;
+ onSyncComplete?: () => void;
+ /** Callback to push file content to repo (creates a task) */
+ onPushToRepo?: () => void;
+ /** Whether a push operation is in progress */
+ isPushing?: boolean;
+}
+
+/**
+ * Shows repository file link status and provides sync functionality.
+ * Displays the linked file path and allows updating from the repo via daemon,
+ * or pushing local changes back to the repo.
+ */
+export function RepoSyncIndicator({
+ fileId,
+ repoFilePath,
+ repoSyncStatus,
+ repoSyncedAt,
+ onSyncComplete,
+ onPushToRepo,
+ isPushing = false,
+}: RepoSyncIndicatorProps) {
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const handleSync = useCallback(async () => {
+ setIsSyncing(true);
+ setError(null);
+ try {
+ await syncFileFromRepo(fileId);
+ // The actual update happens via WebSocket notification
+ // Give a brief delay then notify parent
+ setTimeout(() => {
+ onSyncComplete?.();
+ }, 500);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Sync failed");
+ } finally {
+ setIsSyncing(false);
+ }
+ }, [fileId, onSyncComplete]);
+
+ // Don't render if no repo file path is set
+ if (!repoFilePath) {
+ return null;
+ }
+
+ const isActuallySyncing = isSyncing || repoSyncStatus === "syncing";
+ const isSynced = repoSyncStatus === "synced";
+ const isModified = repoSyncStatus === "modified";
+
+ // Format the synced timestamp
+ const syncedAtFormatted = repoSyncedAt
+ ? new Date(repoSyncedAt).toLocaleString()
+ : null;
+
+ return (
+ <div className="flex items-center gap-2 text-xs font-mono">
+ {/* File path icon and link */}
+ <div className="flex items-center gap-1 text-[#555]">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="flex-shrink-0"
+ >
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
+ <polyline points="14 2 14 8 20 8" />
+ </svg>
+ <span className="text-[#75aafc]" title={`Linked to repository file: ${repoFilePath}`}>
+ {repoFilePath}
+ </span>
+ </div>
+
+ {/* Status indicator */}
+ {isSynced && (
+ <span
+ className="text-green-500 flex items-center gap-1"
+ title={syncedAtFormatted ? `Last synced: ${syncedAtFormatted}` : "Synced"}
+ >
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ </span>
+ )}
+ {isModified && (
+ <span className="text-yellow-500" title="File modified, may need sync">
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
+ <circle cx="12" cy="12" r="10" />
+ <line x1="12" y1="8" x2="12" y2="12" />
+ <line x1="12" y1="16" x2="12.01" y2="16" />
+ </svg>
+ </span>
+ )}
+
+ {/* Pull from repo button */}
+ <button
+ onClick={handleSync}
+ disabled={isActuallySyncing || isPushing}
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${
+ isActuallySyncing || isPushing
+ ? "text-[#555] cursor-wait"
+ : "text-[#555] hover:text-[#75aafc] hover:bg-[rgba(117,170,252,0.1)]"
+ }`}
+ title="Pull latest from repository"
+ >
+ {isActuallySyncing ? (
+ <>
+ <svg
+ width="10"
+ height="10"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="animate-spin"
+ >
+ <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" />
+ </svg>
+ <span>Pulling...</span>
+ </>
+ ) : (
+ <>
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M12 19V5" />
+ <path d="M5 12l7-7 7 7" />
+ </svg>
+ <span>Pull</span>
+ </>
+ )}
+ </button>
+
+ {/* Push to repo button */}
+ {onPushToRepo && (
+ <button
+ onClick={onPushToRepo}
+ disabled={isActuallySyncing || isPushing}
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded transition-colors ${
+ isPushing
+ ? "text-[#555] cursor-wait"
+ : "text-[#555] hover:text-green-500 hover:bg-[rgba(34,197,94,0.1)]"
+ }`}
+ title="Push changes to repository (creates a task)"
+ >
+ {isPushing ? (
+ <>
+ <svg
+ width="10"
+ height="10"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ className="animate-spin"
+ >
+ <circle cx="12" cy="12" r="10" strokeDasharray="32" strokeDashoffset="8" />
+ </svg>
+ <span>Pushing...</span>
+ </>
+ ) : (
+ <>
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M12 5v14" />
+ <path d="M19 12l-7 7-7-7" />
+ </svg>
+ <span>Push</span>
+ </>
+ )}
+ </button>
+ )}
+
+ {/* Error message */}
+ {error && (
+ <span className="text-red-500 text-[10px]" title={error}>
+ Failed
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx
index af2cd05..35834d4 100644
--- a/makima/frontend/src/components/listen/ControlPanel.tsx
+++ b/makima/frontend/src/components/listen/ControlPanel.tsx
@@ -1,6 +1,11 @@
import { Logo } from "../Logo";
import type { MicrophoneStatus } from "../../hooks/useMicrophone";
+export interface ContractOption {
+ id: string;
+ name: string;
+}
+
interface ControlPanelProps {
isListening: boolean;
isConnected: boolean;
@@ -10,6 +15,11 @@ interface ControlPanelProps {
onToggle: () => void;
onNew: () => void;
error?: string | null;
+ // Contract selection
+ contracts: ContractOption[];
+ selectedContractId: string | null;
+ onContractChange: (contractId: string | null) => void;
+ contractsLoading?: boolean;
}
function getStatusText(isListening: boolean, micStatus: MicrophoneStatus): string {
@@ -38,6 +48,10 @@ export function ControlPanel({
onToggle,
onNew,
error,
+ contracts,
+ selectedContractId,
+ onContractChange,
+ contractsLoading,
}: ControlPanelProps) {
const statusText = getStatusText(isListening, micStatus);
const isRequesting = micStatus === "requesting";
@@ -133,13 +147,20 @@ export function ControlPanel({
>
New
</button>
- <button
- disabled
- className="px-3 py-1.5 font-mono text-xs text-[#9aa9c6] bg-[#0b1423] border border-[rgba(117,170,252,0.25)] cursor-not-allowed uppercase tracking-wide opacity-50"
- title="File upload coming soon"
+ <select
+ value={selectedContractId || ""}
+ onChange={(e) => onContractChange(e.target.value || null)}
+ disabled={isListening || contractsLoading}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] focus:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide"
+ title={selectedContractId ? "Saving to selected contract" : "Transcript not saved"}
>
- Upload
- </button>
+ <option value="">Ephemeral Transcript</option>
+ {contracts.map((contract) => (
+ <option key={contract.id} value={contract.id}>
+ {contract.name}
+ </option>
+ ))}
+ </select>
</div>
</div>
);
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
index be4fb80..967b1d1 100644
--- a/makima/frontend/src/components/mesh/TaskDetail.tsx
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -23,6 +23,8 @@ interface TaskDetailProps {
onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void;
/** Which subtask's output is currently being viewed */
viewingSubtaskId?: string | null;
+ /** Navigate to view the contract */
+ onViewContract?: (contractId: string) => void;
// Optional advanced features
overlayDiff?: string;
changedFiles?: string[];
@@ -105,6 +107,7 @@ export function TaskDetail({
onCreateSubtask,
onToggleSubtaskOutput,
viewingSubtaskId,
+ onViewContract,
overlayDiff,
changedFiles,
onRequestDiff,
@@ -417,6 +420,15 @@ export function TaskDetail({
>
{task.status}
</span>
+ {/* Contract badge - clickable to view contract */}
+ {task.contractId && onViewContract && (
+ <button
+ onClick={() => onViewContract(task.contractId!)}
+ className="px-2 py-0.5 font-mono text-xs text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/20 transition-colors"
+ >
+ Contract
+ </button>
+ )}
{/* Orchestrator badge for depth 0 tasks with subtasks */}
{task.depth === 0 && task.subtasks.length > 0 && (
<span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx
index a37e564..d013782 100644
--- a/makima/frontend/src/components/mesh/TaskList.tsx
+++ b/makima/frontend/src/components/mesh/TaskList.tsx
@@ -1,4 +1,5 @@
-import type { TaskSummary, TaskStatus } from "../../lib/api";
+import { useMemo } from "react";
+import type { TaskSummary, TaskStatus, ContractPhase } from "../../lib/api";
interface TaskListProps {
tasks: TaskSummary[];
@@ -8,6 +9,13 @@ interface TaskListProps {
onCreate: () => void;
}
+interface GroupedTasks {
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
+ tasks: TaskSummary[];
+}
+
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
@@ -61,6 +69,17 @@ function getStatusBgColor(status: TaskStatus): string {
}
}
+function getPhaseColor(phase: ContractPhase | null): string {
+ switch (phase) {
+ case "research": return "text-blue-400";
+ case "specify": return "text-cyan-400";
+ case "plan": return "text-yellow-400";
+ case "execute": return "text-green-400";
+ case "review": return "text-purple-400";
+ default: return "text-[#8b949e]";
+ }
+}
+
export function TaskList({
tasks,
loading,
@@ -68,6 +87,53 @@ export function TaskList({
onDelete,
onCreate,
}: TaskListProps) {
+ // Group tasks by contract
+ const groupedTasks = useMemo(() => {
+ // Separate root tasks (no parent) from subtasks
+ const rootTasks = tasks.filter((t) => !t.parentTaskId);
+
+ // Group by contractId
+ const groups = new Map<string | null, GroupedTasks>();
+
+ for (const task of rootTasks) {
+ const key = task.contractId;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ contractId: task.contractId,
+ contractName: task.contractName,
+ contractPhase: task.contractPhase,
+ tasks: [],
+ });
+ }
+ groups.get(key)!.tasks.push(task);
+ }
+
+ // Sort tasks within each group: supervisors first, then by status (running first), then by date
+ for (const group of groups.values()) {
+ group.tasks.sort((a, b) => {
+ // Supervisors always first
+ if (a.isSupervisor && !b.isSupervisor) return -1;
+ if (!a.isSupervisor && b.isSupervisor) return 1;
+ // Running tasks next
+ if (a.status === "running" && b.status !== "running") return -1;
+ if (a.status !== "running" && b.status === "running") return 1;
+ // Then by date (newest first)
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ });
+ }
+
+ // Sort: contracts first (alphabetically by name), then orphan tasks
+ const sorted = Array.from(groups.values()).sort((a, b) => {
+ // Orphan tasks (no contract) go last
+ if (!a.contractId && b.contractId) return 1;
+ if (a.contractId && !b.contractId) return -1;
+ // Sort by contract name
+ return (a.contractName || "").localeCompare(b.contractName || "");
+ });
+
+ return sorted;
+ }, [tasks]);
+
if (loading) {
return (
<div className="panel h-full flex items-center justify-center">
@@ -76,8 +142,7 @@ export function TaskList({
);
}
- // Separate root tasks (no parent) from subtasks
- const rootTasks = tasks.filter((t) => !t.parentTaskId);
+ const totalTasks = groupedTasks.reduce((sum, g) => sum + g.tasks.length, 0);
return (
<div className="panel h-full flex flex-col">
@@ -94,65 +159,107 @@ export function TaskList({
</div>
<div className="flex-1 overflow-y-auto">
- {rootTasks.length === 0 ? (
+ {totalTasks === 0 ? (
<div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
No tasks yet. Create one to start orchestrating Claude Code instances.
</div>
) : (
- <div className="divide-y divide-[rgba(117,170,252,0.15)]">
- {rootTasks.map((task) => (
- <div
- key={task.id}
- className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
- >
- <div className="flex items-start justify-between gap-4">
- <button
- onClick={() => onSelect(task.id)}
- className="flex-1 text-left"
- >
- <div className="flex items-center gap-2 mb-1">
- <h3 className="font-mono text-sm text-[#dbe7ff]">
- {task.name}
- </h3>
- <span
- className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
- task.status
- )} ${getStatusBgColor(task.status)} border border-current/20`}
- >
- {task.status}
+ <div>
+ {groupedTasks.map((group) => (
+ <div key={group.contractId || "orphan"}>
+ {/* Contract header */}
+ <div className="sticky top-0 bg-[#0d1117] border-b border-[rgba(117,170,252,0.25)] px-4 py-2 flex items-center gap-2">
+ {group.contractId ? (
+ <>
+ <span className="font-mono text-xs text-[#dbe7ff] font-medium">
+ {group.contractName}
</span>
- {task.depth === 0 && task.subtaskCount > 0 && (
- <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
- Orchestrator
+ {group.contractPhase && (
+ <span className={`font-mono text-[10px] ${getPhaseColor(group.contractPhase)}`}>
+ [{group.contractPhase}]
</span>
)}
- {task.priority > 0 && (
- <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
- P{task.priority}
- </span>
- )}
- </div>
- {task.progressSummary && (
- <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
- {task.progressSummary}
- </p>
- )}
- <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
- {task.subtaskCount > 0 && (
- <span>{task.subtaskCount} subtasks</span>
- )}
- <span>{formatDate(task.createdAt)}</span>
+ <span className="font-mono text-[10px] text-[#8b949e]">
+ ({group.tasks.length})
+ </span>
+ </>
+ ) : (
+ <span className="font-mono text-xs text-[#8b949e] italic">
+ Unassigned Tasks ({group.tasks.length})
+ </span>
+ )}
+ </div>
+
+ {/* Tasks in this group */}
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {group.tasks.map((task) => (
+ <div
+ key={task.id}
+ className={`p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors ${
+ task.isSupervisor
+ ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]"
+ : ""
+ }`}
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left"
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">
+ {task.name}
+ </h3>
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
+ task.status
+ )} ${getStatusBgColor(task.status)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {task.isSupervisor && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[#0f3c78] border border-[#3f6fb3] uppercase">
+ Supervisor
+ </span>
+ )}
+ {!task.isSupervisor && task.depth === 0 && task.subtaskCount > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+ {task.progressSummary && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {task.progressSummary}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ {task.subtaskCount > 0 && (
+ <span>{task.subtaskCount} subtasks</span>
+ )}
+ <span>{formatDate(task.createdAt)}</span>
+ </div>
+ </button>
+ {/* Supervisor tasks cannot be deleted directly - they are deleted with the contract */}
+ {!task.isSupervisor && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ )}
+ </div>
</div>
- </button>
- <button
- onClick={(e) => {
- e.stopPropagation();
- onDelete(task.id);
- }}
- className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
- >
- Delete
- </button>
+ ))}
</div>
</div>
))}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
index 10de225..cb0eba3 100644
--- a/makima/frontend/src/components/mesh/TaskOutput.tsx
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -275,7 +275,103 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
</div>
);
+ case "auth_required":
+ return <AuthRequiredEntry entry={entry} />;
+
default:
return null;
}
}
+
+function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) {
+ const [authCode, setAuthCode] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const loginUrl = entry.toolInput?.loginUrl as string | undefined;
+ const hostname = entry.toolInput?.hostname as string | undefined;
+ // Get taskId from entry or fallback to toolInput (for robustness)
+ const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!authCode.trim() || !taskId) return;
+
+ setSubmitting(true);
+ setError(null);
+
+ try {
+ // Send the auth code to the task via the message endpoint
+ await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`);
+ setSubmitted(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to submit code");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (submitted) {
+ return (
+ <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2">
+ <div className="flex items-center gap-2 text-green-400 font-semibold">
+ <span>✓</span>
+ <span>Authentication code submitted</span>
+ </div>
+ <p className="text-green-200/80 text-sm mt-1">
+ Waiting for authentication to complete...
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2">
+ <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
+ <span>🔐</span>
+ <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span>
+ </div>
+ <p className="text-amber-200/80 text-sm mb-3">
+ The daemon's OAuth token has expired. Click the button to login, then paste the code below:
+ </p>
+
+ <div className="flex flex-col gap-3">
+ {loginUrl ? (
+ <a
+ href={loginUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center"
+ >
+ 1. Login to Claude
+ </a>
+ ) : (
+ <p className="text-red-400 text-sm">Login URL not available</p>
+ )}
+
+ <form onSubmit={handleSubmit} className="flex gap-2">
+ <input
+ type="text"
+ value={authCode}
+ onChange={(e) => setAuthCode(e.target.value)}
+ placeholder="2. Paste authentication code here"
+ className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
+ disabled={submitting}
+ />
+ <button
+ type="submit"
+ disabled={submitting || !authCode.trim()}
+ className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors"
+ >
+ {submitting ? "..." : "Submit"}
+ </button>
+ </form>
+
+ {error && (
+ <p className="text-red-400 text-sm">{error}</p>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskTree.tsx b/makima/frontend/src/components/mesh/TaskTree.tsx
new file mode 100644
index 0000000..46ae78d
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskTree.tsx
@@ -0,0 +1,390 @@
+import { useState, useCallback } from "react";
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface TaskTreeProps {
+ tasks: TaskSummary[];
+ supervisorTaskId: string | null;
+ onSelect: (taskId: string) => void;
+ onStartSupervisor?: () => void;
+ loading?: boolean;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+interface TreeNodeProps {
+ task: TaskSummary;
+ isSupervisorTask: boolean;
+ onSelect: (taskId: string) => void;
+ depth: number;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusIcon(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "○";
+ case "running":
+ return "◉";
+ case "paused":
+ return "◎";
+ case "blocked":
+ return "◈";
+ case "done":
+ return "●";
+ case "failed":
+ return "✕";
+ case "merged":
+ return "◆";
+ default:
+ return "○";
+ }
+}
+
+function TreeNode({ task, isSupervisorTask, onSelect, depth, fetchSubtasks }: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(isSupervisorTask); // Supervisor expanded by default
+ const [children, setChildren] = useState<TaskSummary[] | null>(null);
+ const [loadingChildren, setLoadingChildren] = useState(false);
+
+ const hasSubtasks = task.subtaskCount > 0;
+
+ const handleToggle = useCallback(async () => {
+ if (!hasSubtasks) return;
+
+ if (expanded) {
+ setExpanded(false);
+ } else {
+ if (!children && fetchSubtasks) {
+ setLoadingChildren(true);
+ try {
+ const subtasks = await fetchSubtasks(task.id);
+ setChildren(subtasks);
+ } catch (err) {
+ console.error("Failed to fetch subtasks:", err);
+ } finally {
+ setLoadingChildren(false);
+ }
+ }
+ setExpanded(true);
+ }
+ }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]);
+
+ const indent = depth * 16;
+
+ return (
+ <div className="select-none">
+ <div
+ className={`flex items-center gap-2 py-2 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group ${
+ isSupervisorTask ? "bg-[rgba(117,170,252,0.08)] border-l-2 border-[#75aafc]" : ""
+ }`}
+ style={{ paddingLeft: `${indent + 8}px` }}
+ >
+ {/* Expand/Collapse button */}
+ <button
+ onClick={handleToggle}
+ className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${
+ hasSubtasks
+ ? "text-[#75aafc] hover:text-[#9bc3ff]"
+ : "text-transparent cursor-default"
+ }`}
+ disabled={!hasSubtasks}
+ >
+ {loadingChildren ? (
+ <span className="animate-spin">...</span>
+ ) : hasSubtasks ? (
+ expanded ? "▼" : "▶"
+ ) : (
+ ""
+ )}
+ </button>
+
+ {/* Supervisor badge or status icon */}
+ {isSupervisorTask ? (
+ <span className="px-1.5 py-0.5 font-mono text-[9px] bg-[#0f3c78] text-[#75aafc] border border-[#3f6fb3] uppercase">
+ Supervisor
+ </span>
+ ) : (
+ <span
+ className={`font-mono text-xs ${getStatusColor(task.status)}`}
+ title={task.status}
+ >
+ {getStatusIcon(task.status)}
+ </span>
+ )}
+
+ {/* Task name - clickable */}
+ <button
+ onClick={() => onSelect(task.id)}
+ className={`flex-1 text-left font-mono text-sm transition-colors truncate ${
+ isSupervisorTask
+ ? "text-[#9bc3ff] hover:text-white font-medium"
+ : "text-[#dbe7ff] hover:text-white"
+ }`}
+ >
+ {task.name}
+ </button>
+
+ {/* Status for supervisor */}
+ {isSupervisorTask && (
+ <span
+ className={`font-mono text-[10px] ${getStatusColor(task.status)}`}
+ >
+ {task.status}
+ </span>
+ )}
+
+ {/* Subtask count badge */}
+ {hasSubtasks && !isSupervisorTask && (
+ <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]">
+ {task.subtaskCount} sub
+ </span>
+ )}
+
+ {/* Priority indicator */}
+ {task.priority > 0 && (
+ <span className="font-mono text-[9px] text-orange-400">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+
+ {/* Children */}
+ {expanded && children && children.length > 0 && (
+ <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}>
+ {children.map((child) => (
+ <TreeNode
+ key={child.id}
+ task={child}
+ isSupervisorTask={false}
+ onSelect={onSelect}
+ depth={depth + 1}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+// Stats summary component
+export interface TaskTreeStats {
+ total: number;
+ pending: number;
+ running: number;
+ paused: number;
+ blocked: number;
+ done: number;
+ failed: number;
+ merged: number;
+}
+
+export function calculateTreeStats(tasks: TaskSummary[]): TaskTreeStats {
+ const stats: TaskTreeStats = {
+ total: tasks.length,
+ pending: 0,
+ running: 0,
+ paused: 0,
+ blocked: 0,
+ done: 0,
+ failed: 0,
+ merged: 0,
+ };
+
+ for (const task of tasks) {
+ // Skip supervisor task in stats
+ if (task.isSupervisor) continue;
+
+ switch (task.status) {
+ case "pending":
+ stats.pending++;
+ break;
+ case "running":
+ stats.running++;
+ break;
+ case "paused":
+ stats.paused++;
+ break;
+ case "blocked":
+ stats.blocked++;
+ break;
+ case "done":
+ stats.done++;
+ break;
+ case "failed":
+ stats.failed++;
+ break;
+ case "merged":
+ stats.merged++;
+ break;
+ }
+ }
+
+ // Adjust total to exclude supervisor
+ stats.total = tasks.filter(t => !t.isSupervisor).length;
+
+ return stats;
+}
+
+// Progress bar for task tree
+export function TaskTreeProgressBar({ stats }: { stats: TaskTreeStats }) {
+ if (stats.total === 0) return null;
+
+ const completedPercent = ((stats.done + stats.merged) / stats.total) * 100;
+ const runningPercent = (stats.running / stats.total) * 100;
+ const failedPercent = (stats.failed / stats.total) * 100;
+
+ return (
+ <div className="space-y-2">
+ {/* Progress bar */}
+ <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden flex">
+ <div
+ className="bg-emerald-400 transition-all"
+ style={{ width: `${completedPercent}%` }}
+ title={`Completed: ${stats.done + stats.merged}`}
+ />
+ <div
+ className="bg-green-400 transition-all"
+ style={{ width: `${runningPercent}%` }}
+ title={`Running: ${stats.running}`}
+ />
+ <div
+ className="bg-red-400 transition-all"
+ style={{ width: `${failedPercent}%` }}
+ title={`Failed: ${stats.failed}`}
+ />
+ </div>
+
+ {/* Summary */}
+ <div className="flex items-center justify-between font-mono text-[10px]">
+ <span className="text-[#555]">
+ {stats.done + stats.merged} / {stats.total} completed
+ </span>
+ {stats.running > 0 && (
+ <span className="text-green-400">{stats.running} running</span>
+ )}
+ {stats.failed > 0 && (
+ <span className="text-red-400">{stats.failed} failed</span>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function TaskTree({
+ tasks,
+ supervisorTaskId,
+ onSelect,
+ onStartSupervisor,
+ loading = false,
+ fetchSubtasks,
+}: TaskTreeProps) {
+ if (loading) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ Loading tasks...
+ </div>
+ );
+ }
+
+ // Separate supervisor from other tasks
+ const supervisorTask = tasks.find(t => t.id === supervisorTaskId || t.isSupervisor);
+ const workerTasks = tasks.filter(t => t.id !== supervisorTaskId && !t.isSupervisor && !t.parentTaskId);
+
+ // Calculate stats for worker tasks
+ const stats = calculateTreeStats(tasks);
+
+ return (
+ <div className="space-y-4">
+ {/* Supervisor Section */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Contract Supervisor
+ </h3>
+ {supervisorTask && supervisorTask.status === "pending" && onStartSupervisor && (
+ <button
+ onClick={onStartSupervisor}
+ className="px-2 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
+ >
+ Start Supervisor
+ </button>
+ )}
+ </div>
+
+ {supervisorTask ? (
+ <TreeNode
+ task={supervisorTask}
+ isSupervisorTask={true}
+ onSelect={onSelect}
+ depth={0}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ) : (
+ <div className="p-3 border border-dashed border-[rgba(117,170,252,0.2)] text-center">
+ <p className="font-mono text-xs text-[#555]">
+ No supervisor task found
+ </p>
+ </div>
+ )}
+ </div>
+
+ {/* Progress Section */}
+ {stats.total > 0 && (
+ <div className="space-y-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Task Progress
+ </h3>
+ <TaskTreeProgressBar stats={stats} />
+ </div>
+ )}
+
+ {/* Worker Tasks Section */}
+ {workerTasks.length > 0 && (
+ <div className="space-y-2">
+ <h3 className="font-mono text-xs text-[#75aafc] uppercase">
+ Worker Tasks ({workerTasks.length})
+ </h3>
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {workerTasks.map((task) => (
+ <TreeNode
+ key={task.id}
+ task={task}
+ isSupervisorTask={false}
+ onSelect={onSelect}
+ depth={0}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Empty State */}
+ {workerTasks.length === 0 && !supervisorTask && (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ No tasks in this contract
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/PhaseColumn.tsx b/makima/frontend/src/components/workflow/PhaseColumn.tsx
new file mode 100644
index 0000000..ddea85f
--- /dev/null
+++ b/makima/frontend/src/components/workflow/PhaseColumn.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import type { ContractSummary, ContractPhase } from "../../lib/api";
+import { WorkflowContractCard } from "./WorkflowContractCard";
+
+interface PhaseColumnProps {
+ phase: ContractPhase;
+ contracts: ContractSummary[];
+ onContractClick: (contractId: string) => void;
+ onDrop: (contractId: string, phase: ContractPhase) => void;
+}
+
+const phaseConfig: Record<
+ ContractPhase,
+ { label: string; color: string; bgColor: string; borderColor: string }
+> = {
+ research: {
+ label: "Research",
+ color: "text-purple-400",
+ bgColor: "bg-purple-400/10",
+ borderColor: "border-purple-400/30",
+ },
+ specify: {
+ label: "Specify",
+ color: "text-blue-400",
+ bgColor: "bg-blue-400/10",
+ borderColor: "border-blue-400/30",
+ },
+ plan: {
+ label: "Plan",
+ color: "text-cyan-400",
+ bgColor: "bg-cyan-400/10",
+ borderColor: "border-cyan-400/30",
+ },
+ execute: {
+ label: "Execute",
+ color: "text-yellow-400",
+ bgColor: "bg-yellow-400/10",
+ borderColor: "border-yellow-400/30",
+ },
+ review: {
+ label: "Review",
+ color: "text-green-400",
+ bgColor: "bg-green-400/10",
+ borderColor: "border-green-400/30",
+ },
+};
+
+export function PhaseColumn({
+ phase,
+ contracts,
+ onContractClick,
+ onDrop,
+}: PhaseColumnProps) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const config = phaseConfig[phase];
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(true);
+ };
+
+ const handleDragLeave = () => {
+ setIsDragOver(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ const contractId = e.dataTransfer.getData("contractId");
+ if (contractId) {
+ onDrop(contractId, phase);
+ }
+ };
+
+ return (
+ <div
+ className={`
+ flex flex-col min-w-[220px] flex-1 border border-[rgba(117,170,252,0.15)]
+ ${isDragOver ? "bg-[rgba(117,170,252,0.05)]" : "bg-transparent"}
+ transition-colors
+ `}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ {/* Column header */}
+ <div
+ className={`
+ p-3 border-b ${config.borderColor} ${config.bgColor}
+ flex items-center justify-between
+ `}
+ >
+ <span className={`font-mono text-xs uppercase tracking-wider ${config.color}`}>
+ {config.label}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ ({contracts.length})
+ </span>
+ </div>
+
+ {/* Cards container */}
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
+ {contracts.length === 0 ? (
+ <div className="p-4 text-center font-mono text-[10px] text-[#555]">
+ No contracts
+ </div>
+ ) : (
+ contracts.map((contract) => (
+ <WorkflowContractCard
+ key={contract.id}
+ contract={contract}
+ onClick={() => onContractClick(contract.id)}
+ onDragStart={(e) => {
+ e.dataTransfer.setData("contractId", contract.id);
+ e.dataTransfer.effectAllowed = "move";
+ }}
+ />
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/WorkflowBoard.tsx b/makima/frontend/src/components/workflow/WorkflowBoard.tsx
new file mode 100644
index 0000000..af4aec7
--- /dev/null
+++ b/makima/frontend/src/components/workflow/WorkflowBoard.tsx
@@ -0,0 +1,54 @@
+import { useMemo } from "react";
+import type { ContractSummary, ContractPhase } from "../../lib/api";
+import { PhaseColumn } from "./PhaseColumn";
+
+interface WorkflowBoardProps {
+ contracts: ContractSummary[];
+ onContractClick: (contractId: string) => void;
+ onPhaseChange: (contractId: string, newPhase: ContractPhase) => void;
+}
+
+const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"];
+
+export function WorkflowBoard({
+ contracts,
+ onContractClick,
+ onPhaseChange,
+}: WorkflowBoardProps) {
+ // Group contracts by phase
+ const contractsByPhase = useMemo(() => {
+ const grouped: Record<ContractPhase, ContractSummary[]> = {
+ research: [],
+ specify: [],
+ plan: [],
+ execute: [],
+ review: [],
+ };
+
+ for (const contract of contracts) {
+ const phase = contract.phase as ContractPhase;
+ if (grouped[phase]) {
+ grouped[phase].push(contract);
+ } else {
+ // Default to research if unknown phase
+ grouped.research.push(contract);
+ }
+ }
+
+ return grouped;
+ }, [contracts]);
+
+ return (
+ <div className="flex gap-2 h-full overflow-x-auto">
+ {phases.map((phase) => (
+ <PhaseColumn
+ key={phase}
+ phase={phase}
+ contracts={contractsByPhase[phase]}
+ onContractClick={onContractClick}
+ onDrop={onPhaseChange}
+ />
+ ))}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/workflow/WorkflowContractCard.tsx b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
new file mode 100644
index 0000000..e6c8a1c
--- /dev/null
+++ b/makima/frontend/src/components/workflow/WorkflowContractCard.tsx
@@ -0,0 +1,53 @@
+import type { ContractSummary, ContractStatus } from "../../lib/api";
+
+interface WorkflowContractCardProps {
+ contract: ContractSummary;
+ onClick: () => void;
+ onDragStart: (e: React.DragEvent) => void;
+}
+
+const statusConfig: Record<ContractStatus, { label: string; color: string }> = {
+ active: { label: "Active", color: "text-green-400" },
+ completed: { label: "Done", color: "text-blue-400" },
+ archived: { label: "Archived", color: "text-[#555]" },
+};
+
+export function WorkflowContractCard({
+ contract,
+ onClick,
+ onDragStart,
+}: WorkflowContractCardProps) {
+ const status = statusConfig[contract.status] || statusConfig.active;
+
+ return (
+ <div
+ draggable
+ onDragStart={onDragStart}
+ onClick={onClick}
+ className="p-3 bg-[rgba(9,13,20,0.8)] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] cursor-pointer transition-colors select-none"
+ >
+ {/* Name */}
+ <div className="font-mono text-sm text-[#dbe7ff] truncate mb-1">
+ {contract.name}
+ </div>
+
+ {/* Status and counts row */}
+ <div className="flex items-center justify-between">
+ <span className={`font-mono text-[10px] uppercase ${status.color}`}>
+ {status.label}
+ </span>
+ <div className="flex items-center gap-2 font-mono text-[10px] text-[#555]">
+ <span title="Files">{contract.fileCount} files</span>
+ <span title="Tasks">{contract.taskCount} tasks</span>
+ </div>
+ </div>
+
+ {/* Description preview if exists */}
+ {contract.description && (
+ <div className="mt-1 font-mono text-[10px] text-[#555] truncate">
+ {contract.description}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useContracts.ts b/makima/frontend/src/hooks/useContracts.ts
new file mode 100644
index 0000000..f803527
--- /dev/null
+++ b/makima/frontend/src/hooks/useContracts.ts
@@ -0,0 +1,308 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listContracts,
+ getContract,
+ createContract,
+ updateContract,
+ deleteContract,
+ changeContractPhase,
+ getContractEvents,
+ addRemoteRepository,
+ addLocalRepository,
+ createManagedRepository,
+ deleteContractRepository,
+ setRepositoryPrimary,
+ addTaskToContract,
+ removeTaskFromContract,
+ VersionConflictError,
+ type ContractSummary,
+ type ContractWithRelations,
+ type ContractEvent,
+ type ContractRepository,
+ type ContractPhase,
+ type CreateContractRequest,
+ type UpdateContractRequest,
+ type AddRemoteRepositoryRequest,
+ type AddLocalRepositoryRequest,
+ type CreateManagedRepositoryRequest,
+} from "../lib/api";
+
+export interface ConflictState {
+ hasConflict: boolean;
+ expectedVersion: number;
+ actualVersion: number;
+}
+
+export function useContracts() {
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
+
+ const fetchContracts = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch contracts");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchContract = useCallback(
+ async (id: string): Promise<ContractWithRelations | null> => {
+ setError(null);
+ try {
+ return await getContract(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch contract");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveContract = useCallback(
+ async (data: CreateContractRequest): Promise<ContractSummary | null> => {
+ setError(null);
+ try {
+ const contract = await createContract(data);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save contract");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const editContract = useCallback(
+ async (
+ id: string,
+ data: UpdateContractRequest
+ ): Promise<ContractSummary | null> => {
+ setError(null);
+ setConflict(null);
+ try {
+ const contract = await updateContract(id, data);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ if (e instanceof VersionConflictError) {
+ setConflict({
+ hasConflict: true,
+ expectedVersion: e.expectedVersion,
+ actualVersion: e.actualVersion,
+ });
+ return null;
+ }
+ setError(e instanceof Error ? e.message : "Failed to update contract");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const clearConflict = useCallback(() => {
+ setConflict(null);
+ }, []);
+
+ const removeContract = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteContract(id);
+ await fetchContracts(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete contract");
+ return false;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const changePhase = useCallback(
+ async (
+ id: string,
+ phase: ContractPhase
+ ): Promise<ContractSummary | null> => {
+ setError(null);
+ try {
+ const contract = await changeContractPhase(id, phase);
+ await fetchContracts(); // Refresh list
+ return contract;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to change phase");
+ return null;
+ }
+ },
+ [fetchContracts]
+ );
+
+ const fetchEvents = useCallback(
+ async (id: string): Promise<ContractEvent[]> => {
+ setError(null);
+ try {
+ return await getContractEvents(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch events");
+ return [];
+ }
+ },
+ []
+ );
+
+ // Repository management
+ const addRemoteRepo = useCallback(
+ async (
+ contractId: string,
+ data: AddRemoteRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await addRemoteRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add remote repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const addLocalRepo = useCallback(
+ async (
+ contractId: string,
+ data: AddLocalRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await addLocalRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add local repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const createManagedRepo = useCallback(
+ async (
+ contractId: string,
+ data: CreateManagedRepositoryRequest
+ ): Promise<ContractRepository | null> => {
+ setError(null);
+ try {
+ return await createManagedRepository(contractId, data);
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to create managed repository"
+ );
+ return null;
+ }
+ },
+ []
+ );
+
+ const removeRepo = useCallback(
+ async (contractId: string, repoId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteContractRepository(contractId, repoId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to delete repository"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ const setRepoPrimary = useCallback(
+ async (contractId: string, repoId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await setRepositoryPrimary(contractId, repoId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to set repository as primary"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ // Task association
+ const addTask = useCallback(
+ async (contractId: string, taskId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await addTaskToContract(contractId, taskId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to add task to contract"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ const removeTask = useCallback(
+ async (contractId: string, taskId: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await removeTaskFromContract(contractId, taskId);
+ return true;
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : "Failed to remove task from contract"
+ );
+ return false;
+ }
+ },
+ []
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchContracts();
+ }, [fetchContracts]);
+
+ return {
+ contracts,
+ loading,
+ error,
+ conflict,
+ clearConflict,
+ fetchContracts,
+ fetchContract,
+ saveContract,
+ editContract,
+ removeContract,
+ changePhase,
+ fetchEvents,
+ // Repository management
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ removeRepo,
+ setRepoPrimary,
+ // Task association
+ addTask,
+ removeTask,
+ };
+}
diff --git a/makima/frontend/src/hooks/useWebSocket.ts b/makima/frontend/src/hooks/useWebSocket.ts
index 961951f..c593621 100644
--- a/makima/frontend/src/hooks/useWebSocket.ts
+++ b/makima/frontend/src/hooks/useWebSocket.ts
@@ -214,12 +214,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}) {
}, []);
const startSession = useCallback(
- (sampleRate: number, channels: number = 1) => {
+ (sampleRate: number, channels: number = 1, contractId?: string | null, authToken?: string | null) => {
sendMessage({
type: "start",
sampleRate,
channels,
encoding: "pcm32f",
+ ...(contractId && authToken ? { contractId, authToken } : {}),
});
},
[sendMessage]
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index a11f15e..d77c85c 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -132,7 +132,8 @@ export type BodyElement =
data: Record<string, unknown>[];
config?: Record<string, unknown>;
}
- | { type: "image"; src: string; alt?: string; caption?: string };
+ | { type: "image"; src: string; alt?: string; caption?: string }
+ | { type: "markdown"; content: string };
export interface FileSummary {
id: string;
@@ -141,8 +142,16 @@ export interface FileSummary {
transcriptCount: number;
duration: number | null;
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
+ // Contract info (joined from contracts table)
+ contractId: string | null;
+ contractName: string | null;
+ contractPhase: ContractPhase | null;
}
export interface FileDetail {
@@ -155,6 +164,12 @@ export interface FileDetail {
summary: string | null;
body: BodyElement[];
version: number;
+ /** Path to linked repository file (e.g., "README.md") */
+ repoFilePath: string | null;
+ /** When file was last synced from repository */
+ repoSyncedAt: string | null;
+ /** Sync status: 'none', 'synced', 'modified', 'conflict' */
+ repoSyncStatus: 'none' | 'synced' | 'modified' | 'conflict' | null;
createdAt: string;
updatedAt: string;
}
@@ -165,10 +180,14 @@ export interface FileListResponse {
}
export interface CreateFileRequest {
+ /** Contract this file belongs to (required - files must belong to a contract) */
+ contractId: string;
name?: string;
description?: string;
- transcript: TranscriptEntry[];
+ transcript?: TranscriptEntry[];
location?: string;
+ /** Initial body elements (e.g., from a template) */
+ body?: BodyElement[];
}
export interface UpdateFileRequest {
@@ -400,6 +419,23 @@ export async function restoreFileVersion(
return res.json();
}
+/**
+ * Sync a file from its linked repository file.
+ * Triggers an async operation - the file will be updated when the daemon responds.
+ * Returns 202 Accepted if the sync started successfully.
+ */
+export async function syncFileFromRepo(fileId: string): Promise<{ message: string; fileId: string }> {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/sync-from-repo`, {
+ method: "POST",
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.message || `Failed to sync file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
// =============================================================================
// LLM Tool Definitions for Version History
// =============================================================================
@@ -490,6 +526,12 @@ export type DaemonStatus = "connected" | "disconnected" | "unhealthy";
export interface TaskSummary {
id: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
+ /** Contract name (joined from contracts table) */
+ contractName: string | null;
+ /** Contract phase (joined from contracts table) */
+ contractPhase: ContractPhase | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -497,6 +539,8 @@ export interface TaskSummary {
priority: number;
progressSummary: string | null;
subtaskCount: number;
+ /** Whether this is a supervisor task (contract orchestrator) */
+ isSupervisor: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -505,6 +549,8 @@ export interface TaskSummary {
export interface Task {
id: string;
ownerId: string;
+ /** Contract this task belongs to */
+ contractId: string | null;
parentTaskId: string | null;
depth: number;
name: string;
@@ -556,6 +602,8 @@ export interface TaskListResponse {
}
export interface CreateTaskRequest {
+ /** Contract this task belongs to (required) */
+ contractId: string;
name: string;
description?: string;
plan: string;
@@ -1289,3 +1337,493 @@ export async function deleteAccount(
}
return res.json();
}
+
+// =============================================================================
+// Contract Types for Workflow Management
+// =============================================================================
+
+export type ContractPhase = "research" | "specify" | "plan" | "execute" | "review";
+export type ContractStatus = "active" | "completed" | "archived";
+export type RepositorySourceType = "remote" | "local" | "managed";
+export type RepositoryStatus = "ready" | "pending" | "creating" | "failed";
+
+export interface ContractRepository {
+ id: string;
+ contractId: string;
+ name: string;
+ repositoryUrl: string | null;
+ localPath: string | null;
+ sourceType: RepositorySourceType;
+ status: RepositoryStatus;
+ isPrimary: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ fileCount: number;
+ taskCount: number;
+ repositoryCount: number;
+ version: number;
+ createdAt: string;
+}
+
+export interface Contract {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ phase: ContractPhase;
+ status: ContractStatus;
+ /** Supervisor task ID for contract orchestration */
+ supervisorTaskId: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ContractWithRelations extends Contract {
+ repositories: ContractRepository[];
+ files: FileSummary[];
+ tasks: TaskSummary[];
+}
+
+export interface ContractEvent {
+ id: string;
+ contractId: string;
+ eventType: string;
+ previousPhase: string | null;
+ newPhase: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+export interface ContractListResponse {
+ contracts: ContractSummary[];
+ total: number;
+}
+
+export interface CreateContractRequest {
+ name: string;
+ description?: string;
+ /** Initial phase to start in (defaults to "research") */
+ initialPhase?: ContractPhase;
+}
+
+export interface UpdateContractRequest {
+ name?: string;
+ description?: string;
+ phase?: ContractPhase;
+ status?: ContractStatus;
+ version?: number;
+}
+
+export interface AddRemoteRepositoryRequest {
+ name: string;
+ repositoryUrl: string;
+ isPrimary?: boolean;
+}
+
+export interface AddLocalRepositoryRequest {
+ name: string;
+ localPath: string;
+ isPrimary?: boolean;
+}
+
+export interface CreateManagedRepositoryRequest {
+ name: string;
+ isPrimary?: boolean;
+}
+
+export interface ChangePhaseRequest {
+ phase: ContractPhase;
+}
+
+// =============================================================================
+// Contract API Functions
+// =============================================================================
+
+/**
+ * List all contracts.
+ */
+export async function listContracts(): Promise<ContractListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`);
+ if (!res.ok) {
+ throw new Error(`Failed to list contracts: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get a contract with all its relations.
+ */
+export async function getContract(id: string): Promise<ContractWithRelations> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a new contract.
+ */
+export async function createContract(
+ data: CreateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Update a contract.
+ */
+export async function updateContract(
+ id: string,
+ data: UpdateContractRequest
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to update contract: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a contract.
+ */
+export async function deleteContract(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Change contract phase.
+ */
+export async function changeContractPhase(
+ id: string,
+ phase: ContractPhase
+): Promise<ContractSummary> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/phase`, {
+ method: "POST",
+ body: JSON.stringify({ phase }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to change phase: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Get contract event history.
+ */
+export async function getContractEvents(
+ id: string
+): Promise<ContractEvent[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${id}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to get events: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Contract Repository Management
+// =============================================================================
+
+/**
+ * Add a remote repository to a contract.
+ */
+export async function addRemoteRepository(
+ contractId: string,
+ data: AddRemoteRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/remote`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add remote repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Add a local repository to a contract.
+ */
+export async function addLocalRepository(
+ contractId: string,
+ data: AddLocalRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/local`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add local repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a managed repository (daemon will create it).
+ */
+export async function createManagedRepository(
+ contractId: string,
+ data: CreateManagedRepositoryRequest
+): Promise<ContractRepository> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/managed`,
+ {
+ method: "POST",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to create managed repository: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Delete a repository from a contract.
+ */
+export async function deleteContractRepository(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to delete repository: ${res.statusText}`);
+ }
+}
+
+/**
+ * Set a repository as primary.
+ */
+export async function setRepositoryPrimary(
+ contractId: string,
+ repoId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/repositories/${repoId}/primary`,
+ {
+ method: "PUT",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to set repository as primary: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Task Association
+// =============================================================================
+
+/**
+ * Add a task to a contract.
+ */
+export async function addTaskToContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "POST",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to add task to contract: ${res.statusText}`);
+ }
+}
+
+/**
+ * Remove a task from a contract.
+ */
+export async function removeTaskFromContract(
+ contractId: string,
+ taskId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/tasks/${taskId}`,
+ {
+ method: "DELETE",
+ }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to remove task from contract: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Contract Chat Types and API
+// =============================================================================
+
+export interface ContractChatRequest {
+ message: string;
+ model?: LlmModel;
+ history?: ChatMessage[];
+}
+
+export interface ContractToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface ContractChatResponse {
+ response: string;
+ toolCalls: ContractToolCallInfo[];
+ pendingQuestions?: UserQuestion[];
+}
+
+/**
+ * Chat with a contract using LLM-powered management tools.
+ */
+export async function chatWithContract(
+ contractId: string,
+ message: string,
+ model?: LlmModel,
+ history?: ChatMessage[]
+): Promise<ContractChatResponse> {
+ const body: ContractChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Contract chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// Contract chat history types
+export interface ContractChatMessage {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant" | "error";
+ content: string;
+ toolCalls?: unknown;
+ pendingQuestions?: unknown;
+ createdAt: string;
+}
+
+export interface ContractChatHistoryResponse {
+ contractId: string;
+ conversationId: string;
+ messages: ContractChatMessage[];
+}
+
+/** Get contract chat history */
+export async function getContractChatHistory(
+ contractId: string
+): Promise<ContractChatHistoryResponse> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to fetch contract chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Clear contract chat history (starts a new conversation) */
+export async function clearContractChatHistory(
+ contractId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/contracts/${contractId}/chat/history`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to clear contract chat history: ${res.statusText}`);
+ }
+}
+
+// =============================================================================
+// Template Types and API
+// =============================================================================
+
+export interface TemplateSummary {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ elementCount: number;
+}
+
+export interface FileTemplate {
+ id: string;
+ name: string;
+ phase: ContractPhase;
+ description: string;
+ suggestedBody: BodyElement[];
+}
+
+export interface ListTemplatesResponse {
+ templates: TemplateSummary[];
+}
+
+export async function listTemplates(
+ phase?: ContractPhase
+): Promise<ListTemplatesResponse> {
+ const params = phase ? `?phase=${phase}` : "";
+ const res = await authFetch(`${API_BASE}/api/v1/templates${params}`);
+ if (!res.ok) {
+ throw new Error(`Failed to list templates: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getTemplate(id: string): Promise<FileTemplate> {
+ const res = await authFetch(`${API_BASE}/api/v1/templates/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get template: ${res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/lib/markdown.ts b/makima/frontend/src/lib/markdown.ts
new file mode 100644
index 0000000..b6e860a
--- /dev/null
+++ b/makima/frontend/src/lib/markdown.ts
@@ -0,0 +1,228 @@
+/**
+ * Markdown conversion utilities for BodyElement arrays.
+ *
+ * Provides bidirectional conversion between structured BodyElement[] and markdown strings.
+ */
+
+import { BodyElement } from "./api";
+
+/**
+ * Convert an array of BodyElements to a markdown string.
+ *
+ * Handles:
+ * - Headings: # through ###### based on level
+ * - Paragraphs: plain text with blank lines between
+ * - Code blocks: ```language\ncontent\n```
+ * - Lists: ordered (1. 2. 3.) and unordered (- - -)
+ * - Charts: rendered as fenced JSON
+ * - Images: rendered as markdown image syntax
+ */
+export function bodyToMarkdown(elements: BodyElement[]): string {
+ return elements
+ .map((elem) => {
+ switch (elem.type) {
+ case "heading": {
+ const hashes = "#".repeat(Math.min(elem.level, 6));
+ return `${hashes} ${elem.text}`;
+ }
+ case "paragraph":
+ return elem.text;
+ case "code": {
+ const lang = elem.language || "";
+ return `\`\`\`${lang}\n${elem.content}\n\`\`\``;
+ }
+ case "list": {
+ return elem.items
+ .map((item, i) => (elem.ordered ? `${i + 1}. ${item}` : `- ${item}`))
+ .join("\n");
+ }
+ case "chart": {
+ const titleStr = elem.title ? ` - ${elem.title}` : "";
+ const dataStr = JSON.stringify(elem.data, null, 2);
+ return `\`\`\`chart:${elem.chartType}${titleStr}\n${dataStr}\n\`\`\``;
+ }
+ case "image": {
+ const alt = elem.alt || "image";
+ const caption = elem.caption ? `\n*${elem.caption}*` : "";
+ return `![${alt}](${elem.src})${caption}`;
+ }
+ case "markdown":
+ // Markdown elements output their content directly
+ return elem.content;
+ default:
+ return "";
+ }
+ })
+ .filter((s) => s !== "")
+ .join("\n\n");
+}
+
+/**
+ * Parse a markdown string into an array of BodyElements.
+ *
+ * Handles:
+ * - Headings: lines starting with # through ######
+ * - Code blocks: ```language ... ```
+ * - Ordered lists: lines starting with 1. 2. etc.
+ * - Unordered lists: lines starting with - or * or +
+ * - Paragraphs: all other non-empty lines
+ */
+export function markdownToBody(markdown: string): BodyElement[] {
+ const elements: BodyElement[] = [];
+ const lines = markdown.split("\n");
+ let currentParagraph: string[] = [];
+ let inCodeBlock = false;
+ let codeBlockLanguage: string | undefined;
+ let codeBlockContent: string[] = [];
+ let currentList: { ordered: boolean; items: string[] } | null = null;
+
+ const flushParagraph = () => {
+ if (currentParagraph.length > 0) {
+ const text = currentParagraph.join("\n").trim();
+ if (text) {
+ elements.push({ type: "paragraph", text });
+ }
+ currentParagraph = [];
+ }
+ };
+
+ const flushCodeBlock = () => {
+ if (codeBlockContent.length > 0 || inCodeBlock) {
+ elements.push({
+ type: "code",
+ language: codeBlockLanguage || undefined,
+ content: codeBlockContent.join("\n"),
+ });
+ codeBlockContent = [];
+ codeBlockLanguage = undefined;
+ }
+ };
+
+ const flushList = () => {
+ if (currentList && currentList.items.length > 0) {
+ elements.push({
+ type: "list",
+ ordered: currentList.ordered,
+ items: currentList.items,
+ });
+ currentList = null;
+ }
+ };
+
+ // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
+ const convertImagesToLinks = (text: string): string => {
+ return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
+ const linkText = alt || "image";
+ return `[${linkText}](${url})`;
+ });
+ };
+
+ for (const rawLine of lines) {
+ // Check for code block fence (``` or ~~~)
+ const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
+ if (codeFenceMatch) {
+ if (!inCodeBlock) {
+ // Starting a code block
+ flushParagraph();
+ flushList();
+ inCodeBlock = true;
+ codeBlockLanguage = codeFenceMatch[2] || undefined;
+ codeBlockContent = [];
+ } else {
+ // Ending a code block
+ flushCodeBlock();
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // If inside a code block, add line as-is
+ if (inCodeBlock) {
+ codeBlockContent.push(rawLine);
+ continue;
+ }
+
+ // Convert images to links in all lines
+ const line = convertImagesToLinks(rawLine);
+
+ // Check for headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const text = headingMatch[2].trim();
+ elements.push({ type: "heading", level, text });
+ continue;
+ }
+
+ // Check for unordered list items (-, *, +)
+ const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
+ if (unorderedMatch) {
+ flushParagraph();
+ const itemText = unorderedMatch[1].trim();
+ if (currentList && currentList.ordered) {
+ // Switch from ordered to unordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: false, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Check for ordered list items (1. 2. etc)
+ const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
+ if (orderedMatch) {
+ flushParagraph();
+ const itemText = orderedMatch[1].trim();
+ if (currentList && !currentList.ordered) {
+ // Switch from unordered to ordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: true, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Empty line - flush everything
+ if (line.trim() === "") {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ // Regular text - flush list first, then add to paragraph
+ flushList();
+ currentParagraph.push(line);
+ }
+
+ // Flush any remaining content
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+ flushParagraph();
+ flushList();
+
+ return elements;
+}
+
+/**
+ * Copy markdown to clipboard.
+ * Returns true if successful, false otherwise.
+ */
+export async function copyMarkdownToClipboard(
+ elements: BodyElement[]
+): Promise<boolean> {
+ try {
+ const markdown = bodyToMarkdown(elements);
+ await navigator.clipboard.writeText(markdown);
+ return true;
+ } catch (error) {
+ console.error("Failed to copy markdown to clipboard:", error);
+ return false;
+ }
+}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index d4ca13a..496a569 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -8,6 +8,8 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
+import ContractsPage from "./routes/contracts";
+import WorkflowPage from "./routes/workflow";
import MeshPage from "./routes/mesh";
import LoginPage from "./routes/login";
import SettingsPage from "./routes/settings";
@@ -45,6 +47,30 @@ createRoot(document.getElementById("root")!).render(
}
/>
<Route
+ path="/contracts"
+ element={
+ <ProtectedRoute>
+ <ContractsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/contracts/:id"
+ element={
+ <ProtectedRoute>
+ <ContractsPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/workflow"
+ element={
+ <ProtectedRoute>
+ <WorkflowPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
path="/mesh"
element={
<ProtectedRoute>
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 7084c2e..ecdd7f2 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -1,5 +1,6 @@
import { Masthead } from "../components/Masthead";
import { Logo } from "../components/Logo";
+import { JapaneseHoverText } from "../components/JapaneseHoverText";
export default function HomePage() {
return (
@@ -13,7 +14,10 @@ export default function HomePage() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Control System
+ <JapaneseHoverText
+ japanese="支配する"
+ english="Control System"
+ />
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
Mesh Orchestration Platform
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
new file mode 100644
index 0000000..8c90804
--- /dev/null
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -0,0 +1,614 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { ContractList } from "../components/contracts/ContractList";
+import { ContractDetail } from "../components/contracts/ContractDetail";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import { createTask, getDaemonDirectories } from "../lib/api";
+import type {
+ ContractWithRelations,
+ ContractPhase,
+ ContractStatus,
+ CreateContractRequest,
+ RepositorySourceType,
+ DaemonDirectory,
+} from "../lib/api";
+
+export default function ContractsPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <ContractsPageContent />;
+}
+
+function ContractsPageContent() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const {
+ contracts,
+ loading,
+ error,
+ fetchContract,
+ saveContract,
+ editContract,
+ removeContract,
+ changePhase,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ removeRepo,
+ setRepoPrimary,
+ } = useContracts();
+
+ const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+ const [newContractDescription, setNewContractDescription] = useState("");
+ const [initialPhase, setInitialPhase] = useState<ContractPhase>("research");
+ const [repoType, setRepoType] = useState<RepositorySourceType>("remote");
+ const [repoName, setRepoName] = useState("");
+ const [repoUrl, setRepoUrl] = useState("");
+ const [repoPath, setRepoPath] = useState("");
+ const [createError, setCreateError] = useState<string | null>(null);
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+
+ // Fetch daemon directories when "local" repo type is selected
+ useEffect(() => {
+ if (repoType === "local" && isCreating) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [repoType, isCreating]);
+
+ // Load contract detail when ID changes
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchContract(id).then((contract) => {
+ setContractDetail(contract);
+ setDetailLoading(false);
+ });
+ } else {
+ setContractDetail(null);
+ }
+ }, [id, fetchContract]);
+
+ const handleSelect = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/contracts");
+ }, [navigate]);
+
+ const handleCreate = useCallback(() => {
+ setIsCreating(true);
+ }, []);
+
+ // Validate repository configuration
+ const isRepoValid = useCallback(() => {
+ if (!repoName.trim()) return false;
+ if (repoType === "remote" && !repoUrl.trim()) return false;
+ if (repoType === "local" && !repoPath.trim()) return false;
+ return true;
+ }, [repoType, repoName, repoUrl, repoPath]);
+
+ const handleCreateSubmit = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ if (!isRepoValid()) {
+ setCreateError("Repository configuration is required");
+ return;
+ }
+
+ setCreateError(null);
+
+ const data: CreateContractRequest = {
+ name: newContractName.trim(),
+ description: newContractDescription.trim() || undefined,
+ initialPhase: initialPhase !== "research" ? initialPhase : undefined,
+ };
+
+ try {
+ const contract = await saveContract(data);
+ if (contract) {
+ // Add the repository after contract creation
+ try {
+ if (repoType === "remote") {
+ await addRemoteRepo(contract.id, {
+ name: repoName.trim(),
+ repositoryUrl: repoUrl.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "local") {
+ await addLocalRepo(contract.id, {
+ name: repoName.trim(),
+ localPath: repoPath.trim(),
+ isPrimary: true,
+ });
+ } else if (repoType === "managed") {
+ await createManagedRepo(contract.id, {
+ name: repoName.trim(),
+ isPrimary: true,
+ });
+ }
+ } catch (repoError) {
+ console.error("Failed to add repository:", repoError);
+ // Still navigate to the contract - repo can be added later
+ }
+
+ // Clear form state
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ navigate(`/contracts/${contract.id}`);
+ }
+ } catch (err) {
+ setCreateError(err instanceof Error ? err.message : "Failed to create contract");
+ }
+ }, [
+ newContractName,
+ newContractDescription,
+ repoType,
+ repoName,
+ repoUrl,
+ repoPath,
+ isRepoValid,
+ saveContract,
+ addRemoteRepo,
+ addLocalRepo,
+ createManagedRepo,
+ navigate,
+ ]);
+
+ const handleCreateCancel = useCallback(() => {
+ setIsCreating(false);
+ setNewContractName("");
+ setNewContractDescription("");
+ setInitialPhase("research");
+ setRepoType("remote");
+ setRepoName("");
+ setRepoUrl("");
+ setRepoPath("");
+ setCreateError(null);
+ }, []);
+
+ const handleUpdate = useCallback(
+ async (name: string, description: string) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ name,
+ description: description || undefined,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ const handleDelete = useCallback(async () => {
+ if (contractDetail && confirm("Are you sure you want to delete this contract?")) {
+ const success = await removeContract(contractDetail.id);
+ if (success) {
+ navigate("/contracts");
+ }
+ }
+ }, [contractDetail, removeContract, navigate]);
+
+ const handlePhaseChange = useCallback(
+ async (phase: ContractPhase) => {
+ if (contractDetail) {
+ const updated = await changePhase(contractDetail.id, phase);
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, changePhase, fetchContract]
+ );
+
+ const handleStatusChange = useCallback(
+ async (status: ContractStatus) => {
+ if (contractDetail) {
+ const updated = await editContract(contractDetail.id, {
+ status,
+ version: contractDetail.version,
+ });
+ if (updated) {
+ // Refresh detail
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }
+ },
+ [contractDetail, editContract, fetchContract]
+ );
+
+ // Repository handlers
+ const handleAddRemoteRepo = useCallback(
+ async (name: string, url: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addRemoteRepo, fetchContract]
+ );
+
+ const handleAddLocalRepo = useCallback(
+ async (name: string, path: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, addLocalRepo, fetchContract]
+ );
+
+ const handleCreateManagedRepo = useCallback(
+ async (name: string, isPrimary: boolean) => {
+ if (contractDetail) {
+ await createManagedRepo(contractDetail.id, { name, isPrimary });
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, createManagedRepo, fetchContract]
+ );
+
+ const handleDeleteRepo = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await removeRepo(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, removeRepo, fetchContract]
+ );
+
+ const handleSetRepoPrimary = useCallback(
+ async (repoId: string) => {
+ if (contractDetail) {
+ await setRepoPrimary(contractDetail.id, repoId);
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ },
+ [contractDetail, setRepoPrimary, fetchContract]
+ );
+
+ // Refresh contract detail (used after file/task operations)
+ const handleRefresh = useCallback(async () => {
+ if (contractDetail) {
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ }
+ }, [contractDetail, fetchContract]);
+
+ // File/task navigation handlers
+ const handleFileSelect = useCallback(
+ (fileId: string) => {
+ navigate(`/files/${fileId}`);
+ },
+ [navigate]
+ );
+
+ const handleTaskSelect = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ // Create task within contract context
+ const handleTaskCreate = useCallback(
+ async (name: string, plan: string, repositoryUrl?: string) => {
+ if (!contractDetail) return;
+ try {
+ // Create the task with contract_id (task is automatically associated)
+ const task = await createTask({
+ contractId: contractDetail.id,
+ name,
+ plan,
+ repositoryUrl,
+ });
+ // Refresh contract detail to show new task
+ const refreshed = await fetchContract(contractDetail.id);
+ setContractDetail(refreshed);
+ // Navigate to the new task
+ navigate(`/mesh/${task.id}`);
+ } catch (e) {
+ console.error("Failed to create task:", e);
+ alert(e instanceof Error ? e.message : "Failed to create task");
+ }
+ },
+ [contractDetail, fetchContract, navigate]
+ );
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {/* Create contract modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Contract
+ </h3>
+
+ {createError && (
+ <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs">
+ {createError}
+ </div>
+ )}
+
+ <div className="space-y-4">
+ {/* Contract name */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Contract Name
+ </label>
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => setNewContractName(e.target.value)}
+ placeholder="Contract name"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ autoFocus
+ />
+ </div>
+
+ {/* Description */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Description (optional)
+ </label>
+ <textarea
+ value={newContractDescription}
+ onChange={(e) => setNewContractDescription(e.target.value)}
+ placeholder="Describe what this contract is for..."
+ rows={2}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ />
+ </div>
+
+ {/* Starting Phase */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Starting Phase
+ </label>
+ <select
+ value={initialPhase}
+ onChange={(e) => setInitialPhase(e.target.value as ContractPhase)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ >
+ <option value="research">Research</option>
+ <option value="specify">Specify</option>
+ <option value="plan">Plan</option>
+ <option value="execute">Execute</option>
+ <option value="review">Review</option>
+ </select>
+ <p className="mt-1 font-mono text-xs text-[#8b949e]">
+ Skip earlier phases if you already have requirements defined
+ </p>
+ </div>
+
+ {/* Repository Configuration */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <label className="block font-mono text-xs text-[#75aafc] uppercase mb-3">
+ Repository Configuration (Required)
+ </label>
+
+ {/* Repository type selector */}
+ <div className="flex gap-2 mb-3">
+ <button
+ type="button"
+ onClick={() => setRepoType("remote")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "remote"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Remote
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("local")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "local"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Local
+ </button>
+ <button
+ type="button"
+ onClick={() => setRepoType("managed")}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ repoType === "managed"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Managed
+ </button>
+ </div>
+
+ {/* Repository name */}
+ <div className="mb-3">
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository Name
+ </label>
+ <input
+ type="text"
+ value={repoName}
+ onChange={(e) => setRepoName(e.target.value)}
+ placeholder="e.g., my-project"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+ </div>
+
+ {/* Repository URL (for remote) */}
+ {repoType === "remote" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Repository URL
+ </label>
+ <input
+ type="text"
+ value={repoUrl}
+ onChange={(e) => setRepoUrl(e.target.value)}
+ placeholder="https://github.com/user/repo.git"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ />
+ </div>
+ )}
+
+ {/* Repository path (for local) */}
+ {repoType === "local" && (
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Local Path
+ </label>
+ <DirectoryInput
+ value={repoPath}
+ onChange={setRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/repository"
+ />
+ </div>
+ )}
+
+ {/* Managed description */}
+ {repoType === "managed" && (
+ <p className="font-mono text-xs text-[#8b949e]">
+ A managed repository will be created automatically by the daemon.
+ </p>
+ )}
+ </div>
+
+ {/* Actions */}
+ <div className="flex gap-2 justify-end pt-2">
+ <button
+ onClick={handleCreateCancel}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateSubmit}
+ disabled={!newContractName.trim() || !isRepoValid()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0">
+ {/* Contract list */}
+ <ContractList
+ contracts={contracts}
+ loading={loading}
+ onSelect={handleSelect}
+ onCreate={handleCreate}
+ selectedId={id}
+ />
+
+ {/* Contract detail or empty state */}
+ {contractDetail ? (
+ <ContractDetail
+ contract={contractDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onUpdate={handleUpdate}
+ onDelete={handleDelete}
+ onPhaseChange={handlePhaseChange}
+ onStatusChange={handleStatusChange}
+ onFileSelect={handleFileSelect}
+ onTaskSelect={handleTaskSelect}
+ onTaskCreate={handleTaskCreate}
+ onRefresh={handleRefresh}
+ onAddRemoteRepo={handleAddRemoteRepo}
+ onAddLocalRepo={handleAddLocalRepo}
+ onCreateManagedRepo={handleCreateManagedRepo}
+ onDeleteRepo={handleDeleteRepo}
+ onSetRepoPrimary={handleSetRepoPrimary}
+ />
+ ) : (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ Select a contract or create a new one
+ </p>
+ <button
+ onClick={handleCreate}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + New Contract
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 3ba2d52..6cfb3ca 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -12,8 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
-import { createTask } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task, ContractSummary } from "../lib/api";
+import { createTask, listContracts } from "../lib/api";
import { useAuth } from "../contexts/AuthContext";
export default function FilesPage() {
@@ -59,6 +59,14 @@ function FilesPageContent() {
const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<Task | null>(null);
+ // Contract selection modal state for task creation
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ const [pendingTaskData, setPendingTaskData] = useState<{ name: string; plan: string } | null>(null);
+ // Contract selection modal state for file creation
+ const [showFileContractModal, setShowFileContractModal] = useState(false);
+ const [pendingFileData, setPendingFileData] = useState<{ name: string; body?: BodyElement[] } | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef<number | null>(null);
@@ -548,10 +556,10 @@ function FilesPageContent() {
[fileDetail]
);
- // Create a mesh task from an element
+ // Create a mesh task from an element - shows contract selection modal
const handleCreateTaskFromElement = useCallback(
async (index: number, selectedText?: string) => {
- if (!fileDetail) return;
+ if (!fileDetail || contractsLoading) return;
const element = fileDetail.body[index];
if (!element) return;
@@ -578,57 +586,98 @@ function FilesPageContent() {
// Create a task name from the content
const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+ // Store pending task data and show contract selection modal
+ setPendingTaskData({ name, plan: content });
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setShowContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ },
+ [fileDetail, contractsLoading]
+ );
+
+ // Create task with selected contract
+ const handleCreateTaskWithContract = useCallback(
+ async (contractId: string) => {
+ if (!pendingTaskData || !fileDetail) return;
+ setShowContractModal(false);
try {
const task = await createTask({
- name,
- plan: content,
+ contractId,
+ name: pendingTaskData.name,
+ plan: pendingTaskData.plan,
description: `Created from ${fileDetail.name}`,
});
setCreatedTask(task);
+ setPendingTaskData(null);
} catch (err) {
console.error("Failed to create task:", err);
}
},
- [fileDetail]
+ [pendingTaskData, fileDetail]
);
+ // Open contract selection modal for file creation
const handleCreate = useCallback(async () => {
- if (creating) return;
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
+ try {
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name: `Untitled ${new Date().toLocaleDateString()}` });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ }, [creating, contractsLoading]);
+
+ // Create file with selected contract
+ const handleCreateFileWithContract = useCallback(async (contractId: string) => {
+ if (creating || !pendingFileData) return;
+ setShowFileContractModal(false);
setCreating(true);
try {
const newFile = await saveFile({
- name: `Untitled ${new Date().toLocaleDateString()}`,
+ contractId,
+ name: pendingFileData.name,
+ body: pendingFileData.body,
transcript: [],
});
if (newFile) {
+ // If there's body content, update it
+ if (pendingFileData.body && pendingFileData.body.length > 0) {
+ await editFile(newFile.id, { body: pendingFileData.body, version: newFile.version });
+ }
navigate(`/files/${newFile.id}`);
}
} finally {
setCreating(false);
+ setPendingFileData(null);
}
- }, [creating, saveFile, navigate]);
+ }, [creating, pendingFileData, saveFile, editFile, navigate]);
const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
- if (creating) return;
- setCreating(true);
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
try {
- const newFile = await saveFile({
- name,
- transcript: [],
- });
- if (newFile) {
- // Update with the parsed body
- const updated = await editFile(newFile.id, { body, version: newFile.version });
- if (updated) {
- navigate(`/files/${updated.id}`);
- } else {
- navigate(`/files/${newFile.id}`);
- }
- }
+ const response = await listContracts();
+ setContracts(response.contracts);
+ setPendingFileData({ name, body });
+ setShowFileContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
} finally {
- setCreating(false);
+ setContractsLoading(false);
}
- }, [creating, saveFile, editFile, navigate]);
+ }, [creating, contractsLoading]);
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
@@ -808,6 +857,124 @@ function FilesPageContent() {
</div>
</div>
)}
+
+ {/* Contract Selection Modal for Task Creation */}
+ {showContractModal && (
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+ <div className="bg-[#0d1117] border border-[#30363d] rounded-lg max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[#30363d] flex justify-between items-center">
+ <h2 className="text-lg font-semibold text-white">Select Contract for Task</h2>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ }}
+ className="text-[#8b949e] hover:text-white"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#8b949e] mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowContractModal(false);
+ setPendingTaskData(null);
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#238636] hover:bg-[#2ea043] text-white rounded-md text-sm"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleCreateTaskWithContract(contract.id)}
+ className="w-full text-left p-3 rounded-md border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-white font-medium">{contract.name}</span>
+ <span className="text-xs px-2 py-0.5 rounded bg-[#21262d] text-[#8b949e]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-sm text-[#8b949e] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Contract Selection Modal for File Creation */}
+ {showFileContractModal && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Select Contract for File</h2>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ }}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found. Create a contract first.</p>
+ <button
+ onClick={() => {
+ setShowFileContractModal(false);
+ setPendingFileData(null);
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleCreateFileWithContract(contract.id)}
+ className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span>
+ <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index aaba90c..36c468b 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -2,9 +2,11 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Masthead } from "../components/Masthead";
import { SpeakerPanel } from "../components/listen/SpeakerPanel";
import { TranscriptPanel } from "../components/listen/TranscriptPanel";
-import { ControlPanel } from "../components/listen/ControlPanel";
+import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel";
import { useMicrophone } from "../hooks/useMicrophone";
import { useWebSocket } from "../hooks/useWebSocket";
+import { listContracts } from "../lib/api";
+import { useAuth } from "../contexts/AuthContext";
export default function ListenPage() {
const [isListening, setIsListening] = useState(false);
@@ -12,6 +14,37 @@ export default function ListenPage() {
const [permissionRequested, setPermissionRequested] = useState(false);
const isListeningRef = useRef(false);
+ // Contract selection state
+ const [contracts, setContracts] = useState<ContractOption[]>([]);
+ const [selectedContractId, setSelectedContractId] = useState<string | null>(null);
+ const [contractsLoading, setContractsLoading] = useState(true);
+ const { session, isAuthenticated } = useAuth();
+
+ // Fetch contracts on mount
+ useEffect(() => {
+ if (!isAuthenticated) {
+ setContractsLoading(false);
+ return;
+ }
+
+ async function fetchContracts() {
+ try {
+ const response = await listContracts();
+ setContracts(
+ response.contracts.map((c) => ({
+ id: c.id,
+ name: c.name,
+ }))
+ );
+ } catch (err) {
+ console.error("Failed to fetch contracts:", err);
+ } finally {
+ setContractsLoading(false);
+ }
+ }
+ fetchContracts();
+ }, [isAuthenticated]);
+
// Keep ref in sync with state for use in callbacks
useEffect(() => {
isListeningRef.current = isListening;
@@ -108,9 +141,11 @@ export default function ListenPage() {
}
// Both microphone and WebSocket are ready - start the session
- ws.startSession(mic.sampleRate, mic.channels);
+ // Pass contract_id and auth token if available
+ const authToken = session?.access_token || null;
+ ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken);
setIsListening(true);
- }, [isListening, mic, ws]);
+ }, [isListening, mic, ws, selectedContractId, session]);
const handleNew = useCallback(() => {
// Stop current session - backend auto-saves transcript on disconnect
@@ -152,6 +187,10 @@ export default function ListenPage() {
onToggle={handleToggle}
onNew={handleNew}
error={error}
+ contracts={contracts}
+ selectedContractId={selectedContractId}
+ onContractChange={setSelectedContractId}
+ contractsLoading={contractsLoading}
/>
</div>
</main>
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
index 7ecf96d..d067865 100644
--- a/makima/frontend/src/routes/mesh.tsx
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -7,8 +7,9 @@ import { TaskOutput } from "../components/mesh/TaskOutput";
import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
import { useTasks } from "../hooks/useTasks";
import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
-import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
-import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories } from "../lib/api";
+import { DirectoryInput } from "../components/mesh/DirectoryInput";
import { useAuth } from "../contexts/AuthContext";
// View modes for the task detail page
@@ -91,6 +92,17 @@ export default function MeshPage() {
const [creating, setCreating] = useState(false);
const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
+ // Contract selection modal state
+ const [showContractModal, setShowContractModal] = useState(false);
+ const [contracts, setContracts] = useState<ContractSummary[]>([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
+ // Task creation modal (step 2)
+ const [modalStep, setModalStep] = useState<1 | 2>(1);
+ const [selectedContract, setSelectedContract] = useState<ContractWithRelations | null>(null);
+ const [daemonDirectories, setDaemonDirectories] = useState<DaemonDirectory[]>([]);
+ const [newTaskName, setNewTaskName] = useState("");
+ const [newTaskRepoUrl, setNewTaskRepoUrl] = useState<string | null>(null);
+ const [newTaskTargetPath, setNewTaskTargetPath] = useState("");
// Track which subtask's output we're viewing (null = parent task)
const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null);
const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null);
@@ -139,6 +151,14 @@ export default function MeshPage() {
// Only process output for the task we're currently viewing
if (event.taskId === activeOutputTaskId) {
setTaskOutputEntries((prev) => {
+ // For auth_required, only allow one per task (replace existing)
+ if (event.messageType === "auth_required") {
+ const hasExisting = prev.some(e => e.messageType === "auth_required");
+ if (hasExisting) {
+ return prev; // Skip duplicate auth_required
+ }
+ }
+
// Deduplicate by checking if last entry is identical
// This prevents duplicates from React StrictMode or WebSocket reconnects
const lastEntry = prev[prev.length - 1];
@@ -383,13 +403,63 @@ export default function MeshPage() {
[editTask, taskDetail]
);
+ // Open contract selection modal
const handleCreate = useCallback(async () => {
- if (creating) return;
+ if (creating || contractsLoading) return;
+ setContractsLoading(true);
+ try {
+ const [contractsResponse, directoriesResponse] = await Promise.all([
+ listContracts(),
+ getDaemonDirectories().catch(() => ({ directories: [] })),
+ ]);
+ setContracts(contractsResponse.contracts);
+ setDaemonDirectories(directoriesResponse.directories);
+ setModalStep(1);
+ setSelectedContract(null);
+ setNewTaskName("");
+ setNewTaskRepoUrl(null);
+ setNewTaskTargetPath("");
+ setShowContractModal(true);
+ } catch (e) {
+ console.error("Failed to load contracts:", e);
+ } finally {
+ setContractsLoading(false);
+ }
+ }, [creating, contractsLoading]);
+
+ // Handle contract selection and move to step 2
+ const handleSelectContract = useCallback(async (contractSummary: ContractSummary) => {
+ try {
+ const contract = await getContract(contractSummary.id);
+ setSelectedContract(contract);
+ setNewTaskName(`Task for ${contract.name}`);
+ // Pre-select primary repository if available
+ const primaryRepo = contract.repositories.find((r) => r.isPrimary && r.status === "ready");
+ if (primaryRepo) {
+ setNewTaskRepoUrl(primaryRepo.repositoryUrl);
+ } else {
+ // Otherwise select first ready repository
+ const firstReady = contract.repositories.find((r) => r.status === "ready");
+ setNewTaskRepoUrl(firstReady?.repositoryUrl || null);
+ }
+ setModalStep(2);
+ } catch (e) {
+ console.error("Failed to load contract details:", e);
+ }
+ }, []);
+
+ // Create task with configured options
+ const handleCreateTask = useCallback(async () => {
+ if (creating || !selectedContract) return;
+ setShowContractModal(false);
setCreating(true);
try {
const newTask = await saveTask({
- name: `Task ${new Date().toLocaleDateString()}`,
+ contractId: selectedContract.id,
+ name: newTaskName || `Task for ${selectedContract.name}`,
plan: "# Plan\n\nDescribe what this task should accomplish...",
+ repositoryUrl: newTaskRepoUrl || undefined,
+ targetRepoPath: newTaskTargetPath || undefined,
});
if (newTask) {
navigate(`/mesh/${newTask.id}`);
@@ -397,13 +467,29 @@ export default function MeshPage() {
} finally {
setCreating(false);
}
- }, [creating, saveTask, navigate]);
+ }, [creating, saveTask, navigate, selectedContract, newTaskName, newTaskRepoUrl, newTaskTargetPath]);
+
+ // Close modal and reset state
+ const handleCloseModal = useCallback(() => {
+ setShowContractModal(false);
+ setModalStep(1);
+ setSelectedContract(null);
+ setNewTaskName("");
+ setNewTaskRepoUrl(null);
+ setNewTaskTargetPath("");
+ }, []);
const handleCreateSubtask = useCallback(async () => {
if (!taskDetail || creating) return;
+ // Subtasks inherit contract_id from parent
+ if (!taskDetail.contractId) {
+ console.error("Parent task has no contract_id");
+ return;
+ }
setCreating(true);
try {
const newTask = await saveTask({
+ contractId: taskDetail.contractId,
name: `Subtask of ${taskDetail.name}`,
plan: "# Plan\n\nDescribe what this subtask should accomplish...",
parentTaskId: taskDetail.id,
@@ -597,6 +683,7 @@ export default function MeshPage() {
onCreateSubtask={handleCreateSubtask}
onToggleSubtaskOutput={handleToggleSubtaskOutput}
viewingSubtaskId={viewingSubtaskId}
+ onViewContract={(contractId) => navigate(`/contracts/${contractId}`)}
/>
</div>
)}
@@ -662,6 +749,159 @@ export default function MeshPage() {
</div>
</div>
</main>
+
+ {/* Task Creation Modal (Two Steps) */}
+ {showContractModal && (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] max-w-lg w-full mx-4 max-h-[80vh] flex flex-col">
+ <div className="p-4 border-b border-[rgba(117,170,252,0.15)] flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ {modalStep === 2 && (
+ <button
+ onClick={() => setModalStep(1)}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ title="Back to contract selection"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
+ </svg>
+ </button>
+ )}
+ <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">
+ {modalStep === 1 ? "Select Contract" : "Configure Task"}
+ </h2>
+ </div>
+ <button
+ onClick={handleCloseModal}
+ className="text-[#7788aa] hover:text-[#9bc3ff] transition-colors"
+ >
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ <div className="p-4 overflow-y-auto flex-1">
+ {modalStep === 1 ? (
+ // Step 1: Select Contract
+ contracts.length === 0 ? (
+ <div className="text-center py-8">
+ <p className="text-[#7788aa] font-mono text-xs mb-4">No contracts found.</p>
+ <button
+ onClick={() => {
+ handleCloseModal();
+ navigate("/contracts");
+ }}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors"
+ >
+ Create Contract
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {contracts.map((contract) => (
+ <button
+ key={contract.id}
+ onClick={() => handleSelectContract(contract)}
+ className="w-full text-left p-3 border border-[rgba(117,170,252,0.15)] bg-[#0a1525] hover:border-[rgba(117,170,252,0.35)] hover:bg-[#0d1b2d] transition-colors"
+ >
+ <div className="flex items-center justify-between">
+ <span className="text-[#9bc3ff] font-mono text-xs">{contract.name}</span>
+ <span className="text-[10px] font-mono uppercase px-2 py-0.5 border border-[rgba(117,170,252,0.25)] text-[#75aafc]">
+ {contract.phase}
+ </span>
+ </div>
+ {contract.description && (
+ <p className="text-[10px] font-mono text-[#7788aa] mt-1 line-clamp-2">{contract.description}</p>
+ )}
+ <div className="flex gap-3 mt-2 text-[10px] font-mono text-[#556677]">
+ <span>{contract.taskCount} tasks</span>
+ <span>{contract.repositoryCount} repos</span>
+ </div>
+ </button>
+ ))}
+ </div>
+ )
+ ) : (
+ // Step 2: Configure Task
+ selectedContract && (
+ <div className="space-y-4">
+ {/* Contract badge */}
+ <div className="flex items-center gap-2 text-xs font-mono text-[#7788aa]">
+ <span>Contract:</span>
+ <span className="text-[#9bc3ff]">{selectedContract.name}</span>
+ </div>
+
+ {/* Task name */}
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Task Name</label>
+ <input
+ type="text"
+ value={newTaskName}
+ onChange={(e) => setNewTaskName(e.target.value)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="Task name"
+ />
+ </div>
+
+ {/* Repository selection */}
+ {selectedContract.repositories.length > 0 && (
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Repository</label>
+ <select
+ value={newTaskRepoUrl || ""}
+ onChange={(e) => setNewTaskRepoUrl(e.target.value || null)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ >
+ <option value="">No repository</option>
+ {selectedContract.repositories
+ .filter((r) => r.status === "ready")
+ .map((repo) => (
+ <option key={repo.id} value={repo.repositoryUrl || repo.localPath || ""}>
+ {repo.name}
+ {repo.isPrimary && " (primary)"}
+ </option>
+ ))}
+ </select>
+ <p className="text-[10px] font-mono text-[#556677]">
+ The repository this task will work on.
+ </p>
+ </div>
+ )}
+
+ {/* Target repo path with DirectoryInput */}
+ {newTaskRepoUrl && (
+ <div className="space-y-1">
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa]">Target Repository Path</label>
+ <DirectoryInput
+ value={newTaskTargetPath}
+ onChange={setNewTaskTargetPath}
+ suggestions={daemonDirectories}
+ placeholder="/path/to/your/local/repo"
+ repoUrl={newTaskRepoUrl}
+ />
+ <p className="text-[10px] font-mono text-[#556677]">
+ Path where the task will push/merge changes. Leave empty to configure later.
+ </p>
+ </div>
+ )}
+
+ {/* Create button */}
+ <div className="pt-2">
+ <button
+ onClick={handleCreateTask}
+ disabled={creating}
+ className="w-full px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] disabled:opacity-50 transition-colors"
+ >
+ {creating ? "Creating..." : "Create Task"}
+ </button>
+ </div>
+ </div>
+ )
+ )}
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
index 6d56e67..7ca40ba 100644
--- a/makima/frontend/src/routes/settings.tsx
+++ b/makima/frontend/src/routes/settings.tsx
@@ -10,8 +10,10 @@ import {
changePassword,
changeEmail,
deleteAccount,
+ listDaemons,
type ApiKeyInfo,
type CreateApiKeyResponse,
+ type Daemon,
} from "../lib/api";
// =============================================================================
@@ -297,8 +299,22 @@ export default function SettingsPage() {
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
+ // Daemon state
+ const [daemons, setDaemons] = useState<Daemon[]>([]);
+ const [daemonsLoading, setDaemonsLoading] = useState(true);
+ const [daemonsError, setDaemonsError] = useState<string | null>(null);
+
useEffect(() => {
loadApiKey();
+ loadDaemons();
+ }, []);
+
+ // Auto-refresh daemons every 30 seconds
+ useEffect(() => {
+ const interval = setInterval(() => {
+ loadDaemons();
+ }, 30000);
+ return () => clearInterval(interval);
}, []);
const loadApiKey = async () => {
@@ -314,6 +330,18 @@ export default function SettingsPage() {
}
};
+ const loadDaemons = async () => {
+ try {
+ setDaemonsError(null);
+ const response = await listDaemons();
+ setDaemons(response.daemons);
+ } catch (err) {
+ setDaemonsError(err instanceof Error ? err.message : "Failed to load daemons");
+ } finally {
+ setDaemonsLoading(false);
+ }
+ };
+
const handleCreate = async () => {
try {
setActionLoading(true);
@@ -579,6 +607,91 @@ export default function SettingsPage() {
Then run: <code className="text-green-400">makima-daemon</code>
</p>
</section>
+
+ {/* Connected Daemons */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <div className="flex items-center justify-between mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa]">
+ Daemons
+ </h2>
+ {daemons.length > 0 && (
+ <span className="text-[10px] font-mono text-[#556677]">
+ ({daemons.filter(d => d.status === "connected").length} connected / {daemons.length} total)
+ </span>
+ )}
+ </div>
+ <button
+ onClick={loadDaemons}
+ disabled={daemonsLoading}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50"
+ title="Refresh"
+ >
+ {daemonsLoading ? "..." : "↻"}
+ </button>
+ </div>
+
+ {daemonsError && <ErrorAlert>{daemonsError}</ErrorAlert>}
+
+ {daemonsLoading && daemons.length === 0 ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : daemons.length === 0 ? (
+ <div className="text-center py-4">
+ <p className="text-[#7788aa] font-mono text-xs mb-2">No daemons connected</p>
+ <p className="text-[#556677] font-mono text-[10px]">
+ Start a daemon to enable task execution
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {daemons.map((daemon) => (
+ <div
+ key={daemon.id}
+ className="border border-[rgba(117,170,252,0.15)] bg-[#0a1525] p-3"
+ >
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-xs text-[#9bc3ff]">
+ {daemon.hostname || "Unknown Host"}
+ </span>
+ <span
+ className={`text-[10px] font-mono uppercase px-2 py-0.5 border ${
+ daemon.status === "connected"
+ ? "text-green-400 border-green-700/50 bg-green-900/20"
+ : daemon.status === "unhealthy"
+ ? "text-yellow-400 border-yellow-700/50 bg-yellow-900/20"
+ : "text-[#8899aa] border-[rgba(117,170,252,0.25)]"
+ }`}
+ >
+ {daemon.status}
+ </span>
+ </div>
+ <div className="font-mono text-[10px] text-[#7788aa] space-y-1">
+ <div className="flex justify-between">
+ <span>Tasks</span>
+ <span className="text-[#9bc3ff]">
+ {daemon.currentTaskCount} / {daemon.maxConcurrentTasks}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span>Connected</span>
+ <span className="text-[#75aafc]">
+ {new Date(daemon.connectedAt).toLocaleString()}
+ </span>
+ </div>
+ {daemon.machineId && (
+ <div className="flex justify-between">
+ <span>Machine</span>
+ <span className="text-[#556677] truncate ml-2" title={daemon.machineId}>
+ {daemon.machineId.substring(0, 16)}...
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
</div>
{/* Right Column */}
diff --git a/makima/frontend/src/routes/workflow.tsx b/makima/frontend/src/routes/workflow.tsx
new file mode 100644
index 0000000..cb72e9e
--- /dev/null
+++ b/makima/frontend/src/routes/workflow.tsx
@@ -0,0 +1,205 @@
+import { useState, useCallback, useEffect, useMemo } from "react";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { WorkflowBoard } from "../components/workflow/WorkflowBoard";
+import { useContracts } from "../hooks/useContracts";
+import { useAuth } from "../contexts/AuthContext";
+import type { ContractPhase, ContractStatus } from "../lib/api";
+
+type StatusFilter = "all" | ContractStatus;
+
+export default function WorkflowPage() {
+ const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
+ const navigate = useNavigate();
+
+ // Redirect to login if not authenticated (when auth is configured)
+ useEffect(() => {
+ if (!authLoading && isAuthConfigured && !isAuthenticated) {
+ navigate("/login");
+ }
+ }, [authLoading, isAuthConfigured, isAuthenticated, navigate]);
+
+ // Show loading while checking auth
+ if (authLoading) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex items-center justify-center">
+ <p className="text-[#7788aa] font-mono text-sm">Loading...</p>
+ </main>
+ </div>
+ );
+ }
+
+ // Don't render if not authenticated (will redirect)
+ if (isAuthConfigured && !isAuthenticated) {
+ return null;
+ }
+
+ return <WorkflowPageContent />;
+}
+
+function WorkflowPageContent() {
+ const navigate = useNavigate();
+ const { contracts, loading, error, changePhase, saveContract } = useContracts();
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
+ const [isCreating, setIsCreating] = useState(false);
+ const [newContractName, setNewContractName] = useState("");
+
+ // Filter contracts by status
+ const filteredContracts = useMemo(() => {
+ if (statusFilter === "all") {
+ return contracts;
+ }
+ return contracts.filter((c) => c.status === statusFilter);
+ }, [contracts, statusFilter]);
+
+ const handleContractClick = useCallback(
+ (contractId: string) => {
+ navigate(`/contracts/${contractId}`);
+ },
+ [navigate]
+ );
+
+ const handlePhaseChange = useCallback(
+ async (contractId: string, newPhase: ContractPhase) => {
+ await changePhase(contractId, newPhase);
+ },
+ [changePhase]
+ );
+
+ const handleCreateContract = useCallback(async () => {
+ if (!newContractName.trim()) return;
+ const contract = await saveContract({
+ name: newContractName.trim(),
+ });
+ if (contract) {
+ setNewContractName("");
+ setIsCreating(false);
+ navigate(`/contracts/${contract.id}`);
+ }
+ }, [newContractName, saveContract, navigate]);
+
+ const handleCancelCreate = useCallback(() => {
+ setNewContractName("");
+ setIsCreating(false);
+ }, []);
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+ <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden">
+ {error && (
+ <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Header with filter and create button */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-4">
+ <h1 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider">
+ Board
+ </h1>
+ {/* Status filter */}
+ <div className="flex items-center gap-1">
+ {(["all", "active", "completed", "archived"] as StatusFilter[]).map(
+ (status) => (
+ <button
+ key={status}
+ onClick={() => setStatusFilter(status)}
+ className={`
+ px-2 py-1 font-mono text-[10px] uppercase transition-colors
+ ${
+ statusFilter === status
+ ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]"
+ : "text-[#555] border border-transparent hover:text-[#75aafc]"
+ }
+ `}
+ >
+ {status}
+ </button>
+ )
+ )}
+ </div>
+ </div>
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ + New Contract
+ </button>
+ </div>
+
+ {/* Create contract modal */}
+ {isCreating && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+ <div className="w-full max-w-md p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
+ <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
+ Create Contract
+ </h3>
+ <div className="space-y-4">
+ <input
+ type="text"
+ value={newContractName}
+ onChange={(e) => setNewContractName(e.target.value)}
+ placeholder="Contract name"
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreateContract();
+ if (e.key === "Escape") handleCancelCreate();
+ }}
+ />
+ <div className="flex gap-2 justify-end">
+ <button
+ onClick={handleCancelCreate}
+ className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCreateContract}
+ disabled={!newContractName.trim()}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Board */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ {loading ? (
+ <div className="h-full flex items-center justify-center">
+ <p className="font-mono text-sm text-[#555]">Loading...</p>
+ </div>
+ ) : filteredContracts.length === 0 && statusFilter === "all" ? (
+ <div className="h-full flex items-center justify-center">
+ <div className="text-center">
+ <p className="font-mono text-sm text-[#555] mb-4">
+ No contracts yet
+ </p>
+ <button
+ onClick={() => setIsCreating(true)}
+ className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ + Create First Contract
+ </button>
+ </div>
+ </div>
+ ) : (
+ <WorkflowBoard
+ contracts={filteredContracts}
+ onContractClick={handleContractClick}
+ onPhaseChange={handlePhaseChange}
+ />
+ )}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 3d441ed..034fc29 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/contexts/authcontext.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20250110100000_create_contracts.sql b/makima/migrations/20250110100000_create_contracts.sql
new file mode 100644
index 0000000..3532b8e
--- /dev/null
+++ b/makima/migrations/20250110100000_create_contracts.sql
@@ -0,0 +1,71 @@
+-- Contracts table (hierarchical - contracts can contain sub-contracts)
+CREATE TABLE IF NOT EXISTS contracts (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE,
+ parent_contract_id UUID REFERENCES contracts(id) ON DELETE CASCADE, -- NULL = root contract
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ phase VARCHAR(32) NOT NULL DEFAULT 'research', -- research/specify/plan/execute/review
+ status VARCHAR(32) NOT NULL DEFAULT 'active', -- active/completed/archived
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_contracts_owner_id ON contracts(owner_id);
+CREATE INDEX idx_contracts_parent_contract_id ON contracts(parent_contract_id);
+CREATE INDEX idx_contracts_status ON contracts(status);
+CREATE INDEX idx_contracts_phase ON contracts(phase);
+
+-- Contract repositories (1-to-many: contract has multiple repos)
+-- Supports: existing remote (GitHub, etc), existing local, or new repos created by daemon
+CREATE TABLE IF NOT EXISTS contract_repositories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL, -- display name / repo name for creation
+ repository_url VARCHAR(512), -- NULL until created (for new repos)
+ local_path VARCHAR(512), -- local filesystem path (for local repos)
+ source_type VARCHAR(32) NOT NULL DEFAULT 'remote', -- remote/local/managed
+ status VARCHAR(32) NOT NULL DEFAULT 'ready', -- ready/pending/creating/failed
+ is_primary BOOLEAN NOT NULL DEFAULT false, -- primary repo for task defaults
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- source_type values:
+-- 'remote' = existing remote repo (GitHub, GitLab, etc) - has repository_url
+-- 'local' = existing local repo - has local_path
+-- 'managed' = new repo created/managed by Makima daemon - gets repository_url after creation
+
+-- status values:
+-- 'ready' = repo is usable (existing repos start here)
+-- 'pending' = waiting for daemon to create
+-- 'creating' = daemon is creating the repo
+-- 'failed' = creation failed
+
+CREATE INDEX idx_contract_repositories_contract_id ON contract_repositories(contract_id);
+CREATE INDEX idx_contract_repositories_status ON contract_repositories(status);
+-- Only one primary per contract
+CREATE UNIQUE INDEX idx_contract_repositories_primary ON contract_repositories(contract_id) WHERE is_primary = true;
+
+-- Add contract_id to files table (one-to-one)
+ALTER TABLE files ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL;
+ALTER TABLE files ADD COLUMN IF NOT EXISTS contract_phase VARCHAR(32);
+CREATE INDEX IF NOT EXISTS idx_files_contract_id ON files(contract_id);
+
+-- Add contract_id to tasks table (already nullable for backward compat)
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_tasks_contract_id ON tasks(contract_id);
+
+-- Contract events for audit trail
+CREATE TABLE IF NOT EXISTS contract_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
+ event_type VARCHAR(64) NOT NULL, -- phase_change, file_added, task_added, status_change
+ previous_phase VARCHAR(32),
+ new_phase VARCHAR(32),
+ event_data JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_contract_events_contract_id ON contract_events(contract_id);
diff --git a/makima/migrations/20250112000000_cascade_delete_contract_children.sql b/makima/migrations/20250112000000_cascade_delete_contract_children.sql
new file mode 100644
index 0000000..6ef8a43
--- /dev/null
+++ b/makima/migrations/20250112000000_cascade_delete_contract_children.sql
@@ -0,0 +1,15 @@
+-- Change files and tasks to cascade delete when contract is deleted
+-- Previously these were ON DELETE SET NULL, which orphaned records
+
+-- Drop existing foreign key constraints
+ALTER TABLE files DROP CONSTRAINT IF EXISTS files_contract_id_fkey;
+ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_contract_id_fkey;
+
+-- Re-add with CASCADE
+ALTER TABLE files
+ ADD CONSTRAINT files_contract_id_fkey
+ FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE;
+
+ALTER TABLE tasks
+ ADD CONSTRAINT tasks_contract_id_fkey
+ FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE;
diff --git a/makima/migrations/20250112100000_create_contract_chat_history.sql b/makima/migrations/20250112100000_create_contract_chat_history.sql
new file mode 100644
index 0000000..c4d71d8
--- /dev/null
+++ b/makima/migrations/20250112100000_create_contract_chat_history.sql
@@ -0,0 +1,33 @@
+-- Create contract_chat_conversations table for storing conversation threads per contract
+CREATE TABLE IF NOT EXISTS contract_chat_conversations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
+ owner_id UUID NOT NULL,
+ name VARCHAR(255),
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_contract_chat_conversations_contract ON contract_chat_conversations(contract_id);
+CREATE INDEX idx_contract_chat_conversations_owner ON contract_chat_conversations(owner_id);
+CREATE INDEX idx_contract_chat_conversations_active ON contract_chat_conversations(contract_id, is_active);
+
+CREATE TRIGGER update_contract_chat_conversations_updated_at
+ BEFORE UPDATE ON contract_chat_conversations
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Create contract_chat_messages table for individual messages
+CREATE TABLE IF NOT EXISTS contract_chat_messages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ conversation_id UUID NOT NULL REFERENCES contract_chat_conversations(id) ON DELETE CASCADE,
+ role VARCHAR(16) NOT NULL CHECK (role IN ('user', 'assistant', 'error')),
+ content TEXT NOT NULL,
+ tool_calls JSONB,
+ pending_questions JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_contract_chat_messages_conversation ON contract_chat_messages(conversation_id);
+CREATE INDEX idx_contract_chat_messages_created ON contract_chat_messages(created_at);
diff --git a/makima/migrations/20250113000000_add_repo_file_path.sql b/makima/migrations/20250113000000_add_repo_file_path.sql
new file mode 100644
index 0000000..eb7cec2
--- /dev/null
+++ b/makima/migrations/20250113000000_add_repo_file_path.sql
@@ -0,0 +1,15 @@
+-- Add repository file path linking to files
+-- Files can now be linked to specific file paths in repositories
+
+ALTER TABLE files ADD COLUMN repo_file_path VARCHAR(500) NULL;
+ALTER TABLE files ADD COLUMN repo_synced_at TIMESTAMPTZ NULL;
+ALTER TABLE files ADD COLUMN repo_sync_status VARCHAR(50) DEFAULT 'none';
+-- Status: 'none' (not linked), 'synced' (up to date), 'modified' (local changes), 'conflict'
+
+-- Index for efficient lookup of files by repo path within a contract
+CREATE INDEX idx_files_repo_file_path ON files(contract_id, repo_file_path)
+WHERE repo_file_path IS NOT NULL;
+
+COMMENT ON COLUMN files.repo_file_path IS 'Path to the file in the repository (e.g., README.md, docs/design.md)';
+COMMENT ON COLUMN files.repo_synced_at IS 'When the file was last synced from the repository';
+COMMENT ON COLUMN files.repo_sync_status IS 'Sync status: none, synced, modified, conflict';
diff --git a/makima/migrations/20250114000000_task_tree_structure.sql b/makima/migrations/20250114000000_task_tree_structure.sql
new file mode 100644
index 0000000..489a702
--- /dev/null
+++ b/makima/migrations/20250114000000_task_tree_structure.sql
@@ -0,0 +1,41 @@
+-- Task tree structure changes for supervisor architecture
+-- - Remove depth constraint (supervisor controls task hierarchy)
+-- - Add checkpoint tracking
+-- - Add is_supervisor flag
+-- - Add supervisor_task_id to contracts
+
+-- Drop the depth constraint (supervisor handles task spawning rules at application level)
+ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_depth_check;
+
+-- Add is_supervisor flag to identify contract supervisor tasks
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS is_supervisor BOOLEAN NOT NULL DEFAULT false;
+
+-- Git checkpoint tracking
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_checkpoint_sha VARCHAR(40);
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS checkpoint_count INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS checkpoint_message TEXT;
+
+-- Conversation state preservation for task branches
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS conversation_state JSONB;
+
+-- Daemon migration tracking
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS migrated_from_daemon_id UUID;
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_active_daemon_id UUID;
+
+-- Add supervisor_task_id to contracts
+ALTER TABLE contracts ADD COLUMN IF NOT EXISTS supervisor_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL;
+
+-- Index for tree queries
+CREATE INDEX IF NOT EXISTS idx_tasks_tree_path ON tasks(contract_id, parent_task_id, created_at);
+
+-- Index for supervisor lookup
+CREATE INDEX IF NOT EXISTS idx_tasks_is_supervisor ON tasks(contract_id, is_supervisor) WHERE is_supervisor = true;
+
+-- Index for checkpoint lookup
+CREATE INDEX IF NOT EXISTS idx_tasks_checkpoint_sha ON tasks(last_checkpoint_sha) WHERE last_checkpoint_sha IS NOT NULL;
+
+COMMENT ON COLUMN tasks.is_supervisor IS 'True for contract supervisor tasks. Only supervisors can spawn new tasks.';
+COMMENT ON COLUMN tasks.last_checkpoint_sha IS 'Git commit SHA of the most recent checkpoint';
+COMMENT ON COLUMN tasks.checkpoint_count IS 'Number of checkpoints created by this task';
+COMMENT ON COLUMN tasks.conversation_state IS 'Saved conversation context for task resumption';
+COMMENT ON COLUMN contracts.supervisor_task_id IS 'The long-running supervisor task that orchestrates this contract';
diff --git a/makima/migrations/20250114000001_task_checkpoints.sql b/makima/migrations/20250114000001_task_checkpoints.sql
new file mode 100644
index 0000000..8692466
--- /dev/null
+++ b/makima/migrations/20250114000001_task_checkpoints.sql
@@ -0,0 +1,24 @@
+-- Task checkpoints table for tracking git commit history per task
+-- Enables branching from any checkpoint
+
+CREATE TABLE IF NOT EXISTS task_checkpoints (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ checkpoint_number INTEGER NOT NULL,
+ commit_sha VARCHAR(40) NOT NULL,
+ branch_name VARCHAR(255) NOT NULL,
+ message TEXT NOT NULL,
+ files_changed JSONB, -- Array of {path, action: 'A'|'M'|'D'}
+ lines_added INTEGER DEFAULT 0,
+ lines_removed INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE(task_id, checkpoint_number)
+);
+
+CREATE INDEX idx_task_checkpoints_task_id ON task_checkpoints(task_id);
+CREATE INDEX idx_task_checkpoints_commit_sha ON task_checkpoints(commit_sha);
+
+COMMENT ON TABLE task_checkpoints IS 'Git commit history for tasks, enabling branching from any checkpoint';
+COMMENT ON COLUMN task_checkpoints.checkpoint_number IS 'Sequential checkpoint number within this task';
+COMMENT ON COLUMN task_checkpoints.files_changed IS 'JSON array of {path, action} for files modified in this commit';
diff --git a/makima/migrations/20250114000002_daemon_capabilities.sql b/makima/migrations/20250114000002_daemon_capabilities.sql
new file mode 100644
index 0000000..0c2e1c2
--- /dev/null
+++ b/makima/migrations/20250114000002_daemon_capabilities.sql
@@ -0,0 +1,27 @@
+-- Daemon capabilities for multi-daemon work distribution
+-- Adds selection scoring and task assignment tracking
+
+-- Extend daemons table for multi-daemon selection
+ALTER TABLE daemons ADD COLUMN IF NOT EXISTS capacity_score INTEGER DEFAULT 100;
+ALTER TABLE daemons ADD COLUMN IF NOT EXISTS task_queue_length INTEGER DEFAULT 0;
+ALTER TABLE daemons ADD COLUMN IF NOT EXISTS supports_migration BOOLEAN DEFAULT true;
+
+-- Track active task assignments per daemon
+-- This allows moving tasks between daemons
+CREATE TABLE IF NOT EXISTS daemon_task_assignments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ daemon_id UUID NOT NULL REFERENCES daemons(id) ON DELETE CASCADE,
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ status VARCHAR(32) NOT NULL DEFAULT 'active', -- active, migrating, completed
+
+ UNIQUE(task_id) -- A task can only be assigned to one daemon at a time
+);
+
+CREATE INDEX idx_daemon_task_assignments_daemon_id ON daemon_task_assignments(daemon_id);
+CREATE INDEX idx_daemon_task_assignments_status ON daemon_task_assignments(status);
+
+COMMENT ON COLUMN daemons.capacity_score IS 'Relative capacity score for daemon selection (higher = more capacity)';
+COMMENT ON COLUMN daemons.task_queue_length IS 'Number of tasks queued/waiting on this daemon';
+COMMENT ON COLUMN daemons.supports_migration IS 'Whether this daemon supports task migration';
+COMMENT ON TABLE daemon_task_assignments IS 'Tracks task-to-daemon assignments for migration support';
diff --git a/makima/migrations/20250114000003_supervisor_state.sql b/makima/migrations/20250114000003_supervisor_state.sql
new file mode 100644
index 0000000..bcfe5e9
--- /dev/null
+++ b/makima/migrations/20250114000003_supervisor_state.sql
@@ -0,0 +1,31 @@
+-- Supervisor state persistence for resumability
+-- Stores conversation history and pending task state for supervisor tasks
+
+CREATE TABLE IF NOT EXISTS supervisor_states (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ conversation_history JSONB NOT NULL DEFAULT '[]', -- Full Claude conversation for resumption
+ last_checkpoint_id UUID REFERENCES task_checkpoints(id) ON DELETE SET NULL,
+ pending_task_ids UUID[] DEFAULT ARRAY[]::UUID[], -- Tasks supervisor is waiting on
+ phase VARCHAR(50) NOT NULL DEFAULT 'research', -- Current contract phase
+ last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE(contract_id) -- One supervisor state per contract
+);
+
+CREATE INDEX idx_supervisor_states_task_id ON supervisor_states(task_id);
+CREATE INDEX idx_supervisor_states_last_activity ON supervisor_states(last_activity);
+
+-- Trigger to update updated_at
+CREATE TRIGGER update_supervisor_states_updated_at
+ BEFORE UPDATE ON supervisor_states
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE supervisor_states IS 'Persisted state for contract supervisors, enabling resumption after interruption';
+COMMENT ON COLUMN supervisor_states.conversation_history IS 'Full Claude conversation history as JSON array';
+COMMENT ON COLUMN supervisor_states.pending_task_ids IS 'Array of task UUIDs the supervisor is waiting on';
+COMMENT ON COLUMN supervisor_states.phase IS 'Current contract phase when supervisor was last active';
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
new file mode 100644
index 0000000..649a8e7
--- /dev/null
+++ b/makima/src/bin/makima.rs
@@ -0,0 +1,564 @@
+//! Makima CLI - unified CLI for server, daemon, and task management.
+
+use std::io::{self, Read};
+use std::path::Path;
+use std::sync::Arc;
+
+use makima::daemon::api::ApiClient;
+use makima::daemon::cli::{
+ Cli, Commands, ContractCommand, SupervisorCommand,
+};
+use makima::daemon::config::{DaemonConfig, RepoEntry};
+use makima::daemon::db::LocalDb;
+use makima::daemon::error::DaemonError;
+use makima::daemon::task::{TaskConfig, TaskManager};
+use makima::daemon::ws::{DaemonCommand, WsClient};
+use tokio::process::Command;
+use tokio::sync::mpsc;
+use tracing_subscriber::{fmt, prelude::*, EnvFilter};
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ let cli = Cli::parse_args();
+
+ match cli.command {
+ Commands::Server(args) => run_server(args).await,
+ Commands::Daemon(args) => run_daemon(args).await,
+ Commands::Supervisor(cmd) => run_supervisor(cmd).await,
+ Commands::Contract(cmd) => run_contract(cmd).await,
+ }
+}
+
+/// Run the makima server.
+async fn run_server(
+ args: makima::daemon::cli::ServerArgs,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ // Initialize logging
+ init_logging(&args.log_level, "text");
+
+ eprintln!("=== Makima Server Starting ===");
+ eprintln!("Port: {}", args.port);
+
+ // Create app state
+ let mut app_state = makima::server::state::AppState::new(
+ &args.parakeet_model_dir,
+ &args.parakeet_eou_dir,
+ &args.sortformer_model_path,
+ );
+
+ // Connect to database if URL provided
+ if let Some(ref db_url) = args.database_url {
+ eprintln!("Connecting to database...");
+ let pool = makima::db::create_pool(db_url).await?;
+ app_state = app_state.with_db_pool(pool);
+ eprintln!("Database connected");
+ }
+
+ let state = Arc::new(app_state);
+ let addr = format!("0.0.0.0:{}", args.port);
+
+ eprintln!("Starting server on {}", addr);
+ makima::server::run_server(state, &addr).await?;
+
+ Ok(())
+}
+
+/// Run the makima daemon.
+async fn run_daemon(
+ args: makima::daemon::cli::DaemonArgs,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ eprintln!("=== Makima Daemon Starting ===");
+
+ // Build a temporary CLI struct for config loading
+ let cli = makima::daemon::cli::daemon::DaemonArgs {
+ config: args.config,
+ repos_dir: args.repos_dir,
+ worktrees_dir: args.worktrees_dir,
+ server_url: args.server_url,
+ api_key: args.api_key,
+ max_tasks: args.max_tasks,
+ log_level: args.log_level,
+ };
+
+ // Load configuration with CLI overrides
+ eprintln!("[1/5] Loading configuration...");
+ let config = match DaemonConfig::load_with_daemon_args(&cli) {
+ Ok(cfg) => {
+ eprintln!(" Config loaded: server={}", cfg.server.url);
+ cfg
+ }
+ Err(e) => {
+ eprintln!("Failed to load configuration: {}", e);
+ eprintln!();
+ eprintln!("Use CLI flags:");
+ eprintln!(" makima daemon --server-url ws://localhost:8080 --api-key your-api-key");
+ eprintln!();
+ eprintln!("Or set environment variables:");
+ eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080");
+ eprintln!(" MAKIMA_API_KEY=your-api-key");
+ eprintln!();
+ eprintln!("Or create a config file: makima-daemon.toml");
+ std::process::exit(1);
+ }
+ };
+
+ // Initialize logging
+ init_logging(&config.logging.level, &config.logging.format);
+ eprintln!("[2/5] Logging initialized");
+
+ // Initialize local database
+ eprintln!(
+ "[3/5] Opening local database: {}",
+ config.local_db.path.display()
+ );
+ let _local_db = LocalDb::open(&config.local_db.path)?;
+ eprintln!(" Database opened");
+
+ // Initialize worktree directories
+ eprintln!("[4/5] Setting up directories...");
+ tokio::fs::create_dir_all(&config.worktree.base_dir).await?;
+ tokio::fs::create_dir_all(&config.worktree.repos_dir).await?;
+ tokio::fs::create_dir_all(&config.repos.home_dir).await?;
+ eprintln!(
+ " Worktree base: {}",
+ config.worktree.base_dir.display()
+ );
+ eprintln!(" Repos cache: {}", config.worktree.repos_dir.display());
+ eprintln!(" Home dir: {}", config.repos.home_dir.display());
+
+ // Auto-clone repositories if configured
+ if !config.repos.auto_clone.is_empty() {
+ eprintln!(
+ " Auto-cloning {} repositories...",
+ config.repos.auto_clone.len()
+ );
+ for repo_entry in &config.repos.auto_clone {
+ if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await {
+ eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e);
+ }
+ }
+ }
+
+ // Create channels for communication
+ let (command_tx, mut command_rx) = mpsc::channel::<DaemonCommand>(64);
+
+ // Get machine info
+ let machine_id = get_machine_id();
+ let hostname = get_hostname();
+ eprintln!(" Machine ID: {}", machine_id);
+ eprintln!(" Hostname: {}", hostname);
+
+ // Create WebSocket client
+ eprintln!("[5/5] Connecting to server: {}", config.server.url);
+ let mut ws_client = WsClient::new(
+ config.server.clone(),
+ machine_id,
+ hostname,
+ config.process.max_concurrent_tasks as i32,
+ command_tx,
+ );
+
+ // Get sender for task manager
+ let ws_tx = ws_client.sender();
+
+ // Create task configuration
+ let task_config = TaskConfig {
+ max_concurrent_tasks: config.process.max_concurrent_tasks,
+ worktree_base_dir: config.worktree.base_dir.clone(),
+ env_vars: config.process.env_vars.clone(),
+ claude_command: config.process.claude_command.clone(),
+ claude_args: config.process.claude_args.clone(),
+ claude_pre_args: config.process.claude_pre_args.clone(),
+ enable_permissions: config.process.enable_permissions,
+ disable_verbose: config.process.disable_verbose,
+ };
+
+ // Create task manager
+ let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone()));
+
+ // Spawn command handler
+ let task_manager_clone = task_manager.clone();
+ tokio::spawn(async move {
+ tracing::info!("Command handler started, waiting for commands...");
+ while let Some(command) = command_rx.recv().await {
+ tracing::info!("Received command from channel: {:?}", command);
+ if let Err(e) = task_manager_clone.handle_command(command).await {
+ tracing::error!("Failed to handle command: {}", e);
+ }
+ }
+ tracing::info!("Command handler stopped");
+ });
+
+ // Handle shutdown signals
+ let shutdown_signal = async {
+ tokio::signal::ctrl_c()
+ .await
+ .expect("Failed to install Ctrl+C handler");
+ eprintln!("\nReceived shutdown signal");
+ };
+
+ eprintln!("=== Daemon running (Ctrl+C to stop) ===");
+
+ // Run WebSocket client with shutdown handling
+ tokio::select! {
+ result = ws_client.run() => {
+ match result {
+ Ok(()) => eprintln!("WebSocket client exited cleanly"),
+ Err(DaemonError::AuthFailed(msg)) => {
+ eprintln!("ERROR: Authentication failed: {}", msg);
+ std::process::exit(1);
+ }
+ Err(e) => {
+ eprintln!("ERROR: WebSocket client error: {}", e);
+ std::process::exit(1);
+ }
+ }
+ }
+ _ = shutdown_signal => {
+ eprintln!("Shutting down...");
+ }
+ }
+
+ // Cleanup
+ tracing::info!("Daemon stopped");
+
+ Ok(())
+}
+
+/// Run supervisor commands.
+async fn run_supervisor(
+ cmd: SupervisorCommand,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ use makima::daemon::api::supervisor::*;
+
+ match cmd {
+ SupervisorCommand::Tasks(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.supervisor_tasks(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Tree(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.supervisor_tree(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Spawn(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!("Creating task: {}...", args.name);
+ let req = SpawnTaskRequest {
+ name: args.name,
+ plan: args.plan,
+ contract_id: args.common.contract_id,
+ parent_task_id: args.parent,
+ checkpoint_sha: args.checkpoint,
+ };
+ let result = client.supervisor_spawn(req).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Wait(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!(
+ "Waiting for task {} (timeout: {}s)...",
+ args.task_id, args.timeout
+ );
+ let result = client.supervisor_wait(args.task_id, args.timeout).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::ReadFile(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .supervisor_read_file(args.task_id, &args.file_path)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Branch(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!("Creating branch: {}...", args.name);
+ let result = client.supervisor_branch(&args.name, args.from).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Merge(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!("Merging task {}...", args.task_id);
+ let result = client
+ .supervisor_merge(args.task_id, args.to, args.squash)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Pr(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ eprintln!("Creating PR for task {}...", args.task_id);
+ let body = args.body.as_deref().unwrap_or("");
+ let result = client
+ .supervisor_pr(args.task_id, &args.title, body, &args.base)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Diff(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client.supervisor_diff(args.task_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Checkpoint(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let task_id = args
+ .common
+ .task_id
+ .ok_or("MAKIMA_TASK_ID is required for checkpoint")?;
+ let result = client
+ .supervisor_checkpoint(task_id, &args.message)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Checkpoints(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let task_id = args.task_id.ok_or("MAKIMA_TASK_ID is required")?;
+ let result = client.supervisor_checkpoints(task_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ SupervisorCommand::Status(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.supervisor_status(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ }
+
+ Ok(())
+}
+
+/// Run contract commands.
+async fn run_contract(
+ cmd: ContractCommand,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ match cmd {
+ ContractCommand::Status(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.contract_status(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::Checklist(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.contract_checklist(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::Goals(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.contract_goals(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::Files(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.contract_files(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::File(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .contract_file(args.common.contract_id, args.file_id)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::Report(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .contract_report(args.common.contract_id, &args.message, args.common.task_id)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::SuggestAction(args) => {
+ let client = ApiClient::new(args.api_url, args.api_key)?;
+ let result = client.contract_suggest_action(args.contract_id).await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::CompletionAction(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let files = args.files.map(|f| {
+ f.split(',')
+ .map(|s| s.trim().to_string())
+ .collect::<Vec<_>>()
+ });
+ let result = client
+ .contract_completion_action(
+ args.common.contract_id,
+ args.common.task_id,
+ files,
+ args.lines_added,
+ args.lines_removed,
+ args.code,
+ )
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::UpdateFile(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ // Read content from stdin
+ let mut content = String::new();
+ io::stdin().read_to_string(&mut content)?;
+ let result = client
+ .contract_update_file(args.common.contract_id, args.file_id, &content)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ ContractCommand::CreateFile(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ // Read content from stdin
+ let mut content = String::new();
+ io::stdin().read_to_string(&mut content)?;
+ let result = client
+ .contract_create_file(args.common.contract_id, &args.name, &content)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
+ }
+
+ Ok(())
+}
+
+fn init_logging(level: &str, format: &str) {
+ let filter = EnvFilter::try_from_default_env()
+ .or_else(|_| EnvFilter::try_new(level))
+ .unwrap_or_else(|_| EnvFilter::new("info"));
+
+ let subscriber = tracing_subscriber::registry().with(filter);
+
+ if format == "json" {
+ subscriber.with(fmt::layer().json()).init();
+ } else {
+ subscriber.with(fmt::layer()).init();
+ }
+}
+
+fn get_machine_id() -> String {
+ // Try to read machine-id from standard locations
+ #[cfg(target_os = "linux")]
+ {
+ if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
+ return id.trim().to_string();
+ }
+ if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
+ return id.trim().to_string();
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ // Use IOPlatformSerialNumber
+ if let Ok(output) = std::process::Command::new("ioreg")
+ .args(["-rd1", "-c", "IOPlatformExpertDevice"])
+ .output()
+ {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ for line in stdout.lines() {
+ if line.contains("IOPlatformUUID") {
+ if let Some(uuid) = line.split('"').nth(3) {
+ return uuid.to_string();
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: generate a random ID and persist it
+ let state_dir = dirs_next::data_local_dir()
+ .unwrap_or_else(|| std::path::PathBuf::from("."))
+ .join("makima");
+ let machine_id_file = state_dir.join("machine-id");
+
+ if let Ok(id) = std::fs::read_to_string(&machine_id_file) {
+ return id.trim().to_string();
+ }
+
+ // Generate new ID
+ let new_id = uuid::Uuid::new_v4().to_string();
+ std::fs::create_dir_all(&state_dir).ok();
+ std::fs::write(&machine_id_file, &new_id).ok();
+ new_id
+}
+
+fn get_hostname() -> String {
+ hostname::get()
+ .map(|h| h.to_string_lossy().to_string())
+ .unwrap_or_else(|_| "unknown".to_string())
+}
+
+/// Auto-clone a repository to the home directory if it doesn't exist.
+async fn auto_clone_repo(
+ repo_entry: &RepoEntry,
+ home_dir: &Path,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ let dir_name = repo_entry
+ .dir_name()
+ .ok_or("Could not determine directory name from URL")?;
+ let target_dir = home_dir.join(&dir_name);
+
+ // Check if already cloned
+ if target_dir.exists() {
+ eprintln!(" [skip] {} (already exists)", dir_name);
+ return Ok(());
+ }
+
+ let url = repo_entry.expanded_url();
+ eprintln!(" [clone] {} -> {}", url, target_dir.display());
+
+ // Build git clone command
+ let mut args = vec!["clone".to_string()];
+
+ // Add shallow clone if requested
+ if repo_entry.shallow() {
+ args.push("--depth".to_string());
+ args.push("1".to_string());
+ }
+
+ // Add branch if specified
+ if let Some(branch) = repo_entry.branch() {
+ args.push("--branch".to_string());
+ args.push(branch.to_string());
+ }
+
+ args.push(url.clone());
+ args.push(target_dir.to_string_lossy().to_string());
+
+ // Run git clone
+ let output = Command::new("git").args(&args).output().await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("git clone failed: {}", stderr).into());
+ }
+
+ eprintln!(" [done] {}", dir_name);
+ Ok(())
+}
+
+/// dirs_next minimal replacement
+mod dirs_next {
+ use std::path::PathBuf;
+
+ pub fn data_local_dir() -> Option<PathBuf> {
+ #[cfg(target_os = "macos")]
+ {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
+ }
+ #[cfg(target_os = "linux")]
+ {
+ std::env::var("XDG_DATA_HOME")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(|| {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join(".local").join("share"))
+ })
+ }
+ #[cfg(target_os = "windows")]
+ {
+ std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ None
+ }
+ }
+}
diff --git a/makima/src/bin/server.rs b/makima/src/bin/server.rs
deleted file mode 100644
index bbc56fd..0000000
--- a/makima/src/bin/server.rs
+++ /dev/null
@@ -1,72 +0,0 @@
-//! Makima Audio API Server binary.
-//!
-//! This server provides WebSocket-based speech-to-text streaming with optional persistence.
-
-use std::sync::Arc;
-
-use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
-
-use makima::server::{run_server, state::AppState};
-
-/// Default model paths (can be overridden via environment variables).
-const DEFAULT_PARAKEET_MODEL_DIR: &str = "models/parakeet-tdt-0.6b-v3";
-const DEFAULT_PARAKEET_EOU_DIR: &str = "models/realtime_eou_120m-v1-onnx";
-const DEFAULT_SORTFORMER_MODEL_PATH: &str = "models/diarization/diar_streaming_sortformer_4spk-v2.1.onnx";
-
-#[tokio::main]
-async fn main() -> anyhow::Result<()> {
- // Initialize tracing subscriber with environment filter
- tracing_subscriber::registry()
- .with(
- tracing_subscriber::EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| "makima=info,tower_http=info".into()),
- )
- .with(tracing_subscriber::fmt::layer())
- .init();
-
- tracing::info!("Starting Makima Listening API Server");
-
- // Read configuration from environment
- let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
- let parakeet_dir = std::env::var("PARAKEET_MODEL_DIR")
- .unwrap_or_else(|_| DEFAULT_PARAKEET_MODEL_DIR.to_string());
- let parakeet_eou_dir = std::env::var("PARAKEET_EOU_DIR")
- .unwrap_or_else(|_| DEFAULT_PARAKEET_EOU_DIR.to_string());
- let sortformer_path = std::env::var("SORTFORMER_MODEL_PATH")
- .unwrap_or_else(|_| DEFAULT_SORTFORMER_MODEL_PATH.to_string());
-
- tracing::info!(
- parakeet = %parakeet_dir,
- eou = %parakeet_eou_dir,
- sortformer = %sortformer_path,
- "Loading ML models..."
- );
-
- // Load ML models
- let mut app_state = AppState::new(&parakeet_dir, &parakeet_eou_dir, &sortformer_path)
- .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?;
-
- tracing::info!("Models loaded successfully");
-
- // Initialize database (optional - server works without it)
- if let Ok(database_url) = std::env::var("POSTGRES_CONNECTION_URI") {
- tracing::info!("Connecting to database...");
- match makima::db::create_pool(&database_url).await {
- Ok(pool) => {
- tracing::info!("Database connected successfully");
- app_state = app_state.with_db_pool(pool);
- }
- Err(e) => {
- tracing::warn!("Failed to connect to database: {}. Running without persistence.", e);
- }
- }
- } else {
- tracing::info!("POSTGRES_CONNECTION_URI not set. Running without persistence.");
- }
-
- let state = Arc::new(app_state);
-
- // Run the server
- let addr = format!("0.0.0.0:{}", port);
- run_server(state, &addr).await
-}
diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs
new file mode 100644
index 0000000..b27d606
--- /dev/null
+++ b/makima/src/daemon/api/client.rs
@@ -0,0 +1,129 @@
+//! Base HTTP client for makima API.
+
+use reqwest::Client;
+use serde::{de::DeserializeOwned, Serialize};
+use thiserror::Error;
+
+/// API client errors.
+#[derive(Error, Debug)]
+pub enum ApiError {
+ #[error("HTTP request failed: {0}")]
+ Request(#[from] reqwest::Error),
+
+ #[error("API error (HTTP {status}): {message}")]
+ Api { status: u16, message: String },
+
+ #[error("Failed to parse response: {0}")]
+ Parse(String),
+}
+
+/// HTTP client for makima API.
+pub struct ApiClient {
+ client: Client,
+ base_url: String,
+ api_key: String,
+}
+
+impl ApiClient {
+ /// Create a new API client.
+ pub fn new(base_url: String, api_key: String) -> Result<Self, ApiError> {
+ let client = Client::builder()
+ .build()?;
+
+ Ok(Self {
+ client,
+ base_url: base_url.trim_end_matches('/').to_string(),
+ api_key,
+ })
+ }
+
+ /// Make a GET request.
+ pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let response = self.client
+ .get(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .send()
+ .await?;
+
+ self.handle_response(response).await
+ }
+
+ /// Make a POST request with JSON body.
+ pub async fn post<T: DeserializeOwned, B: Serialize>(
+ &self,
+ path: &str,
+ body: &B,
+ ) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let response = self.client
+ .post(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("Content-Type", "application/json")
+ .json(body)
+ .send()
+ .await?;
+
+ self.handle_response(response).await
+ }
+
+ /// Make a POST request without body.
+ pub async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let response = self.client
+ .post(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .send()
+ .await?;
+
+ self.handle_response(response).await
+ }
+
+ /// Make a PUT request with JSON body.
+ pub async fn put<T: DeserializeOwned, B: Serialize>(
+ &self,
+ path: &str,
+ body: &B,
+ ) -> Result<T, ApiError> {
+ let url = format!("{}{}", self.base_url, path);
+ let response = self.client
+ .put(&url)
+ .header("X-Makima-Tool-Key", &self.api_key)
+ .header("Content-Type", "application/json")
+ .json(body)
+ .send()
+ .await?;
+
+ self.handle_response(response).await
+ }
+
+ /// Handle API response.
+ async fn handle_response<T: DeserializeOwned>(
+ &self,
+ response: reqwest::Response,
+ ) -> Result<T, ApiError> {
+ let status = response.status();
+ let status_code = status.as_u16();
+
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ return Err(ApiError::Api {
+ status: status_code,
+ message: body,
+ });
+ }
+
+ let body = response.text().await?;
+
+ // Handle empty responses
+ if body.is_empty() || body == "null" {
+ // Try to parse empty/null as the target type
+ serde_json::from_str::<T>("null")
+ .or_else(|_| serde_json::from_str::<T>("{}"))
+ .map_err(|e| ApiError::Parse(e.to_string()))
+ } else {
+ serde_json::from_str::<T>(&body)
+ .map_err(|e| ApiError::Parse(format!("{}: {}", e, body)))
+ }
+ }
+}
diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs
new file mode 100644
index 0000000..aac6b94
--- /dev/null
+++ b/makima/src/daemon/api/contract.rs
@@ -0,0 +1,161 @@
+//! Contract API methods.
+
+use serde::Serialize;
+use uuid::Uuid;
+
+use super::client::{ApiClient, ApiError};
+use super::supervisor::JsonValue;
+
+// Request types
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReportRequest {
+ pub message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub task_id: Option<Uuid>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CompletionActionRequest {
+ pub lines_added: i32,
+ pub lines_removed: i32,
+ pub has_code_changes: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub task_id: Option<Uuid>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub files_modified: Option<Vec<String>>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateFileRequest {
+ pub content: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateFileRequest {
+ pub name: String,
+ pub content: String,
+}
+
+impl ApiClient {
+ /// Get contract status.
+ pub async fn contract_status(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/contracts/{}/daemon/status", contract_id))
+ .await
+ }
+
+ /// Get phase checklist.
+ pub async fn contract_checklist(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/contracts/{}/daemon/checklist", contract_id))
+ .await
+ }
+
+ /// Get contract goals.
+ pub async fn contract_goals(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/contracts/{}/daemon/goals", contract_id))
+ .await
+ }
+
+ /// List contract files.
+ pub async fn contract_files(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/contracts/{}/daemon/files", contract_id))
+ .await
+ }
+
+ /// Get a specific file.
+ pub async fn contract_file(
+ &self,
+ contract_id: Uuid,
+ file_id: Uuid,
+ ) -> Result<JsonValue, ApiError> {
+ self.get(&format!(
+ "/api/v1/contracts/{}/daemon/files/{}",
+ contract_id, file_id
+ ))
+ .await
+ }
+
+ /// Report progress.
+ pub async fn contract_report(
+ &self,
+ contract_id: Uuid,
+ message: &str,
+ task_id: Option<Uuid>,
+ ) -> Result<JsonValue, ApiError> {
+ let req = ReportRequest {
+ message: message.to_string(),
+ task_id,
+ };
+ self.post(&format!("/api/v1/contracts/{}/daemon/report", contract_id), &req)
+ .await
+ }
+
+ /// Get suggested action.
+ pub async fn contract_suggest_action(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.post_empty(&format!(
+ "/api/v1/contracts/{}/daemon/suggest-action",
+ contract_id
+ ))
+ .await
+ }
+
+ /// Get completion action recommendation.
+ pub async fn contract_completion_action(
+ &self,
+ contract_id: Uuid,
+ task_id: Option<Uuid>,
+ files_modified: Option<Vec<String>>,
+ lines_added: i32,
+ lines_removed: i32,
+ has_code_changes: bool,
+ ) -> Result<JsonValue, ApiError> {
+ let req = CompletionActionRequest {
+ task_id,
+ files_modified,
+ lines_added,
+ lines_removed,
+ has_code_changes,
+ };
+ self.post(
+ &format!("/api/v1/contracts/{}/daemon/completion-action", contract_id),
+ &req,
+ )
+ .await
+ }
+
+ /// Update a file.
+ pub async fn contract_update_file(
+ &self,
+ contract_id: Uuid,
+ file_id: Uuid,
+ content: &str,
+ ) -> Result<JsonValue, ApiError> {
+ let req = UpdateFileRequest {
+ content: content.to_string(),
+ };
+ self.put(
+ &format!("/api/v1/contracts/{}/daemon/files/{}", contract_id, file_id),
+ &req,
+ )
+ .await
+ }
+
+ /// Create a new file.
+ pub async fn contract_create_file(
+ &self,
+ contract_id: Uuid,
+ name: &str,
+ content: &str,
+ ) -> Result<JsonValue, ApiError> {
+ let req = CreateFileRequest {
+ name: name.to_string(),
+ content: content.to_string(),
+ };
+ self.post(&format!("/api/v1/contracts/{}/daemon/files", contract_id), &req)
+ .await
+ }
+}
diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs
new file mode 100644
index 0000000..0c05fb4
--- /dev/null
+++ b/makima/src/daemon/api/mod.rs
@@ -0,0 +1,7 @@
+//! HTTP API client for makima CLI commands.
+
+pub mod client;
+pub mod contract;
+pub mod supervisor;
+
+pub use client::ApiClient;
diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs
new file mode 100644
index 0000000..b691cc4
--- /dev/null
+++ b/makima/src/daemon/api/supervisor.rs
@@ -0,0 +1,186 @@
+//! Supervisor API methods.
+
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use super::client::{ApiClient, ApiError};
+
+// Request/Response types
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SpawnTaskRequest {
+ pub name: String,
+ pub plan: String,
+ pub contract_id: Uuid,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub parent_task_id: Option<Uuid>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub checkpoint_sha: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WaitRequest {
+ pub timeout_seconds: i32,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReadFileRequest {
+ pub file_path: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBranchRequest {
+ pub branch_name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub from_ref: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeRequest {
+ pub squash: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target_branch: Option<String>,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePrRequest {
+ pub task_id: Uuid,
+ pub title: String,
+ pub body: String,
+ pub base_branch: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckpointRequest {
+ pub message: String,
+}
+
+// Generic response type for JSON output
+#[derive(Deserialize, Serialize)]
+pub struct JsonValue(pub serde_json::Value);
+
+impl ApiClient {
+ /// Get all tasks in a contract.
+ pub async fn supervisor_tasks(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/supervisor/contracts/{}/tasks", contract_id))
+ .await
+ }
+
+ /// Get task tree structure.
+ pub async fn supervisor_tree(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/supervisor/contracts/{}/tree", contract_id))
+ .await
+ }
+
+ /// Spawn a new task.
+ pub async fn supervisor_spawn(&self, req: SpawnTaskRequest) -> Result<JsonValue, ApiError> {
+ self.post("/api/v1/mesh/supervisor/tasks", &req).await
+ }
+
+ /// Wait for a task to complete.
+ pub async fn supervisor_wait(
+ &self,
+ task_id: Uuid,
+ timeout_seconds: i32,
+ ) -> Result<JsonValue, ApiError> {
+ let req = WaitRequest { timeout_seconds };
+ self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/wait", task_id), &req)
+ .await
+ }
+
+ /// Read a file from a task's worktree.
+ pub async fn supervisor_read_file(
+ &self,
+ task_id: Uuid,
+ file_path: &str,
+ ) -> Result<JsonValue, ApiError> {
+ let req = ReadFileRequest {
+ file_path: file_path.to_string(),
+ };
+ self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/read-file", task_id), &req)
+ .await
+ }
+
+ /// Create a new branch.
+ pub async fn supervisor_branch(
+ &self,
+ branch_name: &str,
+ from_ref: Option<String>,
+ ) -> Result<JsonValue, ApiError> {
+ let req = CreateBranchRequest {
+ branch_name: branch_name.to_string(),
+ from_ref,
+ };
+ self.post("/api/v1/mesh/supervisor/branches", &req).await
+ }
+
+ /// Merge a task's changes.
+ pub async fn supervisor_merge(
+ &self,
+ task_id: Uuid,
+ target_branch: Option<String>,
+ squash: bool,
+ ) -> Result<JsonValue, ApiError> {
+ let req = MergeRequest {
+ squash,
+ target_branch,
+ };
+ self.post(&format!("/api/v1/mesh/supervisor/tasks/{}/merge", task_id), &req)
+ .await
+ }
+
+ /// Create a pull request.
+ pub async fn supervisor_pr(
+ &self,
+ task_id: Uuid,
+ title: &str,
+ body: &str,
+ base_branch: &str,
+ ) -> Result<JsonValue, ApiError> {
+ let req = CreatePrRequest {
+ task_id,
+ title: title.to_string(),
+ body: body.to_string(),
+ base_branch: base_branch.to_string(),
+ };
+ self.post("/api/v1/mesh/supervisor/pr", &req).await
+ }
+
+ /// Get task diff.
+ pub async fn supervisor_diff(&self, task_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/supervisor/tasks/{}/diff", task_id))
+ .await
+ }
+
+ /// Create a checkpoint.
+ pub async fn supervisor_checkpoint(
+ &self,
+ task_id: Uuid,
+ message: &str,
+ ) -> Result<JsonValue, ApiError> {
+ let req = CheckpointRequest {
+ message: message.to_string(),
+ };
+ self.post(&format!("/api/v1/mesh/tasks/{}/checkpoint", task_id), &req)
+ .await
+ }
+
+ /// List checkpoints.
+ pub async fn supervisor_checkpoints(&self, task_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/mesh/tasks/{}/checkpoints", task_id))
+ .await
+ }
+
+ /// Get contract status.
+ pub async fn supervisor_status(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> {
+ self.get(&format!("/api/v1/contracts/{}/daemon/status", contract_id))
+ .await
+ }
+}
diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs
new file mode 100644
index 0000000..5fef5ec
--- /dev/null
+++ b/makima/src/daemon/cli/contract.rs
@@ -0,0 +1,87 @@
+//! Contract subcommand - task-contract interaction commands.
+
+use clap::Args;
+use uuid::Uuid;
+
+/// Common arguments for contract commands.
+#[derive(Args, Debug, Clone)]
+pub struct ContractArgs {
+ /// API URL
+ #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080", global = true)]
+ pub api_url: String,
+
+ /// API key for authentication
+ #[arg(long, env = "MAKIMA_API_KEY", global = true)]
+ pub api_key: String,
+
+ /// Current task ID (optional)
+ #[arg(long, env = "MAKIMA_TASK_ID", global = true)]
+ pub task_id: Option<Uuid>,
+
+ /// Contract ID
+ #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)]
+ pub contract_id: Uuid,
+}
+
+/// Arguments for file command (get specific file).
+#[derive(Args, Debug)]
+pub struct FileArgs {
+ #[command(flatten)]
+ pub common: ContractArgs,
+
+ /// File ID to retrieve
+ pub file_id: Uuid,
+}
+
+/// Arguments for report command.
+#[derive(Args, Debug)]
+pub struct ReportArgs {
+ #[command(flatten)]
+ pub common: ContractArgs,
+
+ /// Progress message
+ pub message: String,
+}
+
+/// Arguments for completion-action command.
+#[derive(Args, Debug)]
+pub struct CompletionActionArgs {
+ #[command(flatten)]
+ pub common: ContractArgs,
+
+ /// Comma-separated list of modified files
+ #[arg(long)]
+ pub files: Option<String>,
+
+ /// Number of lines added
+ #[arg(long, default_value = "0")]
+ pub lines_added: i32,
+
+ /// Number of lines removed
+ #[arg(long, default_value = "0")]
+ pub lines_removed: i32,
+
+ /// Whether there are code changes
+ #[arg(long)]
+ pub code: bool,
+}
+
+/// Arguments for update-file command.
+#[derive(Args, Debug)]
+pub struct UpdateFileArgs {
+ #[command(flatten)]
+ pub common: ContractArgs,
+
+ /// File ID to update
+ pub file_id: Uuid,
+}
+
+/// Arguments for create-file command.
+#[derive(Args, Debug)]
+pub struct CreateFileArgs {
+ #[command(flatten)]
+ pub common: ContractArgs,
+
+ /// Name of the new file
+ pub name: String,
+}
diff --git a/makima/daemon/src/cli.rs b/makima/src/daemon/cli/daemon.rs
index ca84017..de4cff4 100644
--- a/makima/daemon/src/cli.rs
+++ b/makima/src/daemon/cli/daemon.rs
@@ -1,13 +1,11 @@
-//! Command-line argument parsing for makima-daemon.
+//! Daemon subcommand - connect to server and manage tasks.
-use clap::Parser;
+use clap::Args;
use std::path::PathBuf;
-/// Makima daemon for managing Claude Code instances in isolated worktrees.
-#[derive(Parser, Debug)]
-#[command(name = "makima-daemon")]
-#[command(version, about, long_about = None)]
-pub struct Cli {
+/// Run the makima daemon (connect to server and manage tasks).
+#[derive(Args, Debug)]
+pub struct DaemonArgs {
/// Path to custom config file
#[arg(short, long)]
pub config: Option<PathBuf>,
@@ -36,10 +34,3 @@ pub struct Cli {
#[arg(short, long, default_value = "info")]
pub log_level: String,
}
-
-impl Cli {
- /// Parse command-line arguments
- pub fn parse_args() -> Self {
- Self::parse()
- }
-}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
new file mode 100644
index 0000000..24c19c6
--- /dev/null
+++ b/makima/src/daemon/cli/mod.rs
@@ -0,0 +1,120 @@
+//! Command-line interface for the makima CLI.
+
+pub mod contract;
+pub mod daemon;
+pub mod server;
+pub mod supervisor;
+
+use clap::{Parser, Subcommand};
+
+pub use contract::ContractArgs;
+pub use daemon::DaemonArgs;
+pub use server::ServerArgs;
+pub use supervisor::SupervisorArgs;
+
+/// Makima - unified CLI for server, daemon, and task management.
+#[derive(Parser, Debug)]
+#[command(name = "makima")]
+#[command(version, about = "Makima CLI - server, daemon, and task management", long_about = None)]
+pub struct Cli {
+ #[command(subcommand)]
+ pub command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+pub enum Commands {
+ /// Run the makima server
+ Server(ServerArgs),
+
+ /// Run the daemon (connect to server, manage tasks)
+ Daemon(DaemonArgs),
+
+ /// Supervisor commands for contract orchestration
+ #[command(subcommand)]
+ Supervisor(SupervisorCommand),
+
+ /// Contract commands for task-contract interaction
+ #[command(subcommand)]
+ Contract(ContractCommand),
+}
+
+/// Supervisor subcommands for contract orchestration.
+#[derive(Subcommand, Debug)]
+pub enum SupervisorCommand {
+ /// List all tasks in the contract
+ Tasks(SupervisorArgs),
+
+ /// Get the task tree structure
+ Tree(SupervisorArgs),
+
+ /// Create and start a new task
+ Spawn(supervisor::SpawnArgs),
+
+ /// Wait for a task to complete
+ Wait(supervisor::WaitArgs),
+
+ /// Read a file from a task's worktree
+ ReadFile(supervisor::ReadFileArgs),
+
+ /// Create a git branch
+ Branch(supervisor::BranchArgs),
+
+ /// Merge a task's changes to a branch
+ Merge(supervisor::MergeArgs),
+
+ /// Create a pull request
+ Pr(supervisor::PrArgs),
+
+ /// View task diff
+ Diff(supervisor::DiffArgs),
+
+ /// Create a checkpoint
+ Checkpoint(supervisor::CheckpointArgs),
+
+ /// List checkpoints
+ Checkpoints(SupervisorArgs),
+
+ /// Get contract status
+ Status(SupervisorArgs),
+}
+
+/// Contract subcommands for task-contract interaction.
+#[derive(Subcommand, Debug)]
+pub enum ContractCommand {
+ /// Get contract status
+ Status(ContractArgs),
+
+ /// Get the phase checklist
+ Checklist(ContractArgs),
+
+ /// Get contract goals
+ Goals(ContractArgs),
+
+ /// List contract files
+ Files(ContractArgs),
+
+ /// Get a specific file's content
+ File(contract::FileArgs),
+
+ /// Report progress on the contract
+ Report(contract::ReportArgs),
+
+ /// Get suggested next action
+ SuggestAction(ContractArgs),
+
+ /// Get completion recommendation
+ CompletionAction(contract::CompletionActionArgs),
+
+ /// Update a file (reads content from stdin)
+ UpdateFile(contract::UpdateFileArgs),
+
+ /// Create a new file (reads content from stdin)
+ CreateFile(contract::CreateFileArgs),
+}
+
+impl Cli {
+ /// Parse command-line arguments
+ pub fn parse_args() -> Self {
+ Self::parse()
+ }
+}
diff --git a/makima/src/daemon/cli/server.rs b/makima/src/daemon/cli/server.rs
new file mode 100644
index 0000000..371a912
--- /dev/null
+++ b/makima/src/daemon/cli/server.rs
@@ -0,0 +1,43 @@
+//! Server subcommand - run the makima server.
+
+use clap::Args;
+
+/// Run the makima server.
+#[derive(Args, Debug)]
+pub struct ServerArgs {
+ /// Server port
+ #[arg(long, env = "PORT", default_value = "8080")]
+ pub port: u16,
+
+ /// Path to parakeet model directory
+ #[arg(
+ long,
+ env = "PARAKEET_MODEL_DIR",
+ default_value = "models/parakeet-tdt-0.6b-v3"
+ )]
+ pub parakeet_model_dir: String,
+
+ /// Path to parakeet EOU model directory
+ #[arg(
+ long,
+ env = "PARAKEET_EOU_DIR",
+ default_value = "models/realtime_eou_120m-v1-onnx"
+ )]
+ pub parakeet_eou_dir: String,
+
+ /// Path to sortformer model
+ #[arg(
+ long,
+ env = "SORTFORMER_MODEL_PATH",
+ default_value = "models/diarization/diar_streaming_sortformer_4spk-v2.1.onnx"
+ )]
+ pub sortformer_model_path: String,
+
+ /// PostgreSQL connection URI
+ #[arg(long, env = "POSTGRES_CONNECTION_URI")]
+ pub database_url: Option<String>,
+
+ /// Log level (trace, debug, info, warn, error)
+ #[arg(short, long, default_value = "info")]
+ pub log_level: String,
+}
diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs
new file mode 100644
index 0000000..00c7ff4
--- /dev/null
+++ b/makima/src/daemon/cli/supervisor.rs
@@ -0,0 +1,146 @@
+//! Supervisor subcommand - contract orchestration commands.
+
+use clap::Args;
+use uuid::Uuid;
+
+/// Common arguments for supervisor commands.
+#[derive(Args, Debug, Clone)]
+pub struct SupervisorArgs {
+ /// API URL
+ #[arg(long, env = "MAKIMA_API_URL", default_value = "http://localhost:8080", global = true)]
+ pub api_url: String,
+
+ /// API key for authentication
+ #[arg(long, env = "MAKIMA_API_KEY", global = true)]
+ pub api_key: String,
+
+ /// Current task ID (optional)
+ #[arg(long, env = "MAKIMA_TASK_ID", global = true)]
+ pub task_id: Option<Uuid>,
+
+ /// Contract ID
+ #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)]
+ pub contract_id: Uuid,
+}
+
+/// Arguments for spawn command.
+#[derive(Args, Debug)]
+pub struct SpawnArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Name of the task
+ pub name: String,
+
+ /// Plan/description for the task
+ pub plan: String,
+
+ /// Parent task ID to branch from
+ #[arg(long)]
+ pub parent: Option<Uuid>,
+
+ /// Checkpoint SHA to start from
+ #[arg(long)]
+ pub checkpoint: Option<String>,
+}
+
+/// Arguments for wait command.
+#[derive(Args, Debug)]
+pub struct WaitArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to wait for
+ pub task_id: Uuid,
+
+ /// Timeout in seconds
+ #[arg(default_value = "300")]
+ pub timeout: i32,
+}
+
+/// Arguments for read-file command.
+#[derive(Args, Debug)]
+pub struct ReadFileArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to read from
+ pub task_id: Uuid,
+
+ /// File path to read
+ pub file_path: String,
+}
+
+/// Arguments for branch command.
+#[derive(Args, Debug)]
+pub struct BranchArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Branch name to create
+ pub name: String,
+
+ /// Reference (task ID or SHA) to branch from
+ #[arg(long)]
+ pub from: Option<String>,
+}
+
+/// Arguments for merge command.
+#[derive(Args, Debug)]
+pub struct MergeArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to merge
+ pub task_id: Uuid,
+
+ /// Target branch to merge into
+ #[arg(long)]
+ pub to: Option<String>,
+
+ /// Squash commits on merge
+ #[arg(long)]
+ pub squash: bool,
+}
+
+/// Arguments for pr command.
+#[derive(Args, Debug)]
+pub struct PrArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to create PR for
+ pub task_id: Uuid,
+
+ /// PR title
+ #[arg(long)]
+ pub title: String,
+
+ /// PR body/description
+ #[arg(long)]
+ pub body: Option<String>,
+
+ /// Base branch (default: main)
+ #[arg(long, default_value = "main")]
+ pub base: String,
+}
+
+/// Arguments for diff command.
+#[derive(Args, Debug)]
+pub struct DiffArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Task ID to get diff for
+ pub task_id: Uuid,
+}
+
+/// Arguments for checkpoint command.
+#[derive(Args, Debug)]
+pub struct CheckpointArgs {
+ #[command(flatten)]
+ pub common: SupervisorArgs,
+
+ /// Checkpoint message
+ pub message: String,
+}
diff --git a/makima/daemon/src/config.rs b/makima/src/daemon/config.rs
index 94d1e8a..866ee70 100644
--- a/makima/daemon/src/config.rs
+++ b/makima/src/daemon/config.rs
@@ -450,28 +450,33 @@ impl DaemonConfig {
/// 5. ~/.config/makima-daemon/config.toml
/// 6. /etc/makima-daemon/config.toml (Linux only)
/// 7. Default values
- pub fn load_with_cli(cli: &crate::cli::Cli) -> Result<Self, config::ConfigError> {
+ pub fn load_with_cli(cli: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> {
+ Self::load_with_daemon_args(cli)
+ }
+
+ /// Load configuration from various sources with daemon CLI overrides.
+ pub fn load_with_daemon_args(args: &super::cli::daemon::DaemonArgs) -> Result<Self, config::ConfigError> {
// Load base config (with optional custom config file)
- let mut config = Self::load_from_path(cli.config.as_deref())?;
+ let mut config = Self::load_from_path(args.config.as_deref())?;
// Apply CLI overrides (highest priority)
- if let Some(ref repos_dir) = cli.repos_dir {
+ if let Some(ref repos_dir) = args.repos_dir {
config.worktree.repos_dir = repos_dir.clone();
}
- if let Some(ref worktrees_dir) = cli.worktrees_dir {
+ if let Some(ref worktrees_dir) = args.worktrees_dir {
config.worktree.base_dir = worktrees_dir.clone();
}
- if let Some(ref server_url) = cli.server_url {
+ if let Some(ref server_url) = args.server_url {
config.server.url = server_url.clone();
}
- if let Some(ref api_key) = cli.api_key {
+ if let Some(ref api_key) = args.api_key {
config.server.api_key = api_key.clone();
}
- if let Some(max_tasks) = cli.max_tasks {
+ if let Some(max_tasks) = args.max_tasks {
config.process.max_concurrent_tasks = max_tasks;
}
// Log level is always set (has default)
- config.logging.level = cli.log_level.clone();
+ config.logging.level = args.log_level.clone();
// Validate required fields after all sources are merged
config.validate()?;
diff --git a/makima/daemon/src/db/local.rs b/makima/src/daemon/db/local.rs
index 5adbf98..f3ed45a 100644
--- a/makima/daemon/src/db/local.rs
+++ b/makima/src/daemon/db/local.rs
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, Result as SqliteResult};
use uuid::Uuid;
-use crate::task::TaskState;
+use crate::daemon::task::TaskState;
/// Local task record for persistence.
#[derive(Debug, Clone)]
@@ -336,7 +336,7 @@ impl LocalDb {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_open_memory() {
diff --git a/makima/daemon/src/db/mod.rs b/makima/src/daemon/db/mod.rs
index 2c6e0f3..2c6e0f3 100644
--- a/makima/daemon/src/db/mod.rs
+++ b/makima/src/daemon/db/mod.rs
diff --git a/makima/daemon/src/error.rs b/makima/src/daemon/error.rs
index 00e5140..b993169 100644
--- a/makima/daemon/src/error.rs
+++ b/makima/src/daemon/error.rs
@@ -10,10 +10,10 @@ pub enum DaemonError {
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("Worktree error: {0}")]
- Worktree(#[from] crate::worktree::WorktreeError),
+ Worktree(#[from] crate::daemon::worktree::WorktreeError),
#[error("Process error: {0}")]
- Process(#[from] crate::process::ClaudeProcessError),
+ Process(#[from] crate::daemon::process::ClaudeProcessError),
#[error("Task error: {0}")]
Task(#[from] TaskError),
diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs
new file mode 100644
index 0000000..d7ec3f0
--- /dev/null
+++ b/makima/src/daemon/mod.rs
@@ -0,0 +1,22 @@
+//! Makima CLI - Unified CLI for server, daemon, and task management.
+//!
+//! This crate provides:
+//! - `makima server` - Run the makima server
+//! - `makima daemon` - Run the daemon (connect to server, manage tasks)
+//! - `makima supervisor` - Contract orchestration commands
+//! - `makima contract` - Task-contract interaction commands
+
+pub mod api;
+pub mod cli;
+pub mod config;
+pub mod db;
+pub mod error;
+pub mod process;
+pub mod task;
+pub mod temp;
+pub mod worktree;
+pub mod ws;
+
+pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand};
+pub use config::DaemonConfig;
+pub use error::{DaemonError, Result};
diff --git a/makima/daemon/src/process/claude.rs b/makima/src/daemon/process/claude.rs
index e06ee09..93b097c 100644
--- a/makima/daemon/src/process/claude.rs
+++ b/makima/src/daemon/process/claude.rs
@@ -264,19 +264,41 @@ impl ProcessManager {
self
}
+ /// Get the claude command path.
+ pub fn claude_command(&self) -> &str {
+ &self.claude_command
+ }
+
/// Spawn a Claude Code process to execute a plan.
///
/// The process runs in the specified working directory with stream-json output format.
+ /// If `system_prompt` is provided, it will be passed via --system-prompt flag.
pub async fn spawn(
&self,
working_dir: &Path,
plan: &str,
extra_env: Option<HashMap<String, String>>,
) -> Result<ClaudeProcess, ClaudeProcessError> {
+ self.spawn_with_system_prompt(working_dir, plan, extra_env, None).await
+ }
+
+ /// Spawn a Claude Code process with an optional system prompt.
+ ///
+ /// The process runs in the specified working directory with stream-json output format.
+ /// If `system_prompt` is provided, it will be passed via --system-prompt flag as
+ /// behavioral constraints that Claude will treat as system-level instructions.
+ pub async fn spawn_with_system_prompt(
+ &self,
+ working_dir: &Path,
+ plan: &str,
+ extra_env: Option<HashMap<String, String>>,
+ system_prompt: Option<&str>,
+ ) -> Result<ClaudeProcess, ClaudeProcessError> {
tracing::info!(
working_dir = %working_dir.display(),
plan_len = plan.len(),
plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan },
+ has_system_prompt = system_prompt.is_some(),
"Spawning Claude Code process"
);
@@ -313,6 +335,12 @@ impl ProcessManager {
args.push("--dangerously-skip-permissions".to_string());
}
+ // System prompt - passed via --system-prompt flag for system-level constraints
+ if let Some(prompt) = system_prompt {
+ args.push("--system-prompt".to_string());
+ args.push(prompt.to_string());
+ }
+
// Additional user-configured arguments
args.extend(self.claude_args.clone());
diff --git a/makima/daemon/src/process/claude_protocol.rs b/makima/src/daemon/process/claude_protocol.rs
index 930152b..96e5377 100644
--- a/makima/daemon/src/process/claude_protocol.rs
+++ b/makima/src/daemon/process/claude_protocol.rs
@@ -45,7 +45,7 @@ impl ClaudeInputMessage {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_user_message_serialization() {
diff --git a/makima/daemon/src/process/mod.rs b/makima/src/daemon/process/mod.rs
index 814a3c5..814a3c5 100644
--- a/makima/daemon/src/process/mod.rs
+++ b/makima/src/daemon/process/mod.rs
diff --git a/makima/daemon/src/task/manager.rs b/makima/src/daemon/task/manager.rs
index 4979ce7..8269083 100644
--- a/makima/daemon/src/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -13,19 +13,331 @@ use uuid::Uuid;
use std::collections::HashSet;
use super::state::TaskState;
-use crate::error::{DaemonError, TaskError, TaskResult};
-use crate::process::{ClaudeInputMessage, ProcessManager};
-use crate::temp::TempManager;
-use crate::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager};
-use crate::ws::{BranchInfo, DaemonCommand, DaemonMessage};
+use crate::daemon::error::{DaemonError, TaskError, TaskResult};
+use crate::daemon::process::{ClaudeInputMessage, ProcessManager};
+use crate::daemon::temp::TempManager;
+use crate::daemon::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager};
+use crate::daemon::ws::{BranchInfo, DaemonCommand, DaemonMessage};
/// Generate a secure random API key for orchestrator tool access.
fn generate_tool_key() -> String {
- let mut rng = rand::rng();
- let bytes: [u8; 32] = rng.random();
+ let mut rng = rand::thread_rng();
+ let bytes: [u8; 32] = rng.r#gen();
hex::encode(bytes)
}
+/// Check if output contains an OAuth authentication error.
+fn is_oauth_auth_error(output: &str) -> bool {
+ // Match various authentication error patterns from Claude Code
+ if output.contains("Please run /login") {
+ return true;
+ }
+ if output.contains("Invalid API key") {
+ return true;
+ }
+ if output.contains("authentication_error")
+ && (output.contains("OAuth token has expired")
+ || output.contains("Please obtain a new token"))
+ {
+ return true;
+ }
+ false
+}
+
+/// Extract OAuth URL from text (looks for claude.ai OAuth URLs).
+fn extract_url(text: &str) -> Option<String> {
+ // Look for claude.ai OAuth URLs - try multiple patterns
+ let patterns = [
+ "https://claude.ai/oauth",
+ "https://console.anthropic.com/oauth",
+ ];
+
+ for pattern in patterns {
+ if let Some(start) = text.find(pattern) {
+ let remaining = &text[start..];
+ // Find the end of the URL - stop at:
+ // - Whitespace, common URL terminators, escape sequences
+ let end = remaining
+ .find(|c: char| {
+ c.is_whitespace() || c == '"' || c == '\'' || c == '>' || c == ')' || c == ']' || c == '\x07' || c == '\x1b'
+ })
+ .unwrap_or(remaining.len());
+
+ let url = &remaining[..end];
+
+ // Also check if there's another https:// within the match (hyperlink duplication)
+ // Skip first 20 chars to avoid matching the start
+ let url = if url.len() > 30 {
+ if let Some(second_https) = url[20..].find("https://") {
+ &url[..second_https + 20] // Keep only first URL
+ } else {
+ url
+ }
+ } else {
+ url
+ };
+
+ if url.len() > 20 {
+ return Some(url.to_string());
+ }
+ }
+ }
+ None
+}
+
+/// Global storage for pending OAuth flow (only one can be active at a time per daemon)
+static PENDING_AUTH_FLOW: std::sync::OnceLock<std::sync::Mutex<Option<std::sync::mpsc::Sender<String>>>> = std::sync::OnceLock::new();
+
+fn get_auth_flow_storage() -> &'static std::sync::Mutex<Option<std::sync::mpsc::Sender<String>>> {
+ PENDING_AUTH_FLOW.get_or_init(|| std::sync::Mutex::new(None))
+}
+
+/// Send an auth code to the pending OAuth flow.
+pub fn send_auth_code(code: &str) -> bool {
+ let storage = get_auth_flow_storage();
+ if let Ok(mut guard) = storage.lock() {
+ if let Some(sender) = guard.take() {
+ if sender.send(code.to_string()).is_ok() {
+ tracing::info!("Auth code sent to setup-token process");
+ return true;
+ }
+ }
+ }
+ tracing::warn!("No pending auth flow to send code to");
+ false
+}
+
+/// Spawn `claude setup-token` to initiate OAuth flow and capture the login URL.
+/// This spawns the process in a PTY (required by Ink) and reads output until we find a URL.
+/// The process continues running in the background waiting for auth completion.
+async fn get_oauth_login_url(claude_command: &str) -> Option<String> {
+ use portable_pty::{native_pty_system, CommandBuilder, PtySize};
+ use std::io::{Read, Write};
+
+ tracing::info!("Spawning claude setup-token in PTY to get OAuth login URL");
+
+ // Create a PTY - Ink requires a real terminal
+ let pty_system = native_pty_system();
+ let pair = match pty_system.openpty(PtySize {
+ rows: 24,
+ cols: 200, // Wide enough to avoid line wrapping for long URLs/codes
+ pixel_width: 0,
+ pixel_height: 0,
+ }) {
+ Ok(pair) => pair,
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to open PTY");
+ return None;
+ }
+ };
+
+ // Build the command
+ let mut cmd = CommandBuilder::new(claude_command);
+ cmd.arg("setup-token");
+ // Set environment variables to prevent browser from opening and disable fancy output
+ // Use "false" so the browser command fails, forcing setup-token to show URL and wait for manual input
+ cmd.env("BROWSER", "false");
+ cmd.env("TERM", "dumb"); // Disable hyperlinks and fancy terminal features
+ cmd.env("NO_COLOR", "1"); // Disable colors
+
+ // Spawn the process in the PTY
+ let mut child = match pair.slave.spawn_command(cmd) {
+ Ok(child) => child,
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to spawn claude setup-token in PTY");
+ return None;
+ }
+ };
+
+ // Get the reader and writer from the master side
+ let mut reader = match pair.master.try_clone_reader() {
+ Ok(reader) => reader,
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to clone PTY reader");
+ return None;
+ }
+ };
+
+ let mut writer = match pair.master.take_writer() {
+ Ok(writer) => writer,
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to take PTY writer");
+ return None;
+ }
+ };
+
+ // Create channels for communication
+ let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
+ let (url_tx, url_rx) = std::sync::mpsc::channel::<String>();
+
+ // Store the code sender globally so it can be used when AUTH_CODE message arrives
+ {
+ let storage = get_auth_flow_storage();
+ if let Ok(mut guard) = storage.lock() {
+ *guard = Some(code_tx);
+ }
+ }
+
+ // Spawn reader thread - reads PTY output and sends URL when found
+ let reader_handle = std::thread::spawn(move || {
+ let mut buffer = [0u8; 4096];
+ let mut accumulated = String::new();
+ let mut url_sent = false;
+ let mut read_count = 0;
+
+ tracing::info!("setup-token reader thread started");
+
+ loop {
+ match reader.read(&mut buffer) {
+ Ok(0) => {
+ tracing::info!("setup-token PTY EOF reached after {} reads", read_count);
+ break;
+ }
+ Ok(n) => {
+ read_count += 1;
+ let chunk = String::from_utf8_lossy(&buffer[..n]);
+ accumulated.push_str(&chunk);
+
+ // Process complete lines
+ while let Some(newline_pos) = accumulated.find('\n') {
+ let line = accumulated[..newline_pos].to_string();
+ accumulated = accumulated[newline_pos + 1..].to_string();
+
+ let clean_line = strip_ansi_codes(&line);
+ if !clean_line.trim().is_empty() {
+ tracing::info!(line = %clean_line, "setup-token output");
+ }
+
+ // Look for OAuth URL if not found yet
+ if !url_sent {
+ if let Some(url) = extract_url(&line) {
+ tracing::info!(url = %url, "Found OAuth login URL");
+ let _ = url_tx.send(url);
+ url_sent = true;
+ }
+ }
+
+ // Check for success/failure messages
+ if clean_line.contains("successfully") || clean_line.contains("authenticated") || clean_line.contains("Success") {
+ tracing::info!("Authentication appears successful!");
+ }
+ if clean_line.contains("error") || clean_line.contains("failed") || clean_line.contains("invalid") {
+ tracing::warn!(line = %clean_line, "setup-token may have encountered an error");
+ }
+ }
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "PTY read error after {} reads", read_count);
+ break;
+ }
+ }
+ }
+ tracing::info!("setup-token reader thread ended");
+ });
+
+ // Spawn writer thread - waits for auth code and writes it to PTY
+ std::thread::spawn(move || {
+ tracing::info!("setup-token writer thread started, waiting for auth code (10 min timeout)");
+
+ // Wait for auth code from frontend (with long timeout - user needs time to authenticate)
+ match code_rx.recv_timeout(std::time::Duration::from_secs(600)) {
+ Ok(code) => {
+ tracing::info!(code_len = code.len(), "Received auth code from frontend, writing to PTY");
+ // Write code followed by carriage return (Enter key in raw terminal mode)
+ let code_with_enter = format!("{}\r", code);
+ if let Err(e) = writer.write_all(code_with_enter.as_bytes()) {
+ tracing::error!(error = %e, "Failed to write auth code to PTY");
+ } else if let Err(e) = writer.flush() {
+ tracing::error!(error = %e, "Failed to flush PTY writer");
+ } else {
+ tracing::info!("Auth code written to setup-token PTY successfully");
+ // Give Ink a moment to process, then send another Enter in case first was buffered
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ let _ = writer.write_all(b"\r");
+ let _ = writer.flush();
+ tracing::info!("Sent additional Enter keypress");
+ }
+ }
+ Err(e) => {
+ tracing::info!(error = %e, "Auth code receive ended (timeout or channel closed)");
+ }
+ }
+
+ // Wait for reader thread to finish
+ tracing::debug!("Waiting for reader thread to finish...");
+ let _ = reader_handle.join();
+
+ // Wait for child to fully exit
+ tracing::debug!("Waiting for setup-token child process to exit...");
+ match child.wait() {
+ Ok(status) => {
+ tracing::info!(exit_status = ?status, "setup-token process exited");
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to wait for setup-token process");
+ }
+ }
+ });
+
+ // Wait for URL with timeout
+ match url_rx.recv_timeout(std::time::Duration::from_secs(30)) {
+ Ok(url) => Some(url),
+ Err(e) => {
+ tracing::error!(error = %e, "Timed out waiting for OAuth login URL");
+ None
+ }
+ }
+}
+
+/// Strip ANSI escape codes from a string for cleaner logging.
+fn strip_ansi_codes(s: &str) -> String {
+ let mut result = String::with_capacity(s.len());
+ let mut chars = s.chars().peekable();
+
+ while let Some(c) = chars.next() {
+ if c == '\x1b' {
+ // Check what type of escape sequence
+ match chars.peek() {
+ Some(&'[') => {
+ // CSI sequence: ESC [ ... letter
+ chars.next(); // consume '['
+ while let Some(&next) = chars.peek() {
+ chars.next();
+ if next.is_ascii_alphabetic() {
+ break;
+ }
+ }
+ }
+ Some(&']') => {
+ // OSC sequence: ESC ] ... ST (where ST is BEL or ESC \)
+ chars.next(); // consume ']'
+ while let Some(&next) = chars.peek() {
+ if next == '\x07' {
+ chars.next(); // consume BEL (string terminator)
+ break;
+ }
+ if next == '\x1b' {
+ chars.next(); // consume ESC
+ if chars.peek() == Some(&'\\') {
+ chars.next(); // consume \ (string terminator)
+ }
+ break;
+ }
+ chars.next();
+ }
+ }
+ _ => {
+ // Unknown escape, skip just the ESC
+ }
+ }
+ } else if !c.is_control() || c == '\n' {
+ result.push(c);
+ }
+ }
+
+ result
+}
+
/// System prompt for regular (non-orchestrator) subtasks.
/// This ensures subtasks work only within their isolated worktree directory.
const SUBTASK_SYSTEM_PROMPT: &str = r#"You are working in an isolated worktree directory that contains a snapshot of the codebase.
@@ -259,215 +571,220 @@ rsync -av --exclude='.git' --exclude='.makima' "$FINAL_TASK_PATH/" ./
- When you call `done`, YOUR worktree may be used for the final PR/merge
"#;
-/// Content of the helper bash script that orchestrators use to call the API.
-const ORCHESTRATE_SCRIPT: &str = r#"#!/bin/bash
-# Makima Orchestrator Helper Script
-# Usage: ./orchestrate.sh <command> [args...]
-
-API_URL="${MAKIMA_API_URL:-http://localhost:8080}"
-API_KEY="${MAKIMA_API_KEY}"
-TASK_ID="${MAKIMA_TASK_ID}"
-
-if [ -z "$API_KEY" ]; then
- echo "Error: MAKIMA_API_KEY not set" >&2
- exit 1
-fi
-
-if [ -z "$TASK_ID" ]; then
- echo "Error: MAKIMA_TASK_ID not set" >&2
- exit 1
-fi
-
-# Helper function to make API calls and check for errors
-api_call() {
- local method="$1"
- local url="$2"
- local data="$3"
- local response
- local http_code
-
- if [ -n "$data" ]; then
- response=$(curl -s -w "\n%{http_code}" -X "$method" \
- -H "X-Makima-Tool-Key: $API_KEY" \
- -H "Content-Type: application/json" \
- -d "$data" \
- "$url")
- else
- response=$(curl -s -w "\n%{http_code}" -X "$method" \
- -H "X-Makima-Tool-Key: $API_KEY" \
- "$url")
- fi
-
- # Extract HTTP code (last line) and body (everything else)
- http_code=$(echo "$response" | tail -n1)
- body=$(echo "$response" | sed '$d')
-
- # Check for curl errors or non-2xx status
- if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
- echo "Error: API request failed with HTTP $http_code" >&2
- echo "URL: $url" >&2
- echo "Response: $body" >&2
- echo "$body"
- return 1
- fi
-
- echo "$body"
- return 0
-}
-case "$1" in
- list)
- api_call GET "$API_URL/api/v1/mesh/tasks/$TASK_ID/subtasks"
- ;;
- create)
- # Parse arguments: create "name" "plan" [--continue-from <task_id>] [--files "file1,file2"]
- if [ -z "$2" ] || [ -z "$3" ]; then
- echo "Usage: $0 create \"<name>\" \"<plan>\" [--continue-from <task_id>] [--files \"file1,file2\"]" >&2
- exit 1
- fi
- NAME="$2"
- PLAN="$3"
- CONTINUE_FROM=""
- COPY_FILES=""
-
- # Parse optional flags (can be in any order after name and plan)
- shift 3
- while [ $# -gt 0 ]; do
- case "$1" in
- --continue-from)
- CONTINUE_FROM="$2"
- shift 2
- ;;
- --files)
- COPY_FILES="$2"
- shift 2
- ;;
- *)
- echo "Unknown option: $1" >&2
- exit 1
- ;;
- esac
- done
-
- # Escape quotes in name and plan for JSON
- NAME_ESCAPED=$(echo "$NAME" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
- PLAN_ESCAPED=$(echo "$PLAN" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
-
- # Build JSON body
- JSON_BODY="{\"name\":\"$NAME_ESCAPED\",\"plan\":\"$PLAN_ESCAPED\",\"parentTaskId\":\"$TASK_ID\""
-
- if [ -n "$CONTINUE_FROM" ]; then
- echo "Creating subtask: $NAME (continuing from $CONTINUE_FROM)..." >&2
- JSON_BODY="$JSON_BODY,\"continueFromTaskId\":\"$CONTINUE_FROM\""
- else
- echo "Creating subtask: $NAME..." >&2
- fi
-
- if [ -n "$COPY_FILES" ]; then
- # Convert comma-separated file list to JSON array
- FILES_JSON="["
- first=true
- IFS=',' read -ra FILE_ARRAY <<< "$COPY_FILES"
- for file in "${FILE_ARRAY[@]}"; do
- file=$(echo "$file" | xargs) # trim whitespace
- if [ "$first" = true ]; then
- FILES_JSON="$FILES_JSON\"$file\""
- first=false
- else
- FILES_JSON="$FILES_JSON,\"$file\""
- fi
- done
- FILES_JSON="$FILES_JSON]"
- JSON_BODY="$JSON_BODY,\"copyFiles\":$FILES_JSON"
- echo " (copying files: $COPY_FILES)" >&2
- fi
-
- JSON_BODY="$JSON_BODY}"
- api_call POST "$API_URL/api/v1/mesh/tasks" "$JSON_BODY"
- ;;
- start)
- if [ -z "$2" ]; then
- echo "Usage: $0 start <subtask_id>" >&2
- exit 1
- fi
- echo "Starting subtask $2..." >&2
- api_call POST "$API_URL/api/v1/mesh/tasks/$2/start"
- ;;
- stop)
- if [ -z "$2" ]; then
- echo "Usage: $0 stop <subtask_id>" >&2
- exit 1
- fi
- api_call POST "$API_URL/api/v1/mesh/tasks/$2/stop"
- ;;
- status)
- if [ -z "$2" ]; then
- echo "Usage: $0 status <subtask_id>" >&2
- exit 1
- fi
- api_call GET "$API_URL/api/v1/mesh/tasks/$2"
- ;;
- output)
- if [ -z "$2" ]; then
- echo "Usage: $0 output <subtask_id>" >&2
- exit 1
- fi
- api_call GET "$API_URL/api/v1/mesh/tasks/$2/output"
- ;;
- worktree)
- if [ -z "$2" ]; then
- echo "Usage: $0 worktree <subtask_id>" >&2
- exit 1
- fi
- # Get the worktree path from the task's overlayPath field via API
- TASK_JSON=$(api_call GET "$API_URL/api/v1/mesh/tasks/$2")
- if [ $? -ne 0 ]; then
- echo "Error: Failed to get task info" >&2
- exit 1
- fi
- WORKTREE_PATH=$(echo "$TASK_JSON" | grep -o '"overlayPath":"[^"]*"' | cut -d'"' -f4)
- if [ -z "$WORKTREE_PATH" ]; then
- echo "Error: Task has no worktree path (may not have started yet)" >&2
- exit 1
- fi
- if [ -d "$WORKTREE_PATH" ]; then
- echo "$WORKTREE_PATH"
- else
- echo "Error: Worktree not found at $WORKTREE_PATH" >&2
- echo "The worktree may have been cleaned up." >&2
- exit 1
- fi
- ;;
- done)
- SUMMARY="${2:-Task completed}"
- api_call PUT "$API_URL/api/v1/mesh/tasks/$TASK_ID" "{\"status\":\"done\",\"progressSummary\":\"$SUMMARY\"}"
- ;;
- *)
- echo "Makima Orchestrator Helper"
- echo ""
- echo "Usage: $0 <command> [args...]"
- echo ""
- echo "Subtask Commands:"
- echo " list List all subtasks and their status"
- echo " create \"<name>\" \"<plan>\" Create a new subtask"
- echo " create \"...\" --continue-from ID Create subtask continuing from another task's worktree"
- echo " create \"...\" --files \"file1,file2\" Copy specific files from parent (orchestrator) worktree"
- echo " start <subtask_id> Start a subtask"
- echo " stop <subtask_id> Stop a running subtask"
- echo " status <subtask_id> Get detailed subtask status"
- echo " output <subtask_id> Get subtask output history"
- echo " worktree <subtask_id> Get path to subtask's worktree"
- echo ""
- echo "Completion:"
- echo " done [summary] Mark orchestrator as complete"
- echo ""
- echo "Examples:"
- echo " create \"Fix bug\" \"Fix the null check bug\" --files \"PLAN.md\""
- echo " create \"Step 2\" \"Continue work\" --continue-from abc123 --files \"shared.rs,types.rs\""
- ;;
-esac
+/// System prompt for supervisor tasks (contract orchestrators).
+/// Supervisors monitor all tasks in a contract, create new tasks, and drive the contract to completion.
+const SUPERVISOR_SYSTEM_PROMPT: &str = r#"You are the SUPERVISOR for this contract. Your ONLY job is to coordinate work by spawning tasks, waiting for them to complete, and managing git operations.
+
+## CRITICAL RULES - READ CAREFULLY
+
+1. **NEVER write code or edit files yourself** - you are a coordinator ONLY
+2. **NEVER make commits yourself** - tasks do their own commits
+3. **ALWAYS spawn tasks** for ANY work that involves:
+ - Writing or editing code
+ - Creating or modifying files
+ - Making implementation changes
+ - Any actual development work
+4. **ALWAYS wait for tasks to complete** - you MUST use `wait` after spawning
+5. **Your role is ONLY to**:
+ - Analyze the contract goal and break it into tasks
+ - Spawn tasks AND wait for them to complete
+ - Review completed task results
+ - Merge completed work using `merge`
+ - Create PRs when ready using `pr`
+
+## REQUIRED WORKFLOW - Follow This Pattern
+
+For EVERY task you spawn, you MUST:
+1. Spawn the task with `spawn`
+2. IMMEDIATELY call `wait` to block until completion
+3. Check the result and handle success/failure
+4. Merge if successful
+
+```bash
+# CORRECT PATTERN - spawn then wait
+RESULT=$(makima supervisor spawn "Task Name" "Detailed plan...")
+TASK_ID=$(echo "$RESULT" | jq -r '.taskId')
+echo "Spawned task: $TASK_ID"
+
+# MUST wait for the task - DO NOT skip this step!
+makima supervisor wait "$TASK_ID"
+
+# Check result, view diff, merge if successful
+makima supervisor diff "$TASK_ID"
+makima supervisor merge "$TASK_ID"
+```
+
+## Example - Full Workflow
+
+Goal: "Add user authentication"
+
+```bash
+# Step 1: Create a makima branch for this work (use makima/{name} convention)
+makima supervisor branch "makima/user-authentication"
+
+# Step 2: Spawn tasks, wait for each, and merge to the branch
+
+# Task 1: Research (spawn and wait)
+RESULT=$(makima supervisor spawn "Research auth patterns" "Explore the codebase for existing authentication. Document findings.")
+TASK_ID=$(echo "$RESULT" | jq -r '.taskId')
+makima supervisor wait "$TASK_ID"
+# Review findings before continuing
+
+# Task 2: Login endpoint (spawn and wait)
+RESULT=$(makima supervisor spawn "Implement login" "Create POST /api/login endpoint...")
+TASK_ID=$(echo "$RESULT" | jq -r '.taskId')
+makima supervisor wait "$TASK_ID"
+makima supervisor diff "$TASK_ID"
+makima supervisor merge "$TASK_ID" --to "makima/user-authentication"
+
+# Task 3: Logout endpoint (spawn and wait)
+RESULT=$(makima supervisor spawn "Implement logout" "Create POST /api/logout endpoint...")
+TASK_ID=$(echo "$RESULT" | jq -r '.taskId')
+makima supervisor wait "$TASK_ID"
+makima supervisor merge "$TASK_ID" --to "makima/user-authentication"
+
+# Step 3: All tasks complete - create PR from makima branch
+makima supervisor pr "makima/user-authentication" --title "Add user authentication" --base main
+```
+
+## Available Tools (via makima supervisor)
+
+### Task Management
+```bash
+# List all tasks in this contract
+makima supervisor tasks
+
+# Spawn a new task (returns JSON with taskId)
+makima supervisor spawn "Task Name" "Detailed plan..."
+
+# IMPORTANT: Wait for task to complete (blocks until done/failed)
+makima supervisor wait <task_id> [timeout_seconds]
+
+# Read a file from any task's worktree
+makima supervisor read-file <task_id> <file_path>
+
+# Get the full task tree structure
+makima supervisor tree
+```
+
+### Git Operations
+```bash
+# Create a new branch
+makima supervisor branch <branch_name> [--from <task_id|sha>]
+
+# Merge a task's changes to a branch
+makima supervisor merge <task_id> [--to <branch>] [--squash]
+
+# Create a pull request
+makima supervisor pr <task_id> --title "Title" [--body "Body"] [--base main]
+
+# View a task's diff
+makima supervisor diff <task_id>
+
+# Create a git checkpoint
+makima supervisor checkpoint "Checkpoint message"
+
+# List checkpoints for a task
+makima supervisor checkpoints [task_id]
+```
+
+### Contract
+```bash
+# Get contract status
+makima supervisor status
+```
+
+## Key Points
+
+1. **Create a makima branch first** - use `branch "makima/{name}"` for the contract's work
+2. **spawn returns immediately** - the task runs in the background
+3. **wait blocks until complete** - you MUST call this to know when a task finishes
+4. **Never fire-and-forget** - always wait for each task before moving on
+5. **Merge to your makima branch** - use `merge <task_id> --to "makima/{name}"` to collect completed work
+6. **Create PR when done** - use `pr "makima/{name}" --title "..." --base main`
+
+## Standard Workflow
+
+1. `branch "makima/{name}"` - Create branch (e.g., "makima/add-auth")
+2. For each piece of work:
+ - `spawn` - Create task
+ - `wait` - Block until complete
+ - `merge --to "makima/{name}"` - Merge to branch
+3. `pr "makima/{name}" --title "..." --base main` - Create PR
+
+## Important Reminders
+
+- **ONLY YOU can spawn tasks** - regular tasks cannot create children
+- **NEVER implement anything yourself** - always spawn tasks
+- **ALWAYS create a makima branch** - use `makima/{name}` naming convention
+- Tasks run independently - you just coordinate
+- You will be resumed if interrupted - your conversation is preserved
+- Create checkpoints before major transitions
+
+---
+
"#;
+/// System prompt for tasks that are part of a contract.
+/// This tells the task about contract.sh and how to use it to interact with the contract.
+const CONTRACT_INTEGRATION_PROMPT: &str = r##"
+## Contract Integration
+
+This task is part of a contract. You have access to contract tools via the `makima contract` CLI.
+
+### Contract Commands
+
+```bash
+# Get contract context (name, phase, goals)
+makima contract status
+
+# Get phase checklist and deliverables
+makima contract checklist
+
+# List contract files
+makima contract files
+
+# Read a specific file content
+makima contract file <file_id>
+
+# Report progress to the contract
+makima contract report "Completed X, working on Y..."
+
+# Create a new contract file (content via stdin)
+echo "# New Documentation" | makima contract create-file "New Document"
+
+# Update an existing contract file (content via stdin)
+cat updated_content.md | makima contract update-file <file_id>
+
+# Get suggested next action when done
+makima contract suggest-action
+
+# Report completion with metrics
+makima contract completion-action --files "file1.rs,file2.rs" --code
+```
+
+### What You Should Do
+
+**Before starting:**
+1. Run `makima contract status` to understand the contract context
+2. Run `makima contract checklist` to see phase deliverables
+3. Run `makima contract files` to see existing documentation
+
+**While working:**
+- Report significant progress with `makima contract report "..."`
+
+**When completing:**
+1. If your work should be documented, create or update contract files
+2. Run `makima contract completion-action` to see recommended next steps
+3. Consider what contract files or phases might need updating
+
+**Important:** Your work should contribute to the contract's goals. Check the contract status to understand what's expected.
+
+---
+
+"##;
+
/// Tracks merge state for an orchestrator task.
#[derive(Default)]
struct MergeTracker {
@@ -478,9 +795,12 @@ struct MergeTracker {
}
/// Managed task information.
+#[derive(Clone)]
pub struct ManagedTask {
/// Task ID.
pub id: Uuid,
+ /// Human-readable task name.
+ pub task_name: String,
/// Current state.
pub state: TaskState,
/// Worktree info if created.
@@ -499,6 +819,8 @@ pub struct ManagedTask {
pub depth: i32,
/// Whether this task runs as an orchestrator (coordinates subtasks).
pub is_orchestrator: bool,
+ /// Whether this task is a supervisor (long-running contract orchestrator).
+ pub is_supervisor: bool,
/// Path to target repository for completion actions.
pub target_repo_path: Option<String>,
/// Completion action: "none", "branch", "merge", "pr".
@@ -507,6 +829,8 @@ pub struct ManagedTask {
pub continue_from_task_id: Option<Uuid>,
/// Files to copy from parent task's worktree.
pub copy_files: Option<Vec<String>>,
+ /// Contract ID if this task is associated with a contract.
+ pub contract_id: Option<Uuid>,
/// Time task was created.
pub created_at: Instant,
/// Time task started running.
@@ -624,6 +948,8 @@ impl TaskManager {
completion_action,
continue_from_task_id,
copy_files,
+ contract_id,
+ is_supervisor,
} => {
tracing::info!(
task_id = %task_id,
@@ -634,18 +960,20 @@ impl TaskManager {
parent_task_id = ?parent_task_id,
depth = depth,
is_orchestrator = is_orchestrator,
+ is_supervisor = is_supervisor,
target_repo_path = ?target_repo_path,
completion_action = ?completion_action,
continue_from_task_id = ?continue_from_task_id,
copy_files = ?copy_files,
+ contract_id = ?contract_id,
plan_len = plan.len(),
"Spawning new task"
);
self.spawn_task(
task_id, task_name, plan, repo_url, base_branch, target_branch,
- parent_task_id, depth, is_orchestrator,
+ parent_task_id, depth, is_orchestrator, is_supervisor,
target_repo_path, completion_action, continue_from_task_id,
- copy_files
+ copy_files, contract_id
).await?;
}
DaemonCommand::PauseTask { task_id } => {
@@ -661,17 +989,84 @@ impl TaskManager {
self.interrupt_task(task_id).await?;
}
DaemonCommand::SendMessage { task_id, message } => {
- tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task");
- // Send message to the task's stdin via the input channel
- let inputs = self.task_inputs.read().await;
- if let Some(sender) = inputs.get(&task_id) {
- if let Err(e) = sender.send(message).await {
- tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel");
+ // Check if this is an auth code message
+ if message.starts_with("AUTH_CODE:") {
+ let code = message.strip_prefix("AUTH_CODE:").unwrap_or("").trim();
+ tracing::info!(task_id = %task_id, "Received auth code from frontend");
+ if send_auth_code(code) {
+ tracing::info!(task_id = %task_id, "Auth code forwarded to setup-token");
} else {
- tracing::info!(task_id = %task_id, "Message sent to task successfully");
+ tracing::warn!(task_id = %task_id, "No pending auth flow to receive code");
}
} else {
- tracing::warn!(task_id = %task_id, "No input channel for task (task may not be running)");
+ // Regular message - send to task's stdin
+ tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task");
+ // Send message to the task's stdin via the input channel
+ let inputs = self.task_inputs.read().await;
+ if let Some(sender) = inputs.get(&task_id) {
+ if let Err(e) = sender.send(message).await {
+ tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel");
+ } else {
+ tracing::info!(task_id = %task_id, "Message sent to task successfully");
+ }
+ } else {
+ drop(inputs); // Release read lock before checking if we need to respawn
+
+ // Check if this is a supervisor that needs to be respawned
+ let task_info = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id).cloned()
+ };
+
+ if let Some(task) = task_info {
+ if task.is_supervisor {
+ tracing::info!(
+ task_id = %task_id,
+ "Supervisor has no active Claude process, respawning with message"
+ );
+
+ // Respawn the supervisor with the new message as the plan
+ // Claude Code will use --continue to maintain conversation history
+ let inner = self.clone_inner();
+ let task_name = task.task_name.clone();
+ let repo_source = task.repo_source.clone();
+ let base_branch = task.base_branch.clone();
+ let target_branch = task.target_branch.clone();
+ let target_repo_path = task.target_repo_path.clone();
+ let completion_action = task.completion_action.clone();
+ let contract_id = task.contract_id;
+
+ // Spawn in background to not block the command handler
+ tokio::spawn(async move {
+ if let Err(e) = inner.run_task(
+ task_id,
+ task_name,
+ message, // Use the message as the new prompt
+ repo_source,
+ base_branch,
+ target_branch,
+ false, // is_orchestrator
+ true, // is_supervisor
+ target_repo_path,
+ completion_action,
+ None, // continue_from_task_id
+ None, // copy_files
+ contract_id,
+ ).await {
+ tracing::error!(
+ task_id = %task_id,
+ error = %e,
+ "Failed to respawn supervisor"
+ );
+ }
+ });
+ } else {
+ tracing::warn!(task_id = %task_id, "No input channel for task (task may not be running)");
+ }
+ } else {
+ tracing::warn!(task_id = %task_id, "Task not found");
+ }
+ }
}
}
DaemonCommand::InjectSiblingContext { task_id, .. } => {
@@ -760,6 +1155,72 @@ impl TaskManager {
);
self.handle_check_target_exists(task_id, target_dir).await?;
}
+
+ // =========================================================================
+ // Contract File Commands
+ // =========================================================================
+
+ DaemonCommand::ReadRepoFile {
+ request_id,
+ contract_id,
+ file_path,
+ repo_path,
+ } => {
+ tracing::info!(
+ request_id = %request_id,
+ contract_id = %contract_id,
+ file_path = %file_path,
+ repo_path = %repo_path,
+ "Reading file from repository"
+ );
+ self.handle_read_repo_file(request_id, file_path, repo_path).await?;
+ }
+ DaemonCommand::CreateBranch {
+ task_id,
+ branch_name,
+ from_ref,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ branch_name = %branch_name,
+ from_ref = ?from_ref,
+ "Creating branch"
+ );
+ self.handle_create_branch(task_id, branch_name, from_ref).await?;
+ }
+ DaemonCommand::MergeTaskToTarget {
+ task_id,
+ target_branch,
+ squash,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ target_branch = ?target_branch,
+ squash = squash,
+ "Merging task to target branch"
+ );
+ self.handle_merge_task_to_target(task_id, target_branch, squash).await?;
+ }
+ DaemonCommand::CreatePR {
+ task_id,
+ title,
+ body,
+ base_branch,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ title = %title,
+ base_branch = %base_branch,
+ "Creating pull request"
+ );
+ self.handle_create_pr(task_id, title, body, base_branch).await?;
+ }
+ DaemonCommand::GetTaskDiff {
+ task_id,
+ } => {
+ tracing::info!(task_id = %task_id, "Getting task diff");
+ self.handle_get_task_diff(task_id).await?;
+ }
}
Ok(())
}
@@ -777,12 +1238,14 @@ impl TaskManager {
parent_task_id: Option<Uuid>,
depth: i32,
is_orchestrator: bool,
+ is_supervisor: bool,
target_repo_path: Option<String>,
completion_action: Option<String>,
continue_from_task_id: Option<Uuid>,
copy_files: Option<Vec<String>>,
+ contract_id: Option<Uuid>,
) -> TaskResult<()> {
- tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, depth = depth, "=== SPAWN_TASK START ===");
+ tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, depth = depth, "=== SPAWN_TASK START ===");
// Check if task already exists - allow re-spawning if in terminal state
{
@@ -817,6 +1280,7 @@ impl TaskManager {
tracing::info!(task_id = %task_id, "Creating task entry in state: Initializing");
let task = ManagedTask {
id: task_id,
+ task_name: task_name.clone(),
state: TaskState::Initializing,
worktree: None,
plan: plan.clone(),
@@ -826,10 +1290,12 @@ impl TaskManager {
parent_task_id,
depth,
is_orchestrator,
+ is_supervisor,
target_repo_path: target_repo_path.clone(),
completion_action: completion_action.clone(),
continue_from_task_id,
copy_files: copy_files.clone(),
+ contract_id,
created_at: Instant::now(),
started_at: None,
completed_at: None,
@@ -852,8 +1318,8 @@ impl TaskManager {
if let Err(e) = inner.run_task(
task_id, task_name, plan, repo_url, base_branch, target_branch,
- is_orchestrator, target_repo_path, completion_action,
- continue_from_task_id, copy_files
+ is_orchestrator, is_supervisor, target_repo_path, completion_action,
+ continue_from_task_id, copy_files, contract_id
).await {
tracing::error!(task_id = %task_id, error = %e, "Task execution failed");
inner.mark_failed(task_id, &e.to_string()).await;
@@ -1330,7 +1796,7 @@ impl TaskManager {
let worktree_path = self.get_task_worktree_path(task_id).await?;
// Expand tilde in target path
- let target_path = crate::worktree::expand_tilde(&target_dir);
+ let target_path = crate::daemon::worktree::expand_tilde(&target_dir);
// Clone the worktree to target directory
let result = self.worktree_manager.clone_worktree_to_directory(
@@ -1364,7 +1830,7 @@ impl TaskManager {
target_dir: String,
) -> Result<(), DaemonError> {
// Expand tilde in target path
- let target_path = crate::worktree::expand_tilde(&target_dir);
+ let target_path = crate::daemon::worktree::expand_tilde(&target_dir);
// Check if target exists
let exists = self.worktree_manager.target_directory_exists(&target_path).await;
@@ -1378,6 +1844,366 @@ impl TaskManager {
let _ = self.ws_tx.send(msg).await;
Ok(())
}
+
+ /// Handle ReadRepoFile command.
+ ///
+ /// Reads a file from a repository on the daemon's filesystem and sends
+ /// the content back to the server for syncing contract files.
+ async fn handle_read_repo_file(
+ &self,
+ request_id: Uuid,
+ file_path: String,
+ repo_path: String,
+ ) -> Result<(), DaemonError> {
+ // Expand tilde in repo path
+ let repo_path_expanded = crate::daemon::worktree::expand_tilde(&repo_path);
+
+ // Construct full file path
+ let full_path = repo_path_expanded.join(&file_path);
+
+ // Try to read the file
+ let (content, success, error) = match tokio::fs::read_to_string(&full_path).await {
+ Ok(content) => (Some(content), true, None),
+ Err(e) => {
+ tracing::warn!(
+ request_id = %request_id,
+ file_path = %file_path,
+ repo_path = %repo_path,
+ full_path = %full_path.display(),
+ error = %e,
+ "Failed to read repo file"
+ );
+ (None, false, Some(e.to_string()))
+ }
+ };
+
+ // Send result back to server
+ let msg = DaemonMessage::RepoFileContent {
+ request_id,
+ file_path,
+ content,
+ success,
+ error,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle CreateBranch command - create a new branch in a task's worktree.
+ async fn handle_create_branch(
+ &self,
+ task_id: Uuid,
+ branch_name: String,
+ from_ref: Option<String>,
+ ) -> Result<(), DaemonError> {
+ // Get task's worktree path
+ let worktree_path = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id)
+ .and_then(|t| t.worktree.as_ref())
+ .map(|w| w.path.clone())
+ };
+
+ let (success, message) = if let Some(path) = worktree_path {
+ // Build git checkout command
+ let mut cmd = tokio::process::Command::new("git");
+ cmd.current_dir(&path);
+ cmd.arg("checkout").arg("-b").arg(&branch_name);
+
+ if let Some(ref from) = from_ref {
+ cmd.arg(from);
+ }
+
+ match cmd.output().await {
+ Ok(output) => {
+ if output.status.success() {
+ (true, format!("Branch '{}' created successfully", branch_name))
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ (false, format!("Failed to create branch: {}", stderr))
+ }
+ }
+ Err(e) => (false, format!("Failed to execute git: {}", e)),
+ }
+ } else {
+ (false, format!("Task {} not found or has no worktree", task_id))
+ };
+
+ let msg = DaemonMessage::BranchCreated {
+ task_id,
+ success,
+ branch_name,
+ message,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle MergeTaskToTarget command - merge a task's changes to a target branch.
+ async fn handle_merge_task_to_target(
+ &self,
+ task_id: Uuid,
+ target_branch: Option<String>,
+ squash: bool,
+ ) -> Result<(), DaemonError> {
+ // Get task info
+ let task_info = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id).map(|t| (
+ t.worktree.as_ref().map(|w| w.path.clone()),
+ t.base_branch.clone(),
+ ))
+ };
+
+ let (success, message, commit_sha, conflicts) = match task_info {
+ Some((Some(worktree_path), base)) => {
+ let target = target_branch.unwrap_or_else(|| base.unwrap_or_else(|| "main".to_string()));
+
+ // First, stage and commit any uncommitted changes
+ let add_result = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["add", "-A"])
+ .output()
+ .await;
+
+ if let Err(e) = add_result {
+ (false, format!("Failed to stage changes: {}", e), None, None)
+ } else {
+ // Commit if there are staged changes
+ let commit_result = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["commit", "-m", "Task completion checkpoint", "--allow-empty"])
+ .output()
+ .await;
+
+ if let Err(e) = commit_result {
+ tracing::warn!(task_id = %task_id, error = %e, "Commit failed (may be empty)");
+ }
+
+ // Get current branch name
+ let branch_output = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .output()
+ .await;
+
+ let source_branch = branch_output
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
+ .unwrap_or_else(|_| "unknown".to_string());
+
+ // Checkout target branch
+ let checkout = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["checkout", &target])
+ .output()
+ .await;
+
+ match checkout {
+ Ok(output) if output.status.success() => {
+ // Merge the source branch
+ let mut merge_cmd = tokio::process::Command::new("git");
+ merge_cmd.current_dir(&worktree_path);
+ merge_cmd.arg("merge");
+ if squash {
+ merge_cmd.arg("--squash");
+ }
+ merge_cmd.arg(&source_branch);
+ merge_cmd.arg("-m").arg(format!("Merge task {} into {}", task_id, target));
+
+ match merge_cmd.output().await {
+ Ok(output) if output.status.success() => {
+ // Get the commit SHA
+ let sha_output = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .await;
+
+ let sha = sha_output
+ .ok()
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
+
+ if squash {
+ // For squash merge, we need to commit
+ let _ = tokio::process::Command::new("git")
+ .current_dir(&worktree_path)
+ .args(["commit", "-m", &format!("Squashed merge of task {}", task_id)])
+ .output()
+ .await;
+ }
+
+ (true, format!("Merged {} into {}", source_branch, target), sha, None)
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ // Check for merge conflicts
+ if stderr.contains("CONFLICT") {
+ let conflict_files = stderr
+ .lines()
+ .filter(|l| l.contains("CONFLICT"))
+ .map(|l| l.to_string())
+ .collect::<Vec<_>>();
+ (false, "Merge conflicts detected".to_string(), None, Some(conflict_files))
+ } else {
+ (false, format!("Merge failed: {}", stderr), None, None)
+ }
+ }
+ Err(e) => (false, format!("Failed to merge: {}", e), None, None),
+ }
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ (false, format!("Failed to checkout target branch: {}", stderr), None, None)
+ }
+ Err(e) => (false, format!("Failed to checkout: {}", e), None, None),
+ }
+ }
+ }
+ Some((None, _)) => (false, format!("Task {} has no worktree", task_id), None, None),
+ None => (false, format!("Task {} not found", task_id), None, None),
+ };
+
+ let msg = DaemonMessage::MergeToTargetResult {
+ task_id,
+ success,
+ message,
+ commit_sha,
+ conflicts,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle CreatePR command - create a pull request for a task's changes.
+ async fn handle_create_pr(
+ &self,
+ task_id: Uuid,
+ title: String,
+ body: Option<String>,
+ base_branch: String,
+ ) -> Result<(), DaemonError> {
+ // Get task's worktree path
+ let worktree_path = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id)
+ .and_then(|t| t.worktree.as_ref())
+ .map(|w| w.path.clone())
+ };
+
+ let (success, message, pr_url, pr_number) = if let Some(path) = worktree_path {
+ // Push the current branch first
+ let push_result = tokio::process::Command::new("git")
+ .current_dir(&path)
+ .args(["push", "-u", "origin", "HEAD"])
+ .output()
+ .await;
+
+ if let Err(e) = push_result {
+ (false, format!("Failed to push branch: {}", e), None, None)
+ } else {
+ // Create PR using gh CLI
+ let mut pr_cmd = tokio::process::Command::new("gh");
+ pr_cmd.current_dir(&path);
+ pr_cmd.args(["pr", "create", "--title", &title, "--base", &base_branch]);
+
+ if let Some(ref body_text) = body {
+ pr_cmd.args(["--body", body_text]);
+ } else {
+ pr_cmd.args(["--body", ""]);
+ }
+
+ match pr_cmd.output().await {
+ Ok(output) if output.status.success() => {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ // gh pr create outputs the PR URL
+ let url = stdout.lines().last().map(|s| s.trim().to_string());
+ // Extract PR number from URL
+ let number = url.as_ref().and_then(|u| {
+ u.split('/').last().and_then(|n| n.parse::<i32>().ok())
+ });
+ (true, "Pull request created".to_string(), url, number)
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ (false, format!("Failed to create PR: {}", stderr), None, None)
+ }
+ Err(e) => (false, format!("Failed to run gh: {}", e), None, None),
+ }
+ }
+ } else {
+ (false, format!("Task {} not found or has no worktree", task_id), None, None)
+ };
+
+ let msg = DaemonMessage::PRCreated {
+ task_id,
+ success,
+ message,
+ pr_url,
+ pr_number,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle GetTaskDiff command - get the diff for a task's changes.
+ async fn handle_get_task_diff(
+ &self,
+ task_id: Uuid,
+ ) -> Result<(), DaemonError> {
+ // Get task's worktree path
+ let worktree_path = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id)
+ .and_then(|t| t.worktree.as_ref())
+ .map(|w| w.path.clone())
+ };
+
+ let (success, diff, error) = if let Some(path) = worktree_path {
+ // Get diff of all changes (staged and unstaged)
+ let diff_result = tokio::process::Command::new("git")
+ .current_dir(&path)
+ .args(["diff", "HEAD"])
+ .output()
+ .await;
+
+ match diff_result {
+ Ok(output) if output.status.success() => {
+ let diff_text = String::from_utf8_lossy(&output.stdout).to_string();
+ if diff_text.is_empty() {
+ // No uncommitted changes, show diff from base
+ let base_diff = tokio::process::Command::new("git")
+ .current_dir(&path)
+ .args(["log", "-p", "--reverse", "HEAD~10..HEAD", "--"])
+ .output()
+ .await;
+
+ match base_diff {
+ Ok(o) => (true, Some(String::from_utf8_lossy(&o.stdout).to_string()), None),
+ Err(e) => (false, None, Some(format!("Failed to get diff: {}", e))),
+ }
+ } else {
+ (true, Some(diff_text), None)
+ }
+ }
+ Ok(output) => {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ (false, None, Some(format!("Git diff failed: {}", stderr)))
+ }
+ Err(e) => (false, None, Some(format!("Failed to run git: {}", e))),
+ }
+ } else {
+ (false, None, Some(format!("Task {} not found or has no worktree", task_id)))
+ };
+
+ let msg = DaemonMessage::TaskDiff {
+ task_id,
+ success,
+ diff,
+ error,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
}
/// Inner state for spawned tasks (cloneable).
@@ -1402,12 +2228,14 @@ impl TaskManagerInner {
base_branch: Option<String>,
target_branch: Option<String>,
is_orchestrator: bool,
+ is_supervisor: bool,
target_repo_path: Option<String>,
completion_action: Option<String>,
continue_from_task_id: Option<Uuid>,
copy_files: Option<Vec<String>>,
+ contract_id: Option<Uuid>,
) -> Result<(), DaemonError> {
- tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, "=== RUN_TASK START ===");
+ tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, "=== RUN_TASK START ===");
// Determine working directory
let working_dir = if let Some(ref source) = repo_source {
@@ -1672,20 +2500,21 @@ impl TaskManagerInner {
}
}
- // Set up orchestrator mode if needed
- let (extra_env, full_plan) = if is_orchestrator {
- tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up orchestrator mode");
+ // Set up supervisor, orchestrator, or subtask mode
+ let (extra_env, full_plan, system_prompt) = if is_supervisor {
+ // Supervisor mode: long-running contract orchestrator
+ tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up supervisor mode");
let msg = DaemonMessage::task_output(
task_id,
- "Setting up orchestrator environment...\n".to_string(),
+ "Setting up supervisor environment...\n".to_string(),
false,
);
let _ = self.ws_tx.send(msg).await;
// Generate tool key for API access
let tool_key = generate_tool_key();
- tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for orchestrator");
+ tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for supervisor");
// Register tool key with server
let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone());
@@ -1695,32 +2524,63 @@ impl TaskManagerInner {
tracing::info!(task_id = %task_id, "Tool key registration message sent to server");
}
- // Create .makima directory and helper script
- let makima_dir = working_dir.join(".makima");
- if let Err(e) = tokio::fs::create_dir_all(&makima_dir).await {
- tracing::warn!(task_id = %task_id, makima_dir = %makima_dir.display(), "Failed to create .makima directory: {}", e);
- } else {
- tracing::info!(task_id = %task_id, makima_dir = %makima_dir.display(), "Created .makima directory");
+ // Set up environment variables for makima CLI
+ let mut env = HashMap::new();
+ // TODO: Make API URL configurable
+ env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string());
+ env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone());
+ env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string());
+ // Supervisor needs contract ID for its tools
+ if let Some(cid) = contract_id {
+ env.insert("MAKIMA_CONTRACT_ID".to_string(), cid.to_string());
}
- let script_path = makima_dir.join("orchestrate.sh");
- if let Err(e) = tokio::fs::write(&script_path, ORCHESTRATE_SCRIPT).await {
- tracing::warn!(task_id = %task_id, script_path = %script_path.display(), "Failed to write orchestrate.sh: {}", e);
+ tracing::info!(
+ task_id = %task_id,
+ api_url = "http://localhost:8080",
+ tool_key_preview = &tool_key[..8.min(tool_key.len())],
+ "Set supervisor environment variables"
+ );
+
+ // For supervisor, pass instructions as SYSTEM PROMPT (not user message)
+ // This ensures Claude treats them as behavioral constraints
+ let supervisor_user_plan = format!(
+ "Contract goal:\n{}",
+ plan
+ );
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Supervisor environment ready (makima CLI available)\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Return system prompt separately - it will be passed via --system-prompt flag
+ (Some(env), supervisor_user_plan, Some(SUPERVISOR_SYSTEM_PROMPT.to_string()))
+ } else if is_orchestrator {
+ tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up orchestrator mode");
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Setting up orchestrator environment...\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Generate tool key for API access
+ let tool_key = generate_tool_key();
+ tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for orchestrator");
+
+ // Register tool key with server
+ let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone());
+ if self.ws_tx.send(register_msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to register tool key");
} else {
- tracing::info!(task_id = %task_id, script_path = %script_path.display(), script_size = ORCHESTRATE_SCRIPT.len(), "Wrote orchestrate.sh");
- // Make script executable
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Err(e) = std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) {
- tracing::warn!(task_id = %task_id, "Failed to set script permissions: {}", e);
- } else {
- tracing::info!(task_id = %task_id, "Set orchestrate.sh executable (0o755)");
- }
- }
+ tracing::info!(task_id = %task_id, "Tool key registration message sent to server");
}
- // Set up environment variables
+ // Set up environment variables for makima CLI
let mut env = HashMap::new();
// TODO: Make API URL configurable
env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string());
@@ -1734,30 +2594,78 @@ impl TaskManagerInner {
"Set orchestrator environment variables"
);
- // Prepend orchestrator instructions to the plan
- let orchestrator_plan = format!(
- "{}\n\n---\n\nYour task:\n{}",
- ORCHESTRATOR_SYSTEM_PROMPT,
+ // For orchestrator, pass instructions as SYSTEM PROMPT
+ let orchestrator_user_plan = format!(
+ "Your task:\n{}",
plan
);
let msg = DaemonMessage::task_output(
task_id,
- format!("Orchestrator environment ready (script at {})\n", script_path.display()),
+ "Orchestrator environment ready (makima CLI available)\n".to_string(),
false,
);
let _ = self.ws_tx.send(msg).await;
- (Some(env), orchestrator_plan)
+ (Some(env), orchestrator_user_plan, Some(ORCHESTRATOR_SYSTEM_PROMPT.to_string()))
} else {
tracing::info!(task_id = %task_id, "Running as regular subtask (not orchestrator)");
- // Prepend subtask instructions to ensure worktree isolation
- let subtask_plan = format!(
- "{}\nYour task:\n{}",
- SUBTASK_SYSTEM_PROMPT,
+ // For subtasks, pass worktree isolation instructions as system prompt
+ let subtask_user_plan = format!(
+ "Your task:\n{}",
plan
);
- (None, subtask_plan)
+ (None, subtask_user_plan, Some(SUBTASK_SYSTEM_PROMPT.to_string()))
+ };
+
+ // Add contract environment if task has contract_id (skip for supervisors - they already have it)
+ let (extra_env, full_plan, system_prompt) = if let Some(cid) = contract_id {
+ if is_supervisor {
+ // Supervisors already have contract ID and API access set up
+ tracing::info!(task_id = %task_id, contract_id = %cid, "Supervisor already has contract integration");
+ (extra_env, full_plan, system_prompt)
+ } else {
+ tracing::info!(task_id = %task_id, contract_id = %cid, "Setting up contract integration");
+
+ // Set up environment variables for makima CLI
+ let mut env = extra_env.unwrap_or_default();
+ env.insert("MAKIMA_CONTRACT_ID".to_string(), cid.to_string());
+
+ // If not already an orchestrator, we need API access for makima CLI
+ if !is_orchestrator {
+ // Generate tool key for API access
+ let tool_key = generate_tool_key();
+ tracing::info!(task_id = %task_id, "Generated tool key for contract access");
+
+ // Register tool key with server
+ let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone());
+ if self.ws_tx.send(register_msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to register contract tool key");
+ }
+
+ env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string());
+ env.insert("MAKIMA_API_KEY".to_string(), tool_key);
+ env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string());
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Contract integration ready (makima CLI available)\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Prepend contract integration prompt to the plan so the task knows to use makima CLI
+ let contract_plan = format!(
+ "{}{}",
+ CONTRACT_INTEGRATION_PROMPT,
+ full_plan
+ );
+
+ (Some(env), contract_plan, system_prompt)
+ }
+ } else {
+ (extra_env, full_plan, system_prompt)
};
// Spawn Claude process
@@ -1798,9 +2706,9 @@ impl TaskManagerInner {
);
let _ = self.ws_tx.send(msg).await;
- tracing::debug!(task_id = %task_id, "Calling process_manager.spawn()...");
+ tracing::debug!(task_id = %task_id, has_system_prompt = system_prompt.is_some(), "Calling process_manager.spawn()...");
let mut process = self.process_manager
- .spawn(&working_dir, &full_plan, extra_env)
+ .spawn_with_system_prompt(&working_dir, &full_plan, extra_env, system_prompt.as_deref())
.await
.map_err(|e| {
tracing::error!(task_id = %task_id, error = %e, "Failed to spawn Claude process");
@@ -1876,6 +2784,11 @@ impl TaskManagerInner {
tracing::debug!(task_id = %task_id, "Output will be forwarded via WebSocket to server");
let ws_tx = self.ws_tx.clone();
+ // For auth error detection
+ let claude_command = self.process_manager.claude_command().to_string();
+ let daemon_hostname = hostname::get().ok().and_then(|h| h.into_string().ok());
+ let mut auth_error_handled = false;
+
let mut output_count = 0u64;
let mut output_bytes = 0usize;
let startup_timeout = tokio::time::Duration::from_secs(30);
@@ -1919,11 +2832,42 @@ impl TaskManagerInner {
}
}
+ // Check for OAuth auth error before sending output
+ let content_for_auth_check = line.content.clone();
+
let msg = DaemonMessage::task_output(task_id, line.content, false);
if ws_tx.send(msg).await.is_err() {
tracing::warn!(task_id = %task_id, "Failed to send output, channel closed");
break;
}
+
+ // Detect OAuth token expiration and trigger remote login flow
+ if !auth_error_handled && is_oauth_auth_error(&content_for_auth_check) {
+ auth_error_handled = true;
+ tracing::warn!(task_id = %task_id, "OAuth authentication error detected, initiating remote login flow");
+
+ // Spawn claude setup-token to get login URL
+ if let Some(login_url) = get_oauth_login_url(&claude_command).await {
+ tracing::info!(task_id = %task_id, login_url = %login_url, "Got OAuth login URL");
+ let auth_msg = DaemonMessage::AuthenticationRequired {
+ task_id: Some(task_id),
+ login_url,
+ hostname: daemon_hostname.clone(),
+ };
+ if ws_tx.send(auth_msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to send auth required message");
+ }
+ } else {
+ tracing::error!(task_id = %task_id, "Failed to get OAuth login URL from setup-token");
+ let fallback_msg = DaemonMessage::task_output(
+ task_id,
+ format!("Authentication required on daemon{}. Please run 'claude /login' on the daemon machine.\n",
+ daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()),
+ false,
+ );
+ let _ = ws_tx.send(fallback_msg).await;
+ }
+ }
}
None => {
tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended");
@@ -2033,15 +2977,38 @@ impl TaskManagerInner {
}
}
- // Notify server
- let error = if success {
- None
+ // Notify server - but NOT for supervisors which should never complete
+ if is_supervisor {
+ tracing::info!(
+ task_id = %task_id,
+ exit_code = exit_code,
+ "Supervisor Claude process exited - NOT marking as complete"
+ );
+ // Update local state to reflect it's paused/waiting for input
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.state = TaskState::Running; // Keep it as running, not completed
+ task.completed_at = None;
+ }
+ }
+ // Send a status message to let the frontend know supervisor is ready for more input
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "\n[Supervisor ready for next instruction]\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
} else {
- Some(format!("Exit code: {}", exit_code))
- };
- tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion");
- let msg = DaemonMessage::task_complete(task_id, success, error);
- let _ = self.ws_tx.send(msg).await;
+ let error = if success {
+ None
+ } else {
+ Some(format!("Exit code: {}", exit_code))
+ };
+ tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion");
+ let msg = DaemonMessage::task_complete(task_id, success, error);
+ let _ = self.ws_tx.send(msg).await;
+ }
// Note: Worktrees are kept until explicitly deleted (per user preference)
// This allows inspection, PR creation, etc.
@@ -2061,7 +3028,7 @@ impl TaskManagerInner {
target_branch: Option<&str>,
) -> Result<Option<String>, String> {
let target_repo = match target_repo_path {
- Some(path) => crate::worktree::expand_tilde(path),
+ Some(path) => crate::daemon::worktree::expand_tilde(path),
None => {
tracing::warn!(task_id = %task_id, "No target_repo_path configured, skipping completion action");
return Ok(None);
@@ -2075,8 +3042,8 @@ impl TaskManagerInner {
// Get the branch name: makima/{task-name-with-dashes}-{short-id}
let branch_name = format!(
"makima/{}-{}",
- crate::worktree::sanitize_name(task_name),
- crate::worktree::short_uuid(task_id)
+ crate::daemon::worktree::sanitize_name(task_name),
+ crate::daemon::worktree::short_uuid(task_id)
);
// Determine target branch - use provided value or detect default branch of target repo
diff --git a/makima/daemon/src/task/mod.rs b/makima/src/daemon/task/mod.rs
index 29c261e..29c261e 100644
--- a/makima/daemon/src/task/mod.rs
+++ b/makima/src/daemon/task/mod.rs
diff --git a/makima/daemon/src/task/state.rs b/makima/src/daemon/task/state.rs
index fe73de1..ca5fc01 100644
--- a/makima/daemon/src/task/state.rs
+++ b/makima/src/daemon/task/state.rs
@@ -124,7 +124,7 @@ impl Default for TaskState {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_valid_transitions() {
diff --git a/makima/daemon/src/temp.rs b/makima/src/daemon/temp.rs
index 015b21b..42d4a28 100644
--- a/makima/daemon/src/temp.rs
+++ b/makima/src/daemon/temp.rs
@@ -214,7 +214,7 @@ impl Default for TempManager {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_temp_manager_default_dir() {
diff --git a/makima/daemon/src/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs
index 266b970..9af5dcb 100644
--- a/makima/daemon/src/worktree/manager.rs
+++ b/makima/src/daemon/worktree/manager.rs
@@ -1590,7 +1590,7 @@ pub fn sanitize_name(name: &str) -> String {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_extract_repo_name() {
diff --git a/makima/daemon/src/worktree/mod.rs b/makima/src/daemon/worktree/mod.rs
index eb9f031..eb9f031 100644
--- a/makima/daemon/src/worktree/mod.rs
+++ b/makima/src/daemon/worktree/mod.rs
diff --git a/makima/daemon/src/ws/client.rs b/makima/src/daemon/ws/client.rs
index ba1263f..67594a2 100644
--- a/makima/daemon/src/ws/client.rs
+++ b/makima/src/daemon/ws/client.rs
@@ -11,8 +11,8 @@ use tokio_tungstenite::{connect_async, tungstenite::{client::IntoClientRequest,
use uuid::Uuid;
use super::protocol::{DaemonCommand, DaemonMessage};
-use crate::config::ServerConfig;
-use crate::error::{DaemonError, Result};
+use crate::daemon::config::ServerConfig;
+use crate::daemon::error::{DaemonError, Result};
/// WebSocket client state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/makima/daemon/src/ws/mod.rs b/makima/src/daemon/ws/mod.rs
index 5a0e9d1..5a0e9d1 100644
--- a/makima/daemon/src/ws/mod.rs
+++ b/makima/src/daemon/ws/mod.rs
diff --git a/makima/daemon/src/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs
index 7c2ad6d..e86a577 100644
--- a/makima/daemon/src/ws/protocol.rs
+++ b/makima/src/daemon/ws/protocol.rs
@@ -74,6 +74,18 @@ pub enum DaemonMessage {
task_id: Uuid,
},
+ /// Authentication required - OAuth token expired, provides login URL.
+ AuthenticationRequired {
+ /// Task ID that triggered the auth error (if any).
+ #[serde(rename = "taskId")]
+ task_id: Option<Uuid>,
+ /// OAuth login URL for remote authentication.
+ #[serde(rename = "loginUrl")]
+ login_url: String,
+ /// Hostname of the daemon requiring auth.
+ hostname: Option<String>,
+ },
+
// =========================================================================
// Merge Response Messages (sent by daemon after processing merge commands)
// =========================================================================
@@ -172,6 +184,72 @@ pub enum DaemonMessage {
#[serde(rename = "targetDir")]
target_dir: String,
},
+
+ // =========================================================================
+ // Contract File Response Messages
+ // =========================================================================
+
+ /// Response to ReadRepoFile command.
+ RepoFileContent {
+ /// Request ID from the original command.
+ #[serde(rename = "requestId")]
+ request_id: Uuid,
+ /// Path to the file that was read.
+ #[serde(rename = "filePath")]
+ file_path: String,
+ /// File content (None if error occurred).
+ content: Option<String>,
+ /// Whether the operation succeeded.
+ success: bool,
+ /// Error message if operation failed.
+ error: Option<String>,
+ },
+
+ // =========================================================================
+ // Supervisor Git Response Messages
+ // =========================================================================
+
+ /// Response to CreateBranch command.
+ BranchCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ #[serde(rename = "branchName")]
+ branch_name: String,
+ message: String,
+ },
+
+ /// Response to MergeTaskToTarget command.
+ MergeToTargetResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ #[serde(rename = "commitSha")]
+ commit_sha: Option<String>,
+ conflicts: Option<Vec<String>>,
+ },
+
+ /// Response to CreatePR command.
+ PRCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ #[serde(rename = "prUrl")]
+ pr_url: Option<String>,
+ #[serde(rename = "prNumber")]
+ pr_number: Option<i32>,
+ },
+
+ /// Response to GetTaskDiff command.
+ TaskDiff {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ diff: Option<String>,
+ error: Option<String>,
+ },
}
/// Information about a branch (used in BranchList message).
@@ -239,6 +317,12 @@ pub enum DaemonCommand {
/// Files to copy from parent task's worktree.
#[serde(rename = "copyFiles")]
copy_files: Option<Vec<String>>,
+ /// Contract ID if this task is associated with a contract.
+ #[serde(rename = "contractId")]
+ contract_id: Option<Uuid>,
+ /// Whether this task is a supervisor (long-running contract orchestrator).
+ #[serde(rename = "isSupervisor", default)]
+ is_supervisor: bool,
},
/// Pause a running task.
@@ -383,6 +467,69 @@ pub enum DaemonCommand {
target_dir: String,
},
+ // =========================================================================
+ // Contract File Commands
+ // =========================================================================
+
+ /// Read a file from a repository linked to a contract.
+ ReadRepoFile {
+ /// Request ID for correlating response.
+ #[serde(rename = "requestId")]
+ request_id: Uuid,
+ /// Contract ID (used for logging/context).
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ /// Path to the file within the repository.
+ #[serde(rename = "filePath")]
+ file_path: String,
+ /// Full repository path on daemon's filesystem.
+ #[serde(rename = "repoPath")]
+ repo_path: String,
+ },
+
+ // =========================================================================
+ // Supervisor Git Commands
+ // =========================================================================
+
+ /// Create a new branch in the supervisor's worktree.
+ CreateBranch {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "branchName")]
+ branch_name: String,
+ /// Optional reference to create branch from (task_id or SHA).
+ #[serde(rename = "fromRef")]
+ from_ref: Option<String>,
+ },
+
+ /// Merge a task's changes to a target branch.
+ MergeTaskToTarget {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Target branch to merge into (default: task's base branch).
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ /// Whether to squash commits.
+ squash: bool,
+ },
+
+ /// Create a pull request for a task's changes.
+ CreatePR {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ title: String,
+ body: Option<String>,
+ /// Base branch for the PR (default: main).
+ #[serde(rename = "baseBranch")]
+ base_branch: String,
+ },
+
+ /// Get the diff for a task's changes.
+ GetTaskDiff {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
/// Error response.
Error {
code: String,
@@ -456,7 +603,7 @@ impl DaemonMessage {
#[cfg(test)]
mod tests {
- use super::*;
+ use crate::daemon::*;
#[test]
fn test_daemon_message_serialization() {
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 5064b97..e16c43f 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -60,6 +60,8 @@ pub enum BodyElement {
alt: Option<String>,
caption: Option<String>,
},
+ /// Raw markdown content - renders as formatted markdown, edits as raw text
+ Markdown { content: String },
}
/// File record from the database.
@@ -68,6 +70,10 @@ pub enum BodyElement {
pub struct File {
pub id: Uuid,
pub owner_id: Uuid,
+ /// Contract this file belongs to (optional)
+ pub contract_id: Option<Uuid>,
+ /// Phase of the contract when file was added (e.g., "research", "specify")
+ pub contract_phase: Option<String>,
pub name: String,
pub description: Option<String>,
#[sqlx(json)]
@@ -80,6 +86,12 @@ pub struct File {
pub body: Vec<BodyElement>,
/// Version number for optimistic locking
pub version: i32,
+ /// Path to linked repository file (e.g., "README.md", "docs/design.md")
+ pub repo_file_path: Option<String>,
+ /// When the file was last synced from the repository
+ pub repo_synced_at: Option<DateTime<Utc>>,
+ /// Sync status: 'none', 'synced', 'modified', 'conflict'
+ pub repo_sync_status: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -88,14 +100,24 @@ pub struct File {
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateFileRequest {
+ /// Contract this file belongs to (required - files must belong to a contract)
+ pub contract_id: Uuid,
/// Name of the file (auto-generated if not provided)
pub name: Option<String>,
/// Optional description
pub description: Option<String>,
- /// Transcript entries
+ /// Transcript entries (default to empty)
+ #[serde(default)]
pub transcript: Vec<TranscriptEntry>,
/// Storage location (e.g., s3://bucket/path) - not used yet
pub location: Option<String>,
+ /// Initial body elements (e.g., from a template)
+ #[serde(default)]
+ pub body: Vec<BodyElement>,
+ /// Path to linked repository file (e.g., "README.md")
+ pub repo_file_path: Option<String>,
+ /// Contract phase this file belongs to (for deliverable tracking)
+ pub contract_phase: Option<String>,
}
/// Request payload for updating an existing file.
@@ -114,6 +136,8 @@ pub struct UpdateFileRequest {
pub body: Option<Vec<BodyElement>>,
/// Version for optimistic locking (required for updates from frontend)
pub version: Option<i32>,
+ /// Path to linked repository file (e.g., "README.md")
+ pub repo_file_path: Option<String>,
}
/// Response for file list endpoint.
@@ -129,6 +153,12 @@ pub struct FileListResponse {
#[serde(rename_all = "camelCase")]
pub struct FileSummary {
pub id: Uuid,
+ /// Contract this file belongs to
+ pub contract_id: Option<Uuid>,
+ /// Contract name (joined from contracts table)
+ pub contract_name: Option<String>,
+ /// Phase when file was added to contract
+ pub contract_phase: Option<String>,
pub name: String,
pub description: Option<String>,
pub transcript_count: usize,
@@ -136,6 +166,10 @@ pub struct FileSummary {
pub duration: Option<f32>,
/// Version number for optimistic locking
pub version: i32,
+ /// Path to linked repository file
+ pub repo_file_path: Option<String>,
+ /// Sync status with repository
+ pub repo_sync_status: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -149,11 +183,16 @@ impl From<File> for FileSummary {
.fold(0.0_f32, f32::max);
Self {
id: file.id,
+ contract_id: file.contract_id,
+ contract_name: None, // Not available from File alone, requires JOIN
+ contract_phase: file.contract_phase,
name: file.name,
description: file.description,
transcript_count: file.transcript.len(),
duration: if duration > 0.0 { Some(duration) } else { None },
version: file.version,
+ repo_file_path: file.repo_file_path,
+ repo_sync_status: file.repo_sync_status,
created_at: file.created_at,
updated_at: file.updated_at,
}
@@ -345,8 +384,10 @@ impl std::str::FromStr for MergeMode {
pub struct Task {
pub id: Uuid,
pub owner_id: Uuid,
+ /// Contract this task belongs to (required for new tasks)
+ pub contract_id: Option<Uuid>,
pub parent_task_id: Option<Uuid>,
- /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max)
+ /// Depth in task hierarchy (no longer constrained)
pub depth: i32,
pub name: String,
pub description: Option<String>,
@@ -354,6 +395,11 @@ pub struct Task {
pub priority: i32,
pub plan: String,
+ // Supervisor flag
+ /// True for contract supervisor tasks. Only supervisors can spawn new tasks.
+ #[serde(default)]
+ pub is_supervisor: bool,
+
// Daemon/container info
pub daemon_id: Option<Uuid>,
pub container_id: Option<String>,
@@ -379,6 +425,30 @@ pub struct Task {
pub last_output: Option<String>,
pub error_message: Option<String>,
+ // Git checkpoint tracking
+ /// Git commit SHA of the most recent checkpoint
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_checkpoint_sha: Option<String>,
+ /// Number of checkpoints created by this task
+ #[serde(default)]
+ pub checkpoint_count: i32,
+ /// Message from the most recent checkpoint
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub checkpoint_message: Option<String>,
+
+ // Conversation state for resumption
+ /// Saved conversation context for task/supervisor resumption
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub conversation_state: Option<serde_json::Value>,
+
+ // Daemon migration tracking
+ /// Previous daemon if task was migrated
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub migrated_from_daemon_id: Option<Uuid>,
+ /// Most recent daemon that worked on this task
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_active_daemon_id: Option<Uuid>,
+
// Timestamps
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
@@ -413,6 +483,12 @@ impl Task {
#[serde(rename_all = "camelCase")]
pub struct TaskSummary {
pub id: Uuid,
+ /// Contract this task belongs to
+ pub contract_id: Option<Uuid>,
+ /// Contract name (joined from contracts table)
+ pub contract_name: Option<String>,
+ /// Contract phase (joined from contracts table)
+ pub contract_phase: Option<String>,
pub parent_task_id: Option<Uuid>,
/// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max)
pub depth: i32,
@@ -422,10 +498,36 @@ pub struct TaskSummary {
pub progress_summary: Option<String>,
pub subtask_count: i64,
pub version: i32,
+ /// True for contract supervisor tasks
+ #[serde(default)]
+ pub is_supervisor: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+/// Convert a full Task to a TaskSummary
+impl From<Task> for TaskSummary {
+ fn from(task: Task) -> Self {
+ Self {
+ id: task.id,
+ contract_id: task.contract_id,
+ contract_name: None, // Not available from Task directly
+ contract_phase: None, // Not available from Task directly
+ parent_task_id: task.parent_task_id,
+ depth: task.depth,
+ name: task.name,
+ status: task.status,
+ priority: task.priority,
+ progress_summary: task.progress_summary,
+ subtask_count: 0, // Would need separate query
+ version: task.version,
+ is_supervisor: task.is_supervisor,
+ created_at: task.created_at,
+ updated_at: task.updated_at,
+ }
+ }
+}
+
/// Response for task list endpoint
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
@@ -438,6 +540,8 @@ pub struct TaskListResponse {
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateTaskRequest {
+ /// Contract this task belongs to (required)
+ pub contract_id: Uuid,
/// Name of the task
pub name: String,
/// Optional description
@@ -446,6 +550,9 @@ pub struct CreateTaskRequest {
pub plan: String,
/// Parent task ID (for subtasks)
pub parent_task_id: Option<Uuid>,
+ /// True for contract supervisor tasks. Only supervisors can spawn new tasks.
+ #[serde(default)]
+ pub is_supervisor: bool,
/// Priority (higher = more urgent)
#[serde(default)]
pub priority: i32,
@@ -466,6 +573,8 @@ pub struct CreateTaskRequest {
/// Files to copy from parent task's worktree when starting
#[serde(skip_serializing_if = "Option::is_none")]
pub copy_files: Option<Vec<String>>,
+ /// Checkpoint SHA to branch from (optional)
+ pub checkpoint_sha: Option<String>,
}
/// Request payload for updating a task
@@ -482,6 +591,8 @@ pub struct UpdateTaskRequest {
pub error_message: Option<String>,
pub merge_mode: Option<String>,
pub pr_url: Option<String>,
+ /// Repository URL for the task (e.g., when updating supervisor with repo info)
+ pub repository_url: Option<String>,
/// Path to user's local repository (outside ~/.makima)
pub target_repo_path: Option<String>,
/// Action on completion: "none", "branch", "merge", "pr"
@@ -733,6 +844,47 @@ pub struct MeshChatHistoryResponse {
}
// =============================================================================
+// Contract Chat History Types
+// =============================================================================
+
+/// Conversation thread for contract chat (scoped to a specific contract)
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatConversation {
+ pub id: Uuid,
+ pub contract_id: Uuid,
+ pub owner_id: Uuid,
+ pub name: Option<String>,
+ pub is_active: bool,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Individual message in a contract chat conversation
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatMessageRecord {
+ pub id: Uuid,
+ pub conversation_id: Uuid,
+ pub role: String,
+ pub content: String,
+ /// Tool calls made during this message (JSON, nullable)
+ pub tool_calls: Option<serde_json::Value>,
+ /// Pending questions requiring user response (JSON, nullable)
+ pub pending_questions: Option<serde_json::Value>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Response for contract chat history endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatHistoryResponse {
+ pub contract_id: Uuid,
+ pub conversation_id: Uuid,
+ pub messages: Vec<ContractChatMessageRecord>,
+}
+
+// =============================================================================
// Merge API Types
// =============================================================================
@@ -834,3 +986,450 @@ pub struct MergeCompleteCheckResponse {
/// Count of skipped branches
pub skipped_count: u32,
}
+
+// =============================================================================
+// Contract Types
+// =============================================================================
+
+/// Contract phase for workflow progression
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum ContractPhase {
+ Research,
+ Specify,
+ Plan,
+ Execute,
+ Review,
+}
+
+impl std::fmt::Display for ContractPhase {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ContractPhase::Research => write!(f, "research"),
+ ContractPhase::Specify => write!(f, "specify"),
+ ContractPhase::Plan => write!(f, "plan"),
+ ContractPhase::Execute => write!(f, "execute"),
+ ContractPhase::Review => write!(f, "review"),
+ }
+ }
+}
+
+impl std::str::FromStr for ContractPhase {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "research" => Ok(ContractPhase::Research),
+ "specify" => Ok(ContractPhase::Specify),
+ "plan" => Ok(ContractPhase::Plan),
+ "execute" => Ok(ContractPhase::Execute),
+ "review" => Ok(ContractPhase::Review),
+ _ => Err(format!("Unknown contract phase: {}", s)),
+ }
+ }
+}
+
+/// Contract status
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum ContractStatus {
+ Active,
+ Completed,
+ Archived,
+}
+
+impl std::fmt::Display for ContractStatus {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ContractStatus::Active => write!(f, "active"),
+ ContractStatus::Completed => write!(f, "completed"),
+ ContractStatus::Archived => write!(f, "archived"),
+ }
+ }
+}
+
+impl std::str::FromStr for ContractStatus {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "active" => Ok(ContractStatus::Active),
+ "completed" => Ok(ContractStatus::Completed),
+ "archived" => Ok(ContractStatus::Archived),
+ _ => Err(format!("Unknown contract status: {}", s)),
+ }
+ }
+}
+
+/// Repository source type
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum RepositorySourceType {
+ /// Existing remote repo (GitHub, GitLab, etc)
+ Remote,
+ /// Existing local repo
+ Local,
+ /// New repo created/managed by Makima daemon
+ Managed,
+}
+
+impl std::fmt::Display for RepositorySourceType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RepositorySourceType::Remote => write!(f, "remote"),
+ RepositorySourceType::Local => write!(f, "local"),
+ RepositorySourceType::Managed => write!(f, "managed"),
+ }
+ }
+}
+
+impl std::str::FromStr for RepositorySourceType {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "remote" => Ok(RepositorySourceType::Remote),
+ "local" => Ok(RepositorySourceType::Local),
+ "managed" => Ok(RepositorySourceType::Managed),
+ _ => Err(format!("Unknown repository source type: {}", s)),
+ }
+ }
+}
+
+/// Repository status (for managed repos)
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum RepositoryStatus {
+ /// Repo is usable
+ Ready,
+ /// Waiting for daemon to create
+ Pending,
+ /// Daemon is creating the repo
+ Creating,
+ /// Creation failed
+ Failed,
+}
+
+impl std::fmt::Display for RepositoryStatus {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RepositoryStatus::Ready => write!(f, "ready"),
+ RepositoryStatus::Pending => write!(f, "pending"),
+ RepositoryStatus::Creating => write!(f, "creating"),
+ RepositoryStatus::Failed => write!(f, "failed"),
+ }
+ }
+}
+
+impl std::str::FromStr for RepositoryStatus {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "ready" => Ok(RepositoryStatus::Ready),
+ "pending" => Ok(RepositoryStatus::Pending),
+ "creating" => Ok(RepositoryStatus::Creating),
+ "failed" => Ok(RepositoryStatus::Failed),
+ _ => Err(format!("Unknown repository status: {}", s)),
+ }
+ }
+}
+
+/// Contract record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Contract {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub phase: String,
+ pub status: String,
+ /// The long-running supervisor task that orchestrates this contract
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub supervisor_task_id: Option<Uuid>,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl Contract {
+ /// Parse phase string to ContractPhase enum
+ pub fn phase_enum(&self) -> Result<ContractPhase, String> {
+ self.phase.parse()
+ }
+
+ /// Parse status string to ContractStatus enum
+ pub fn status_enum(&self) -> Result<ContractStatus, String> {
+ self.status.parse()
+ }
+}
+
+/// Contract repository record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractRepository {
+ pub id: Uuid,
+ pub contract_id: Uuid,
+ pub name: String,
+ pub repository_url: Option<String>,
+ pub local_path: Option<String>,
+ pub source_type: String,
+ pub status: String,
+ pub is_primary: bool,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl ContractRepository {
+ /// Parse source_type string to RepositorySourceType enum
+ pub fn source_type_enum(&self) -> Result<RepositorySourceType, String> {
+ self.source_type.parse()
+ }
+
+ /// Parse status string to RepositoryStatus enum
+ pub fn status_enum(&self) -> Result<RepositoryStatus, String> {
+ self.status.parse()
+ }
+}
+
+/// Summary of a contract for list views
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractSummary {
+ pub id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub phase: String,
+ pub status: String,
+ pub file_count: i64,
+ pub task_count: i64,
+ pub repository_count: i64,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Contract with all relations for detail view
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractWithRelations {
+ #[serde(flatten)]
+ pub contract: Contract,
+ pub repositories: Vec<ContractRepository>,
+ pub files: Vec<FileSummary>,
+ pub tasks: Vec<TaskSummary>,
+}
+
+/// Response for contract list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractListResponse {
+ pub contracts: Vec<ContractSummary>,
+ pub total: i64,
+}
+
+/// Request payload for creating a new contract
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateContractRequest {
+ /// Name of the contract
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// Initial phase to start in (defaults to "research")
+ /// Valid values: "research", "specify", "plan", "execute", "review"
+ #[serde(default)]
+ pub initial_phase: Option<String>,
+}
+
+/// Request payload for updating a contract
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateContractRequest {
+ pub name: Option<String>,
+ pub description: Option<String>,
+ pub phase: Option<String>,
+ pub status: Option<String>,
+ /// Supervisor task ID for contract orchestration
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub supervisor_task_id: Option<Uuid>,
+ /// Version for optimistic locking
+ pub version: Option<i32>,
+}
+
+/// Request to add a remote repository to a contract
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AddRemoteRepositoryRequest {
+ pub name: String,
+ pub repository_url: String,
+ #[serde(default)]
+ pub is_primary: bool,
+}
+
+/// Request to add a local repository to a contract
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AddLocalRepositoryRequest {
+ pub name: String,
+ pub local_path: String,
+ #[serde(default)]
+ pub is_primary: bool,
+}
+
+/// Request to create a managed repository (daemon will create it)
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateManagedRepositoryRequest {
+ pub name: String,
+ #[serde(default)]
+ pub is_primary: bool,
+}
+
+/// Request to change contract phase
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangePhaseRequest {
+ pub phase: String,
+}
+
+/// Contract event record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractEvent {
+ pub id: Uuid,
+ pub contract_id: Uuid,
+ pub event_type: String,
+ pub previous_phase: Option<String>,
+ pub new_phase: Option<String>,
+ #[sqlx(json)]
+ pub event_data: Option<serde_json::Value>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Response for contract events list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractEventListResponse {
+ pub events: Vec<ContractEvent>,
+ pub total: i64,
+}
+
+// ============================================================================
+// Task Checkpoints (for git checkpoint tracking)
+// ============================================================================
+
+/// Task checkpoint record - represents a git commit checkpoint
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskCheckpoint {
+ pub id: Uuid,
+ pub task_id: Uuid,
+ /// Sequential checkpoint number within this task
+ pub checkpoint_number: i32,
+ /// Git commit SHA
+ pub commit_sha: String,
+ /// Git branch name
+ pub branch_name: String,
+ /// Commit message
+ pub message: String,
+ /// Files changed in this commit: [{path, action: 'A'|'M'|'D'}]
+ #[sqlx(json)]
+ pub files_changed: Option<serde_json::Value>,
+ /// Lines added in this commit
+ pub lines_added: Option<i32>,
+ /// Lines removed in this commit
+ pub lines_removed: Option<i32>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Request to create a checkpoint
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateCheckpointRequest {
+ /// Commit message
+ pub message: String,
+}
+
+/// Response for checkpoint list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckpointListResponse {
+ pub checkpoints: Vec<TaskCheckpoint>,
+ pub total: i64,
+}
+
+// ============================================================================
+// Supervisor State (for supervisor resumability)
+// ============================================================================
+
+/// Supervisor state for contract supervisor tasks
+/// Enables resumption after interruption
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SupervisorState {
+ pub id: Uuid,
+ pub contract_id: Uuid,
+ pub task_id: Uuid,
+ /// Full Claude conversation history for resumption
+ #[sqlx(json)]
+ pub conversation_history: serde_json::Value,
+ /// Last checkpoint this supervisor created
+ pub last_checkpoint_id: Option<Uuid>,
+ /// Tasks the supervisor is waiting on
+ #[sqlx(try_from = "Vec<Uuid>")]
+ pub pending_task_ids: Vec<Uuid>,
+ /// Current contract phase when supervisor was last active
+ pub phase: String,
+ /// When supervisor was last active
+ pub last_activity: DateTime<Utc>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request to update supervisor state
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateSupervisorStateRequest {
+ /// Updated conversation history
+ pub conversation_history: Option<serde_json::Value>,
+ /// Tasks the supervisor is waiting on
+ pub pending_task_ids: Option<Vec<Uuid>>,
+ /// Current contract phase
+ pub phase: Option<String>,
+}
+
+// ============================================================================
+// Daemon Task Assignments (for multi-daemon support)
+// ============================================================================
+
+/// Daemon task assignment record
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonTaskAssignment {
+ pub id: Uuid,
+ pub daemon_id: Uuid,
+ pub task_id: Uuid,
+ pub assigned_at: DateTime<Utc>,
+ /// Status: 'active', 'migrating', 'completed'
+ pub status: String,
+}
+
+/// Extended daemon info for selection
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonWithCapacity {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub connection_id: String,
+ pub hostname: Option<String>,
+ pub machine_id: Option<String>,
+ pub max_concurrent_tasks: i32,
+ pub current_task_count: i32,
+ pub capacity_score: Option<i32>,
+ pub task_queue_length: Option<i32>,
+ pub supports_migration: Option<bool>,
+ pub status: String,
+ pub last_heartbeat_at: DateTime<Utc>,
+ pub connected_at: DateTime<Utc>,
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index ce1e97d..3b911c2 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -5,8 +5,12 @@ use sqlx::PgPool;
use uuid::Uuid;
use super::models::{
- CreateFileRequest, CreateTaskRequest, Daemon, File, FileVersion, MeshChatConversation,
- MeshChatMessageRecord, Task, TaskEvent, TaskSummary, UpdateFileRequest, UpdateTaskRequest,
+ Contract, ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository,
+ ContractSummary, CreateCheckpointRequest, CreateContractRequest, CreateFileRequest,
+ CreateTaskRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, File, FileSummary,
+ FileVersion, MeshChatConversation, MeshChatMessageRecord, SupervisorState, Task, TaskCheckpoint,
+ TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateSupervisorStateRequest,
+ UpdateTaskRequest,
};
/// Repository error types.
@@ -52,8 +56,18 @@ fn generate_default_name() -> String {
now.format("Recording - %b %d %Y %H:%M:%S").to_string()
}
-/// Create a new file record.
-pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> {
+/// Internal request for creating files without contract association (e.g., audio transcription).
+/// User-facing file creation should use CreateFileRequest which requires contract_id.
+pub struct InternalCreateFileRequest {
+ pub name: Option<String>,
+ pub description: Option<String>,
+ pub transcript: Vec<super::models::TranscriptEntry>,
+ pub location: Option<String>,
+}
+
+/// Create a new file record (internal use, no contract required).
+/// For user-facing file creation, use create_file_for_owner which requires a contract.
+pub async fn create_file(pool: &PgPool, req: InternalCreateFileRequest) -> Result<File, sqlx::Error> {
let name = req.name.unwrap_or_else(generate_default_name);
let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap();
@@ -62,7 +76,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File,
r#"
INSERT INTO files (name, description, transcript, location, summary, body)
VALUES ($1, $2, $3, $4, NULL, $5)
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(&name)
@@ -78,7 +92,7 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File,
pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
FROM files
WHERE id = $1
"#,
@@ -92,7 +106,7 @@ pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Err
pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
FROM files
ORDER BY created_at DESC
"#,
@@ -144,7 +158,7 @@ pub async fn update_file(
UPDATE files
SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW()
WHERE id = $1 AND version = $7
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(id)
@@ -163,7 +177,7 @@ pub async fn update_file(
UPDATE files
SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW()
WHERE id = $1
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(id)
@@ -219,6 +233,7 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
// =============================================================================
/// Create a new file record for a specific owner.
+/// Files must belong to a contract - the contract_id is required and the phase is looked up.
pub async fn create_file_for_owner(
pool: &PgPool,
owner_id: Uuid,
@@ -226,21 +241,38 @@ pub async fn create_file_for_owner(
) -> Result<File, sqlx::Error> {
let name = req.name.unwrap_or_else(generate_default_name);
let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
- let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap();
+ // Use body from request (may be empty or contain template elements)
+ let body_json = serde_json::to_value(&req.body).unwrap_or_default();
+
+ // Use provided contract_phase, or look up from contract's current phase
+ let contract_phase: Option<String> = if req.contract_phase.is_some() {
+ req.contract_phase
+ } else {
+ sqlx::query_scalar(
+ "SELECT phase FROM contracts WHERE id = $1 AND owner_id = $2",
+ )
+ .bind(req.contract_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?
+ };
sqlx::query_as::<_, File>(
r#"
- INSERT INTO files (owner_id, name, description, transcript, location, summary, body)
- VALUES ($1, $2, $3, $4, $5, NULL, $6)
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ INSERT INTO files (owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, repo_file_path)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9)
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(owner_id)
+ .bind(req.contract_id)
+ .bind(&contract_phase)
.bind(&name)
.bind(&req.description)
.bind(&transcript_json)
.bind(&req.location)
.bind(&body_json)
+ .bind(&req.repo_file_path)
.fetch_one(pool)
.await
}
@@ -253,7 +285,7 @@ pub async fn get_file_for_owner(
) -> Result<Option<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
FROM files
WHERE id = $1 AND owner_id = $2
"#,
@@ -268,7 +300,7 @@ pub async fn get_file_for_owner(
pub async fn list_files_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
- SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
FROM files
WHERE owner_id = $1
ORDER BY created_at DESC
@@ -279,6 +311,72 @@ pub async fn list_files_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<F
.await
}
+/// Database row type for file summary with contract info
+#[derive(Debug, sqlx::FromRow)]
+struct FileSummaryRow {
+ id: Uuid,
+ contract_id: Option<Uuid>,
+ contract_name: Option<String>,
+ contract_phase: Option<String>,
+ name: String,
+ description: Option<String>,
+ #[sqlx(json)]
+ transcript: Vec<crate::db::models::TranscriptEntry>,
+ version: i32,
+ repo_file_path: Option<String>,
+ repo_sync_status: Option<String>,
+ created_at: chrono::DateTime<chrono::Utc>,
+ updated_at: chrono::DateTime<chrono::Utc>,
+}
+
+/// List file summaries for an owner with contract info (joined).
+pub async fn list_file_summaries_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<FileSummary>, sqlx::Error> {
+ let rows = sqlx::query_as::<_, FileSummaryRow>(
+ r#"
+ SELECT
+ f.id, f.contract_id, c.name as contract_name, f.contract_phase,
+ f.name, f.description, f.transcript, f.version,
+ f.repo_file_path, f.repo_sync_status, f.created_at, f.updated_at
+ FROM files f
+ LEFT JOIN contracts c ON f.contract_id = c.id
+ WHERE f.owner_id = $1
+ ORDER BY f.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rows
+ .into_iter()
+ .map(|row| {
+ let duration = row
+ .transcript
+ .iter()
+ .map(|t| t.end)
+ .fold(0.0_f32, f32::max);
+ FileSummary {
+ id: row.id,
+ contract_id: row.contract_id,
+ contract_name: row.contract_name,
+ contract_phase: row.contract_phase,
+ name: row.name,
+ description: row.description,
+ transcript_count: row.transcript.len(),
+ duration: if duration > 0.0 { Some(duration) } else { None },
+ version: row.version,
+ repo_file_path: row.repo_file_path,
+ repo_sync_status: row.repo_sync_status,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ }
+ })
+ .collect())
+}
+
/// Update a file by ID with optimistic locking, scoped to owner.
pub async fn update_file_for_owner(
pool: &PgPool,
@@ -318,7 +416,7 @@ pub async fn update_file_for_owner(
UPDATE files
SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
WHERE id = $1 AND owner_id = $2 AND version = $8
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(id)
@@ -338,7 +436,7 @@ pub async fn update_file_for_owner(
UPDATE files
SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
- RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ RETURNING id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
"#,
)
.bind(id)
@@ -511,6 +609,7 @@ pub async fn restore_file_version(
summary: target.summary,
body: Some(target.body),
version: Some(current_version),
+ repo_file_path: None,
};
update_file(pool, file_id, update_req).await
@@ -540,26 +639,22 @@ pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sq
/// to max 1 (2 levels: orchestrator at depth 0, subtasks at depth 1).
///
/// NOTE: completion_action is NOT inherited - subtasks should not auto-merge unless
-/// explicitly configured. The orchestrator controls when completion steps happen.
+/// explicitly configured. The supervisor controls when completion steps happen.
+///
+/// Task spawning is now controlled by supervisors at the application level.
+/// Depth is no longer constrained in the database.
pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, sqlx::Error> {
// Calculate depth and inherit settings from parent if applicable
- let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
+ let (depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
if let Some(parent_id) = req.parent_task_id {
- // Fetch parent task to get depth and inherit repo settings
+ // Fetch parent task to get depth and inherit settings
let parent = get_task(pool, parent_id).await?
.ok_or_else(|| sqlx::Error::RowNotFound)?;
let new_depth = parent.depth + 1;
- // Validate max depth (must be < 2, i.e., 0 or 1 only)
- // Orchestrators are at depth 0, subtasks at depth 1
- // Subtasks cannot have their own children
- if new_depth >= 2 {
- return Err(sqlx::Error::Protocol(format!(
- "Maximum task depth exceeded. Cannot create subtask at depth {} (max is 1). Subtasks cannot have children.",
- new_depth
- )));
- }
+ // Subtasks inherit contract_id from parent
+ let contract_id = parent.contract_id.unwrap_or(req.contract_id);
// Inherit repo settings if not provided
let repo_url = req.repository_url.clone().or(parent.repository_url);
@@ -568,14 +663,15 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task,
let merge_mode = req.merge_mode.clone().or(parent.merge_mode);
let target_repo_path = req.target_repo_path.clone().or(parent.target_repo_path);
// NOTE: completion_action is NOT inherited - subtasks should not auto-merge.
- // The orchestrator integrates subtask work from their worktrees.
+ // The supervisor integrates subtask work from their worktrees.
let completion_action = req.completion_action.clone();
- (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
+ (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
} else {
- // Top-level task: depth 0
+ // Top-level task: depth 0, use contract_id from request
(
0,
+ req.contract_id,
req.repository_url.clone(),
req.base_branch.clone(),
req.target_branch.clone(),
@@ -590,20 +686,22 @@ pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task,
sqlx::query_as::<_, Task>(
r#"
INSERT INTO tasks (
- parent_task_id, depth, name, description, plan, priority,
- repository_url, base_branch, target_branch, merge_mode,
+ contract_id, parent_task_id, depth, name, description, plan, priority,
+ is_supervisor, repository_url, base_branch, target_branch, merge_mode,
target_repo_path, completion_action, continue_from_task_id, copy_files
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *
"#,
)
+ .bind(contract_id)
.bind(req.parent_task_id)
.bind(depth)
.bind(&req.name)
.bind(&req.description)
.bind(&req.plan)
.bind(req.priority)
+ .bind(req.is_supervisor)
.bind(&repo_url)
.bind(&base_branch)
.bind(&target_branch)
@@ -635,10 +733,13 @@ pub async fn list_tasks(pool: &PgPool) -> Result<Vec<TaskSummary>, sqlx::Error>
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.parent_task_id IS NULL
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -652,10 +753,13 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.parent_task_id = $1
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -665,6 +769,25 @@ pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSum
.await
}
+/// List all tasks in a contract (for supervisor tree view).
+pub async fn list_tasks_by_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Vec<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT * FROM tasks
+ WHERE contract_id = $1 AND owner_id = $2
+ ORDER BY is_supervisor DESC, depth ASC, created_at ASC
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
/// Update a task by ID with optimistic locking.
pub async fn update_task(
pool: &PgPool,
@@ -817,9 +940,9 @@ pub async fn create_task_for_owner(
req: CreateTaskRequest,
) -> Result<Task, sqlx::Error> {
// Calculate depth and inherit settings from parent if applicable
- let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
+ let (depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
if let Some(parent_id) = req.parent_task_id {
- // Fetch parent task to get depth and inherit repo settings (must belong to same owner)
+ // Fetch parent task to get depth and inherit settings (must belong to same owner)
let parent = get_task_for_owner(pool, parent_id, owner_id).await?
.ok_or_else(|| sqlx::Error::RowNotFound)?;
@@ -833,6 +956,9 @@ pub async fn create_task_for_owner(
)));
}
+ // Subtasks inherit contract_id from parent
+ let contract_id = parent.contract_id.unwrap_or(req.contract_id);
+
// Inherit repo settings if not provided
let repo_url = req.repository_url.clone().or(parent.repository_url);
let base_branch = req.base_branch.clone().or(parent.base_branch);
@@ -843,11 +969,12 @@ pub async fn create_task_for_owner(
// The orchestrator integrates subtask work from their worktrees.
let completion_action = req.completion_action.clone();
- (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
+ (new_depth, contract_id, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
} else {
- // Top-level task: depth 0
+ // Top-level task: depth 0, use contract_id from request
(
0,
+ req.contract_id,
req.repository_url.clone(),
req.base_branch.clone(),
req.target_branch.clone(),
@@ -862,21 +989,23 @@ pub async fn create_task_for_owner(
sqlx::query_as::<_, Task>(
r#"
INSERT INTO tasks (
- owner_id, parent_task_id, depth, name, description, plan, priority,
- repository_url, base_branch, target_branch, merge_mode,
+ owner_id, contract_id, parent_task_id, depth, name, description, plan, priority,
+ is_supervisor, repository_url, base_branch, target_branch, merge_mode,
target_repo_path, completion_action, continue_from_task_id, copy_files
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
"#,
)
.bind(owner_id)
+ .bind(contract_id)
.bind(req.parent_task_id)
.bind(depth)
.bind(&req.name)
.bind(&req.description)
.bind(&req.plan)
.bind(req.priority)
+ .bind(req.is_supervisor)
.bind(&repo_url)
.bind(&base_branch)
.bind(&target_branch)
@@ -916,10 +1045,13 @@ pub async fn list_tasks_for_owner(
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.owner_id = $1 AND t.parent_task_id IS NULL
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -938,10 +1070,13 @@ pub async fn list_subtasks_for_owner(
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.owner_id = $1 AND t.parent_task_id = $2
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -986,6 +1121,7 @@ pub async fn update_task_for_owner(
let error_message = req.error_message.or(existing.error_message);
let merge_mode = req.merge_mode.or(existing.merge_mode);
let pr_url = req.pr_url.or(existing.pr_url);
+ let repository_url = req.repository_url.or(existing.repository_url);
let target_repo_path = req.target_repo_path.or(existing.target_repo_path);
let completion_action = req.completion_action.or(existing.completion_action);
let daemon_id = if req.clear_daemon_id {
@@ -1002,8 +1138,9 @@ pub async fn update_task_for_owner(
SET name = $3, description = $4, plan = $5, status = $6, priority = $7,
progress_summary = $8, last_output = $9, error_message = $10,
merge_mode = $11, pr_url = $12, daemon_id = $13,
- target_repo_path = $14, completion_action = $15, updated_at = NOW()
- WHERE id = $1 AND owner_id = $2 AND version = $16
+ target_repo_path = $14, completion_action = $15, repository_url = $16,
+ updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $17
RETURNING *
"#,
)
@@ -1022,6 +1159,7 @@ pub async fn update_task_for_owner(
.bind(daemon_id)
.bind(&target_repo_path)
.bind(&completion_action)
+ .bind(&repository_url)
.bind(req.version.unwrap())
.fetch_optional(pool)
.await?
@@ -1032,7 +1170,8 @@ pub async fn update_task_for_owner(
SET name = $3, description = $4, plan = $5, status = $6, priority = $7,
progress_summary = $8, last_output = $9, error_message = $10,
merge_mode = $11, pr_url = $12, daemon_id = $13,
- target_repo_path = $14, completion_action = $15, updated_at = NOW()
+ target_repo_path = $14, completion_action = $15, repository_url = $16,
+ updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
"#,
@@ -1052,6 +1191,7 @@ pub async fn update_task_for_owner(
.bind(daemon_id)
.bind(&target_repo_path)
.bind(&completion_action)
+ .bind(&repository_url)
.fetch_optional(pool)
.await?
};
@@ -1328,6 +1468,26 @@ pub async fn update_daemon_status(
Ok(result.rows_affected() > 0)
}
+/// Mark daemon as disconnected by connection_id.
+pub async fn disconnect_daemon_by_connection(
+ pool: &PgPool,
+ connection_id: &str,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE daemons
+ SET status = 'disconnected',
+ disconnected_at = NOW()
+ WHERE connection_id = $1
+ "#,
+ )
+ .bind(connection_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
/// Update daemon task count.
pub async fn update_daemon_task_count(
pool: &PgPool,
@@ -1393,6 +1553,25 @@ pub async fn count_daemons(pool: &PgPool) -> Result<i64, sqlx::Error> {
Ok(result.0)
}
+/// Delete stale daemons that haven't sent a heartbeat within the timeout.
+/// Returns the number of deleted daemons.
+pub async fn delete_stale_daemons(
+ pool: &PgPool,
+ timeout_seconds: i64,
+) -> Result<u64, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM daemons
+ WHERE last_heartbeat_at < NOW() - INTERVAL '1 second' * $1
+ "#,
+ )
+ .bind(timeout_seconds)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected())
+}
+
// =============================================================================
// Sibling Awareness Functions
// =============================================================================
@@ -1408,10 +1587,13 @@ pub async fn list_sibling_tasks(
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.parent_task_id = $1 AND t.id != $2
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -1426,10 +1608,13 @@ pub async fn list_sibling_tasks(
sqlx::query_as::<_, TaskSummary>(
r#"
SELECT
- t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
- t.progress_summary, t.version, t.created_at, t.updated_at,
- (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
WHERE t.parent_task_id IS NULL AND t.id != $1
ORDER BY t.priority DESC, t.created_at DESC
"#,
@@ -1710,3 +1895,1092 @@ pub async fn clear_conversation(pool: &PgPool, owner_id: Uuid) -> Result<MeshCha
// Create new active conversation
get_or_create_active_conversation(pool, owner_id).await
}
+
+// =============================================================================
+// Contract Chat History Functions
+// =============================================================================
+
+/// Get or create the active conversation for a contract.
+pub async fn get_or_create_contract_conversation(
+ pool: &PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<ContractChatConversation, sqlx::Error> {
+ // Try to get existing active conversation for this contract
+ let existing = sqlx::query_as::<_, ContractChatConversation>(
+ r#"
+ SELECT *
+ FROM contract_chat_conversations
+ WHERE is_active = true AND contract_id = $1 AND owner_id = $2
+ LIMIT 1
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if let Some(conv) = existing {
+ return Ok(conv);
+ }
+
+ // Create new conversation
+ sqlx::query_as::<_, ContractChatConversation>(
+ r#"
+ INSERT INTO contract_chat_conversations (contract_id, owner_id, is_active)
+ VALUES ($1, $2, true)
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// List messages for a contract conversation.
+pub async fn list_contract_chat_messages(
+ pool: &PgPool,
+ conversation_id: Uuid,
+ limit: Option<i32>,
+) -> Result<Vec<ContractChatMessageRecord>, sqlx::Error> {
+ let limit = limit.unwrap_or(100);
+ sqlx::query_as::<_, ContractChatMessageRecord>(
+ r#"
+ SELECT *
+ FROM contract_chat_messages
+ WHERE conversation_id = $1
+ ORDER BY created_at ASC
+ LIMIT $2
+ "#,
+ )
+ .bind(conversation_id)
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
+
+/// Add a message to a contract conversation.
+pub async fn add_contract_chat_message(
+ pool: &PgPool,
+ conversation_id: Uuid,
+ role: &str,
+ content: &str,
+ tool_calls: Option<serde_json::Value>,
+ pending_questions: Option<serde_json::Value>,
+) -> Result<ContractChatMessageRecord, sqlx::Error> {
+ sqlx::query_as::<_, ContractChatMessageRecord>(
+ r#"
+ INSERT INTO contract_chat_messages
+ (conversation_id, role, content, tool_calls, pending_questions)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *
+ "#,
+ )
+ .bind(conversation_id)
+ .bind(role)
+ .bind(content)
+ .bind(tool_calls)
+ .bind(pending_questions)
+ .fetch_one(pool)
+ .await
+}
+
+/// Clear contract conversation (archive existing and create new).
+pub async fn clear_contract_conversation(
+ pool: &PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<ContractChatConversation, sqlx::Error> {
+ // Mark existing as inactive for this contract
+ sqlx::query(
+ r#"
+ UPDATE contract_chat_conversations
+ SET is_active = false, updated_at = NOW()
+ WHERE is_active = true AND contract_id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ // Create new active conversation
+ get_or_create_contract_conversation(pool, contract_id, owner_id).await
+}
+
+// =============================================================================
+// Contract Functions (Owner-Scoped)
+// =============================================================================
+
+/// Create a new contract for a specific owner.
+pub async fn create_contract_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateContractRequest,
+) -> Result<Contract, sqlx::Error> {
+ // Use provided initial_phase or default to "research"
+ let phase = req.initial_phase.as_deref().unwrap_or("research");
+
+ // Validate the phase
+ let valid_phases = ["research", "specify", "plan", "execute", "review"];
+ if !valid_phases.contains(&phase) {
+ return Err(sqlx::Error::Protocol(format!(
+ "Invalid initial_phase '{}'. Must be one of: {}",
+ phase,
+ valid_phases.join(", ")
+ )));
+ }
+
+ sqlx::query_as::<_, Contract>(
+ r#"
+ INSERT INTO contracts (owner_id, name, description, phase)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(phase)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a contract by ID, scoped to owner.
+pub async fn get_contract_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<Contract>, sqlx::Error> {
+ sqlx::query_as::<_, Contract>(
+ r#"
+ SELECT *
+ FROM contracts
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all contracts for an owner, ordered by created_at DESC.
+pub async fn list_contracts_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<ContractSummary>, sqlx::Error> {
+ sqlx::query_as::<_, ContractSummary>(
+ r#"
+ SELECT
+ c.id, c.name, c.description, c.phase, c.status,
+ c.version, c.created_at,
+ (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count,
+ (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count,
+ (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count
+ FROM contracts c
+ WHERE c.owner_id = $1
+ ORDER BY c.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Get contract summary by ID.
+pub async fn get_contract_summary_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<ContractSummary>, sqlx::Error> {
+ sqlx::query_as::<_, ContractSummary>(
+ r#"
+ SELECT
+ c.id, c.name, c.description, c.phase, c.status,
+ c.version, c.created_at,
+ (SELECT COUNT(*) FROM files WHERE contract_id = c.id) as file_count,
+ (SELECT COUNT(*) FROM tasks WHERE contract_id = c.id) as task_count,
+ (SELECT COUNT(*) FROM contract_repositories WHERE contract_id = c.id) as repository_count
+ FROM contracts c
+ WHERE c.id = $1 AND c.owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Update a contract by ID with optimistic locking, scoped to owner.
+pub async fn update_contract_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ req: UpdateContractRequest,
+) -> Result<Option<Contract>, RepositoryError> {
+ let existing = get_contract_for_owner(pool, id, owner_id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Check version if provided (optimistic locking)
+ if let Some(expected_version) = req.version {
+ if existing.version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: existing.version,
+ });
+ }
+ }
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let phase = req.phase.unwrap_or(existing.phase);
+ let status = req.status.unwrap_or(existing.status);
+ let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id);
+
+ let result = if req.version.is_some() {
+ sqlx::query_as::<_, Contract>(
+ r#"
+ UPDATE contracts
+ SET name = $3, description = $4, phase = $5, status = $6,
+ supervisor_task_id = $7, version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $8
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&phase)
+ .bind(&status)
+ .bind(supervisor_task_id)
+ .bind(req.version.unwrap())
+ .fetch_optional(pool)
+ .await?
+ } else {
+ sqlx::query_as::<_, Contract>(
+ r#"
+ UPDATE contracts
+ SET name = $3, description = $4, phase = $5, status = $6,
+ supervisor_task_id = $7, version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&phase)
+ .bind(&status)
+ .bind(supervisor_task_id)
+ .fetch_optional(pool)
+ .await?
+ };
+
+ // If versioned update returned None, there was a race condition
+ if result.is_none() && req.version.is_some() {
+ if let Some(current) = get_contract_for_owner(pool, id, owner_id).await? {
+ return Err(RepositoryError::VersionConflict {
+ expected: req.version.unwrap(),
+ actual: current.version,
+ });
+ }
+ }
+
+ Ok(result)
+}
+
+/// Delete a contract by ID, scoped to owner.
+pub async fn delete_contract_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM contracts
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Change contract phase and record event.
+pub async fn change_contract_phase_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ new_phase: &str,
+) -> Result<Option<Contract>, sqlx::Error> {
+ // Get current phase
+ let existing = get_contract_for_owner(pool, id, owner_id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ let previous_phase = existing.phase.clone();
+
+ // Update phase
+ let contract = sqlx::query_as::<_, Contract>(
+ r#"
+ UPDATE contracts
+ SET phase = $3, version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(new_phase)
+ .fetch_optional(pool)
+ .await?;
+
+ // Record event
+ if contract.is_some() {
+ sqlx::query(
+ r#"
+ INSERT INTO contract_events (contract_id, event_type, previous_phase, new_phase)
+ VALUES ($1, 'phase_change', $2, $3)
+ "#,
+ )
+ .bind(id)
+ .bind(&previous_phase)
+ .bind(new_phase)
+ .execute(pool)
+ .await?;
+ }
+
+ Ok(contract)
+}
+
+// =============================================================================
+// Contract Repository Functions
+// =============================================================================
+
+/// List repositories for a contract.
+pub async fn list_contract_repositories(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Vec<ContractRepository>, sqlx::Error> {
+ sqlx::query_as::<_, ContractRepository>(
+ r#"
+ SELECT *
+ FROM contract_repositories
+ WHERE contract_id = $1
+ ORDER BY is_primary DESC, created_at ASC
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Add a remote repository to a contract.
+pub async fn add_remote_repository(
+ pool: &PgPool,
+ contract_id: Uuid,
+ name: &str,
+ repository_url: &str,
+ is_primary: bool,
+) -> Result<ContractRepository, sqlx::Error> {
+ // If is_primary, clear other primaries first
+ if is_primary {
+ sqlx::query(
+ r#"
+ UPDATE contract_repositories
+ SET is_primary = false, updated_at = NOW()
+ WHERE contract_id = $1 AND is_primary = true
+ "#,
+ )
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+ }
+
+ sqlx::query_as::<_, ContractRepository>(
+ r#"
+ INSERT INTO contract_repositories (contract_id, name, repository_url, source_type, status, is_primary)
+ VALUES ($1, $2, $3, 'remote', 'ready', $4)
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(name)
+ .bind(repository_url)
+ .bind(is_primary)
+ .fetch_one(pool)
+ .await
+}
+
+/// Add a local repository to a contract.
+pub async fn add_local_repository(
+ pool: &PgPool,
+ contract_id: Uuid,
+ name: &str,
+ local_path: &str,
+ is_primary: bool,
+) -> Result<ContractRepository, sqlx::Error> {
+ // If is_primary, clear other primaries first
+ if is_primary {
+ sqlx::query(
+ r#"
+ UPDATE contract_repositories
+ SET is_primary = false, updated_at = NOW()
+ WHERE contract_id = $1 AND is_primary = true
+ "#,
+ )
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+ }
+
+ sqlx::query_as::<_, ContractRepository>(
+ r#"
+ INSERT INTO contract_repositories (contract_id, name, local_path, source_type, status, is_primary)
+ VALUES ($1, $2, $3, 'local', 'ready', $4)
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(name)
+ .bind(local_path)
+ .bind(is_primary)
+ .fetch_one(pool)
+ .await
+}
+
+/// Create a managed repository (daemon will create it).
+pub async fn create_managed_repository(
+ pool: &PgPool,
+ contract_id: Uuid,
+ name: &str,
+ is_primary: bool,
+) -> Result<ContractRepository, sqlx::Error> {
+ // If is_primary, clear other primaries first
+ if is_primary {
+ sqlx::query(
+ r#"
+ UPDATE contract_repositories
+ SET is_primary = false, updated_at = NOW()
+ WHERE contract_id = $1 AND is_primary = true
+ "#,
+ )
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+ }
+
+ sqlx::query_as::<_, ContractRepository>(
+ r#"
+ INSERT INTO contract_repositories (contract_id, name, source_type, status, is_primary)
+ VALUES ($1, $2, 'managed', 'pending', $3)
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(name)
+ .bind(is_primary)
+ .fetch_one(pool)
+ .await
+}
+
+/// Delete a repository from a contract.
+pub async fn delete_contract_repository(
+ pool: &PgPool,
+ repo_id: Uuid,
+ contract_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM contract_repositories
+ WHERE id = $1 AND contract_id = $2
+ "#,
+ )
+ .bind(repo_id)
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Set a repository as primary (and clear others).
+pub async fn set_repository_primary(
+ pool: &PgPool,
+ repo_id: Uuid,
+ contract_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ // Clear other primaries
+ sqlx::query(
+ r#"
+ UPDATE contract_repositories
+ SET is_primary = false, updated_at = NOW()
+ WHERE contract_id = $1 AND is_primary = true
+ "#,
+ )
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+
+ // Set this one as primary
+ let result = sqlx::query(
+ r#"
+ UPDATE contract_repositories
+ SET is_primary = true, updated_at = NOW()
+ WHERE id = $1 AND contract_id = $2
+ "#,
+ )
+ .bind(repo_id)
+ .bind(contract_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Update managed repository status (used by daemon).
+pub async fn update_managed_repository_status(
+ pool: &PgPool,
+ repo_id: Uuid,
+ status: &str,
+ repository_url: Option<&str>,
+) -> Result<Option<ContractRepository>, sqlx::Error> {
+ sqlx::query_as::<_, ContractRepository>(
+ r#"
+ UPDATE contract_repositories
+ SET status = $2, repository_url = COALESCE($3, repository_url), updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(repo_id)
+ .bind(status)
+ .bind(repository_url)
+ .fetch_optional(pool)
+ .await
+}
+
+// =============================================================================
+// Contract Task Association Functions
+// =============================================================================
+
+/// Add a task to a contract.
+pub async fn add_task_to_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ task_id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE tasks
+ SET contract_id = $2, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $3
+ "#,
+ )
+ .bind(task_id)
+ .bind(contract_id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Remove a task from a contract.
+pub async fn remove_task_from_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ task_id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE tasks
+ SET contract_id = NULL, updated_at = NOW()
+ WHERE id = $1 AND contract_id = $2 AND owner_id = $3
+ "#,
+ )
+ .bind(task_id)
+ .bind(contract_id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// List files in a contract.
+pub async fn list_files_in_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Vec<FileSummary>, sqlx::Error> {
+ // Use a manual query since FileSummary doesn't have a FromRow derive with all the computed fields
+ let files = sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, contract_id, contract_phase, name, description, transcript, location, summary, body, version, repo_file_path, repo_synced_at, repo_sync_status, created_at, updated_at
+ FROM files
+ WHERE contract_id = $1 AND owner_id = $2
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(files.into_iter().map(FileSummary::from).collect())
+}
+
+/// List tasks in a contract.
+pub async fn list_tasks_in_contract(
+ pool: &PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.contract_id, c.name as contract_name, c.phase as contract_phase,
+ t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count,
+ t.version, t.is_supervisor, t.created_at, t.updated_at
+ FROM tasks t
+ LEFT JOIN contracts c ON t.contract_id = c.id
+ WHERE t.contract_id = $1 AND t.owner_id = $2
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(contract_id)
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+// =============================================================================
+// Contract Events
+// =============================================================================
+
+/// List events for a contract.
+pub async fn list_contract_events(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Vec<ContractEvent>, sqlx::Error> {
+ sqlx::query_as::<_, ContractEvent>(
+ r#"
+ SELECT *
+ FROM contract_events
+ WHERE contract_id = $1
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Record a contract event.
+pub async fn record_contract_event(
+ pool: &PgPool,
+ contract_id: Uuid,
+ event_type: &str,
+ event_data: Option<serde_json::Value>,
+) -> Result<ContractEvent, sqlx::Error> {
+ sqlx::query_as::<_, ContractEvent>(
+ r#"
+ INSERT INTO contract_events (contract_id, event_type, event_data)
+ VALUES ($1, $2, $3)
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(event_type)
+ .bind(event_data)
+ .fetch_one(pool)
+ .await
+}
+
+// ============================================================================
+// Task Checkpoints
+// ============================================================================
+
+/// Create a checkpoint for a task.
+pub async fn create_task_checkpoint(
+ pool: &PgPool,
+ task_id: Uuid,
+ commit_sha: &str,
+ branch_name: &str,
+ message: &str,
+ files_changed: Option<serde_json::Value>,
+ lines_added: Option<i32>,
+ lines_removed: Option<i32>,
+) -> Result<TaskCheckpoint, sqlx::Error> {
+ // Get current checkpoint count and increment
+ let checkpoint_number: i32 = sqlx::query_scalar(
+ "SELECT COALESCE(MAX(checkpoint_number), 0) + 1 FROM task_checkpoints WHERE task_id = $1",
+ )
+ .bind(task_id)
+ .fetch_one(pool)
+ .await?;
+
+ // Update task's checkpoint tracking
+ sqlx::query(
+ r#"
+ UPDATE tasks
+ SET last_checkpoint_sha = $1,
+ checkpoint_count = $2,
+ checkpoint_message = $3,
+ updated_at = NOW()
+ WHERE id = $4
+ "#,
+ )
+ .bind(commit_sha)
+ .bind(checkpoint_number)
+ .bind(message)
+ .bind(task_id)
+ .execute(pool)
+ .await?;
+
+ sqlx::query_as::<_, TaskCheckpoint>(
+ r#"
+ INSERT INTO task_checkpoints (
+ task_id, checkpoint_number, commit_sha, branch_name, message,
+ files_changed, lines_added, lines_removed
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING *
+ "#,
+ )
+ .bind(task_id)
+ .bind(checkpoint_number)
+ .bind(commit_sha)
+ .bind(branch_name)
+ .bind(message)
+ .bind(files_changed)
+ .bind(lines_added)
+ .bind(lines_removed)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a checkpoint by ID.
+pub async fn get_task_checkpoint(
+ pool: &PgPool,
+ id: Uuid,
+) -> Result<Option<TaskCheckpoint>, sqlx::Error> {
+ sqlx::query_as::<_, TaskCheckpoint>("SELECT * FROM task_checkpoints WHERE id = $1")
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Get a checkpoint by commit SHA.
+pub async fn get_task_checkpoint_by_sha(
+ pool: &PgPool,
+ commit_sha: &str,
+) -> Result<Option<TaskCheckpoint>, sqlx::Error> {
+ sqlx::query_as::<_, TaskCheckpoint>("SELECT * FROM task_checkpoints WHERE commit_sha = $1")
+ .bind(commit_sha)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List checkpoints for a task.
+pub async fn list_task_checkpoints(
+ pool: &PgPool,
+ task_id: Uuid,
+) -> Result<Vec<TaskCheckpoint>, sqlx::Error> {
+ sqlx::query_as::<_, TaskCheckpoint>(
+ "SELECT * FROM task_checkpoints WHERE task_id = $1 ORDER BY checkpoint_number DESC",
+ )
+ .bind(task_id)
+ .fetch_all(pool)
+ .await
+}
+
+// ============================================================================
+// Supervisor State
+// ============================================================================
+
+/// Create or update supervisor state for a contract.
+pub async fn upsert_supervisor_state(
+ pool: &PgPool,
+ contract_id: Uuid,
+ task_id: Uuid,
+ conversation_history: serde_json::Value,
+ pending_task_ids: &[Uuid],
+ phase: &str,
+) -> Result<SupervisorState, sqlx::Error> {
+ sqlx::query_as::<_, SupervisorState>(
+ r#"
+ INSERT INTO supervisor_states (contract_id, task_id, conversation_history, pending_task_ids, phase, last_activity)
+ VALUES ($1, $2, $3, $4, $5, NOW())
+ ON CONFLICT (contract_id) DO UPDATE SET
+ task_id = EXCLUDED.task_id,
+ conversation_history = EXCLUDED.conversation_history,
+ pending_task_ids = EXCLUDED.pending_task_ids,
+ phase = EXCLUDED.phase,
+ last_activity = NOW(),
+ updated_at = NOW()
+ RETURNING *
+ "#,
+ )
+ .bind(contract_id)
+ .bind(task_id)
+ .bind(conversation_history)
+ .bind(pending_task_ids)
+ .bind(phase)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get supervisor state for a contract.
+pub async fn get_supervisor_state(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Option<SupervisorState>, sqlx::Error> {
+ sqlx::query_as::<_, SupervisorState>("SELECT * FROM supervisor_states WHERE contract_id = $1")
+ .bind(contract_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Get supervisor state by task ID.
+pub async fn get_supervisor_state_by_task(
+ pool: &PgPool,
+ task_id: Uuid,
+) -> Result<Option<SupervisorState>, sqlx::Error> {
+ sqlx::query_as::<_, SupervisorState>("SELECT * FROM supervisor_states WHERE task_id = $1")
+ .bind(task_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Update supervisor conversation history.
+pub async fn update_supervisor_conversation(
+ pool: &PgPool,
+ contract_id: Uuid,
+ conversation_history: serde_json::Value,
+) -> Result<SupervisorState, sqlx::Error> {
+ sqlx::query_as::<_, SupervisorState>(
+ r#"
+ UPDATE supervisor_states
+ SET conversation_history = $1,
+ last_activity = NOW(),
+ updated_at = NOW()
+ WHERE contract_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(conversation_history)
+ .bind(contract_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Update supervisor pending tasks.
+pub async fn update_supervisor_pending_tasks(
+ pool: &PgPool,
+ contract_id: Uuid,
+ pending_task_ids: &[Uuid],
+) -> Result<SupervisorState, sqlx::Error> {
+ sqlx::query_as::<_, SupervisorState>(
+ r#"
+ UPDATE supervisor_states
+ SET pending_task_ids = $1,
+ last_activity = NOW(),
+ updated_at = NOW()
+ WHERE contract_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(pending_task_ids)
+ .bind(contract_id)
+ .fetch_one(pool)
+ .await
+}
+
+// ============================================================================
+// Contract Supervisor
+// ============================================================================
+
+/// Update contract's supervisor task ID.
+pub async fn update_contract_supervisor(
+ pool: &PgPool,
+ contract_id: Uuid,
+ supervisor_task_id: Uuid,
+) -> Result<Contract, sqlx::Error> {
+ sqlx::query_as::<_, Contract>(
+ r#"
+ UPDATE contracts
+ SET supervisor_task_id = $1,
+ updated_at = NOW()
+ WHERE id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(supervisor_task_id)
+ .bind(contract_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get the supervisor task for a contract.
+pub async fn get_contract_supervisor_task(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Option<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT t.* FROM tasks t
+ JOIN contracts c ON c.supervisor_task_id = t.id
+ WHERE c.id = $1
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_optional(pool)
+ .await
+}
+
+// ============================================================================
+// Task Tree Queries
+// ============================================================================
+
+/// Get full task tree for a contract.
+pub async fn get_contract_task_tree(
+ pool: &PgPool,
+ contract_id: Uuid,
+) -> Result<Vec<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ WITH RECURSIVE task_tree AS (
+ -- Base case: root tasks (no parent)
+ SELECT * FROM tasks
+ WHERE contract_id = $1 AND parent_task_id IS NULL
+ UNION ALL
+ -- Recursive case: children of current level
+ SELECT t.* FROM tasks t
+ JOIN task_tree tt ON t.parent_task_id = tt.id
+ )
+ SELECT * FROM task_tree
+ ORDER BY depth, created_at
+ "#,
+ )
+ .bind(contract_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Get task tree from a specific root task.
+pub async fn get_task_tree(pool: &PgPool, root_task_id: Uuid) -> Result<Vec<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ WITH RECURSIVE task_tree AS (
+ -- Base case: the root task
+ SELECT * FROM tasks WHERE id = $1
+ UNION ALL
+ -- Recursive case: children of current level
+ SELECT t.* FROM tasks t
+ JOIN task_tree tt ON t.parent_task_id = tt.id
+ )
+ SELECT * FROM task_tree
+ ORDER BY depth, created_at
+ "#,
+ )
+ .bind(root_task_id)
+ .fetch_all(pool)
+ .await
+}
+
+// ============================================================================
+// Daemon Selection
+// ============================================================================
+
+/// Get daemons with capacity info for selection.
+pub async fn get_available_daemons(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<DaemonWithCapacity>, sqlx::Error> {
+ sqlx::query_as::<_, DaemonWithCapacity>(
+ r#"
+ SELECT id, owner_id, connection_id, hostname, machine_id,
+ max_concurrent_tasks, current_task_count,
+ capacity_score, task_queue_length, supports_migration,
+ status, last_heartbeat_at, connected_at
+ FROM daemons
+ WHERE owner_id = $1 AND status = 'connected'
+ ORDER BY
+ COALESCE(capacity_score, 100) DESC,
+ (max_concurrent_tasks - current_task_count) DESC,
+ COALESCE(task_queue_length, 0) ASC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Create a daemon task assignment.
+pub async fn create_daemon_task_assignment(
+ pool: &PgPool,
+ daemon_id: Uuid,
+ task_id: Uuid,
+) -> Result<DaemonTaskAssignment, sqlx::Error> {
+ sqlx::query_as::<_, DaemonTaskAssignment>(
+ r#"
+ INSERT INTO daemon_task_assignments (daemon_id, task_id)
+ VALUES ($1, $2)
+ RETURNING *
+ "#,
+ )
+ .bind(daemon_id)
+ .bind(task_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Update daemon task assignment status.
+pub async fn update_daemon_task_assignment_status(
+ pool: &PgPool,
+ task_id: Uuid,
+ status: &str,
+) -> Result<DaemonTaskAssignment, sqlx::Error> {
+ sqlx::query_as::<_, DaemonTaskAssignment>(
+ r#"
+ UPDATE daemon_task_assignments
+ SET status = $1
+ WHERE task_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(status)
+ .bind(task_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get daemon task assignment for a task.
+pub async fn get_daemon_task_assignment(
+ pool: &PgPool,
+ task_id: Uuid,
+) -> Result<Option<DaemonTaskAssignment>, sqlx::Error> {
+ sqlx::query_as::<_, DaemonTaskAssignment>(
+ "SELECT * FROM daemon_task_assignments WHERE task_id = $1",
+ )
+ .bind(task_id)
+ .fetch_optional(pool)
+ .await
+}
diff --git a/makima/src/lib.rs b/makima/src/lib.rs
index 064b123..8d3db58 100644
--- a/makima/src/lib.rs
+++ b/makima/src/lib.rs
@@ -1,4 +1,5 @@
pub mod audio;
+pub mod daemon;
pub mod db;
pub mod listen;
pub mod llm;
diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs
new file mode 100644
index 0000000..0d6f9be
--- /dev/null
+++ b/makima/src/llm/contract_tools.rs
@@ -0,0 +1,1091 @@
+//! Tool definitions for contract management via LLM.
+//!
+//! These tools allow the LLM to manage contracts: create tasks, add files,
+//! manage repositories, and handle phase transitions.
+
+use serde_json::json;
+use uuid::Uuid;
+
+use super::tools::Tool;
+
+/// Available tools for contract management
+pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| {
+ vec![
+ // =============================================================================
+ // Query Tools
+ // =============================================================================
+ Tool {
+ name: "get_contract_status".to_string(),
+ description: "Get an overview of the contract including current phase, file count, task count, and repository count.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "list_contract_files".to_string(),
+ description: "List all files in the contract with their names, descriptions, and phases.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "list_contract_tasks".to_string(),
+ description: "List all tasks in the contract with their names, status, and progress.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "list_contract_repositories".to_string(),
+ description: "List all repositories attached to the contract with their types and URLs/paths.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "read_file".to_string(),
+ description: "Read the full contents of a file including its body, transcript, and summary.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "file_id": {
+ "type": "string",
+ "description": "ID of the file to read"
+ }
+ },
+ "required": ["file_id"]
+ }),
+ },
+ // =============================================================================
+ // File Management Tools
+ // =============================================================================
+ Tool {
+ name: "create_file_from_template".to_string(),
+ description: "Create a new file in the contract from a template. Templates are phase-appropriate document structures.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "template_id": {
+ "type": "string",
+ "description": "ID of the template to use (e.g., 'research-notes', 'requirements', 'architecture')"
+ },
+ "name": {
+ "type": "string",
+ "description": "Name for the new file"
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description for the file"
+ }
+ },
+ "required": ["template_id", "name"]
+ }),
+ },
+ Tool {
+ name: "create_empty_file".to_string(),
+ description: "Create a new empty file in the contract without using a template.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name for the new file"
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description for the file"
+ }
+ },
+ "required": ["name"]
+ }),
+ },
+ Tool {
+ name: "list_available_templates".to_string(),
+ description: "List all available templates, optionally filtered by phase. Use this to see what templates can be used with create_file_from_template.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "phase": {
+ "type": "string",
+ "enum": ["research", "specify", "plan", "execute", "review"],
+ "description": "Optional filter to show only templates for a specific phase"
+ }
+ }
+ }),
+ },
+ // =============================================================================
+ // Task Management Tools
+ // =============================================================================
+ Tool {
+ name: "create_contract_task".to_string(),
+ description: "Create a new task within this contract. The task will be associated with the contract and can optionally use a contract repository.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the task"
+ },
+ "plan": {
+ "type": "string",
+ "description": "Detailed instructions/plan for what the task should accomplish"
+ },
+ "repository_url": {
+ "type": "string",
+ "description": "Git repository URL or local path. If not specified, uses the contract's primary repository."
+ },
+ "base_branch": {
+ "type": "string",
+ "description": "Optional base branch to start from (default: main)"
+ }
+ },
+ "required": ["name", "plan"]
+ }),
+ },
+ Tool {
+ name: "delegate_content_generation".to_string(),
+ description: "Create a task to generate substantial content instead of writing it directly. Use this for filling templates, writing documentation, generating user stories, or any substantial writing task. The task will be created and can be started separately.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "file_id": {
+ "type": "string",
+ "description": "ID of the file to update with generated content (optional - if not specified, creates a new task without file context)"
+ },
+ "instruction": {
+ "type": "string",
+ "description": "Clear instructions for what content should be generated"
+ },
+ "context": {
+ "type": "string",
+ "description": "Additional context to help generate appropriate content"
+ }
+ },
+ "required": ["instruction"]
+ }),
+ },
+ Tool {
+ name: "start_task".to_string(),
+ description: "Start a task that is in 'pending' status. The task will be sent to a connected daemon for execution. A daemon must be connected for this to work.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to start"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ // =============================================================================
+ // Phase Management Tools
+ // =============================================================================
+ Tool {
+ name: "get_phase_info".to_string(),
+ description: "Get detailed information about the current phase and what it means for the contract workflow.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "suggest_phase_transition".to_string(),
+ description: "Analyze whether the contract is ready to advance to the NEXT phase. Returns: currentPhase, nextPhase (the phase to advance TO), readiness status, and what's missing. Use this BEFORE calling advance_phase to know exactly which phase to advance to.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "advance_phase".to_string(),
+ description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "new_phase": {
+ "type": "string",
+ "enum": ["specify", "plan", "execute", "review"],
+ "description": "The next phase to transition to. Must be exactly one step ahead of current phase (e.g., research->specify, specify->plan, plan->execute, execute->review)"
+ }
+ },
+ "required": ["new_phase"]
+ }),
+ },
+ // =============================================================================
+ // Repository Management Tools
+ // =============================================================================
+ Tool {
+ name: "list_daemon_directories".to_string(),
+ description: "List suggested directories from connected daemons. Use this to find valid local paths when the user wants to add a local repository or configure a target path. Returns working directories and home directories from connected agents.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "add_repository".to_string(),
+ description: "Add a repository to the contract. Can be a remote URL, local path, or create a managed repository.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["remote", "local", "managed"],
+ "description": "Type of repository to add"
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name for the repository"
+ },
+ "url": {
+ "type": "string",
+ "description": "Repository URL (for remote type) or local path (for local type). Not needed for managed."
+ },
+ "is_primary": {
+ "type": "boolean",
+ "description": "Whether this should be the primary repository for the contract"
+ }
+ },
+ "required": ["type", "name"]
+ }),
+ },
+ Tool {
+ name: "set_primary_repository".to_string(),
+ description: "Set a repository as the primary repository for this contract. The primary repo is used by default for new tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "repository_id": {
+ "type": "string",
+ "description": "ID of the repository to set as primary"
+ }
+ },
+ "required": ["repository_id"]
+ }),
+ },
+ // =============================================================================
+ // Phase Guidance Tools
+ // =============================================================================
+ Tool {
+ name: "get_phase_checklist".to_string(),
+ description: "Get a detailed checklist of phase deliverables showing what's been created vs what's expected. Includes completion percentage and suggestions for next steps.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ // =============================================================================
+ // Task Derivation Tools
+ // =============================================================================
+ Tool {
+ name: "derive_tasks_from_file".to_string(),
+ description: "Parse a file (typically Task Breakdown) to extract a list of tasks. Returns structured task data that can be used with create_chained_tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "file_id": {
+ "type": "string",
+ "description": "ID of the file to parse tasks from (usually a Task Breakdown document)"
+ }
+ },
+ "required": ["file_id"]
+ }),
+ },
+ Tool {
+ name: "create_chained_tasks".to_string(),
+ description: "Create multiple tasks in sequence with automatic chaining. Each task will continue from the previous task's work using continue_from_task_id.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "tasks": {
+ "type": "array",
+ "description": "List of tasks to create in order",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Task name"
+ },
+ "plan": {
+ "type": "string",
+ "description": "Task plan/instructions"
+ }
+ },
+ "required": ["name", "plan"]
+ }
+ }
+ },
+ "required": ["tasks"]
+ }),
+ },
+ // =============================================================================
+ // Task Completion Processing Tools
+ // =============================================================================
+ Tool {
+ name: "process_task_completion".to_string(),
+ description: "Analyze a completed task's output and suggest next actions. Returns summary, affected files, and suggestions for follow-up tasks or file updates.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the completed task to analyze"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "update_file_from_task".to_string(),
+ description: "Update a contract file with information from a completed task. Useful for updating Dev Notes or Implementation Log with task summaries.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "file_id": {
+ "type": "string",
+ "description": "ID of the file to update"
+ },
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task whose output should be added"
+ },
+ "section_title": {
+ "type": "string",
+ "description": "Optional title for the section being added (e.g., 'Task: Implement Authentication')"
+ }
+ },
+ "required": ["file_id", "task_id"]
+ }),
+ },
+ // =============================================================================
+ // Interactive Tools
+ // =============================================================================
+ Tool {
+ name: "ask_user".to_string(),
+ description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "questions": {
+ "type": "array",
+ "description": "List of questions to ask the user",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for this question"
+ },
+ "question": {
+ "type": "string",
+ "description": "The question to ask the user"
+ },
+ "options": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Multiple choice options for the user to select from"
+ },
+ "allowMultiple": {
+ "type": "boolean",
+ "description": "If true, user can select multiple options"
+ },
+ "allowCustom": {
+ "type": "boolean",
+ "description": "If true, user can provide a custom answer"
+ }
+ },
+ "required": ["id", "question", "options"]
+ }
+ }
+ },
+ "required": ["questions"]
+ }),
+ },
+ ]
+});
+
+/// Request for contract tool operations that require async database access
+#[derive(Debug, Clone)]
+pub enum ContractToolRequest {
+ // Query operations
+ GetContractStatus,
+ ListContractFiles,
+ ListContractTasks,
+ ListContractRepositories,
+ ReadFile { file_id: Uuid },
+
+ // File management
+ CreateFileFromTemplate {
+ template_id: String,
+ name: String,
+ description: Option<String>,
+ },
+ CreateEmptyFile {
+ name: String,
+ description: Option<String>,
+ },
+ ListAvailableTemplates { phase: Option<String> },
+
+ // Task management
+ CreateContractTask {
+ name: String,
+ plan: String,
+ repository_url: Option<String>,
+ base_branch: Option<String>,
+ },
+ DelegateContentGeneration {
+ file_id: Option<Uuid>,
+ instruction: String,
+ context: Option<String>,
+ },
+ StartTask { task_id: Uuid },
+
+ // Phase management
+ GetPhaseInfo,
+ SuggestPhaseTransition,
+ AdvancePhase { new_phase: String },
+
+ // Repository management
+ ListDaemonDirectories,
+ AddRepository {
+ repo_type: String,
+ name: String,
+ url: Option<String>,
+ is_primary: bool,
+ },
+ SetPrimaryRepository { repository_id: Uuid },
+
+ // Phase guidance
+ GetPhaseChecklist,
+
+ // Task derivation
+ DeriveTasksFromFile { file_id: Uuid },
+ CreateChainedTasks { tasks: Vec<ChainedTaskDef> },
+
+ // Task completion processing
+ ProcessTaskCompletion { task_id: Uuid },
+ UpdateFileFromTask {
+ file_id: Uuid,
+ task_id: Uuid,
+ section_title: Option<String>,
+ },
+}
+
+/// Task definition for chained task creation
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct ChainedTaskDef {
+ pub name: String,
+ pub plan: String,
+}
+
+/// Result from executing a contract tool
+#[derive(Debug)]
+pub struct ContractToolExecutionResult {
+ pub success: bool,
+ pub message: String,
+ pub data: Option<serde_json::Value>,
+ /// Request for async operations (handled by contract_chat handler)
+ pub request: Option<ContractToolRequest>,
+ /// Questions to ask the user (pauses conversation)
+ pub pending_questions: Option<Vec<super::tools::UserQuestion>>,
+}
+
+/// Parse and validate a contract tool call, returning a ContractToolRequest for async handling
+pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ match call.name.as_str() {
+ // Query operations
+ "get_contract_status" => parse_get_contract_status(),
+ "list_contract_files" => parse_list_contract_files(),
+ "list_contract_tasks" => parse_list_contract_tasks(),
+ "list_contract_repositories" => parse_list_contract_repositories(),
+ "read_file" => parse_read_file(call),
+
+ // File management
+ "create_file_from_template" => parse_create_file_from_template(call),
+ "create_empty_file" => parse_create_empty_file(call),
+ "list_available_templates" => parse_list_available_templates(call),
+
+ // Task management
+ "create_contract_task" => parse_create_contract_task(call),
+ "delegate_content_generation" => parse_delegate_content_generation(call),
+ "start_task" => parse_start_task(call),
+
+ // Phase management
+ "get_phase_info" => parse_get_phase_info(),
+ "suggest_phase_transition" => parse_suggest_phase_transition(),
+ "advance_phase" => parse_advance_phase(call),
+
+ // Repository management
+ "list_daemon_directories" => parse_list_daemon_directories(),
+ "add_repository" => parse_add_repository(call),
+ "set_primary_repository" => parse_set_primary_repository(call),
+
+ // Phase guidance
+ "get_phase_checklist" => parse_get_phase_checklist(),
+
+ // Task derivation
+ "derive_tasks_from_file" => parse_derive_tasks_from_file(call),
+ "create_chained_tasks" => parse_create_chained_tasks(call),
+
+ // Task completion processing
+ "process_task_completion" => parse_process_task_completion(call),
+ "update_file_from_task" => parse_update_file_from_task(call),
+
+ // Interactive tools
+ "ask_user" => parse_ask_user(call),
+
+ _ => ContractToolExecutionResult {
+ success: false,
+ message: format!("Unknown contract tool: {}", call.name),
+ data: None,
+ request: None,
+ pending_questions: None,
+ },
+ }
+}
+
+// =============================================================================
+// Query Tool Parsing
+// =============================================================================
+
+fn parse_get_contract_status() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Getting contract status...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::GetContractStatus),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_contract_files() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Listing contract files...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ListContractFiles),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_contract_tasks() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Listing contract tasks...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ListContractTasks),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_contract_repositories() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Listing contract repositories...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ListContractRepositories),
+ pending_questions: None,
+ }
+}
+
+fn parse_read_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let file_id = parse_uuid_arg(call, "file_id");
+ let Some(file_id) = file_id else {
+ return error_result("Missing or invalid required parameter: file_id");
+ };
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Reading file...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ReadFile { file_id }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// File Management Tool Parsing
+// =============================================================================
+
+fn parse_create_file_from_template(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let template_id = call.arguments.get("template_id").and_then(|v| v.as_str());
+ let name = call.arguments.get("name").and_then(|v| v.as_str());
+
+ let Some(template_id) = template_id else {
+ return error_result("Missing required parameter: template_id");
+ };
+ let Some(name) = name else {
+ return error_result("Missing required parameter: name");
+ };
+
+ let description = call
+ .arguments
+ .get("description")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Creating file '{}' from template '{}'...", name, template_id),
+ data: None,
+ request: Some(ContractToolRequest::CreateFileFromTemplate {
+ template_id: template_id.to_string(),
+ name: name.to_string(),
+ description,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_create_empty_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let name = call.arguments.get("name").and_then(|v| v.as_str());
+
+ let Some(name) = name else {
+ return error_result("Missing required parameter: name");
+ };
+
+ let description = call
+ .arguments
+ .get("description")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Creating empty file '{}'...", name),
+ data: None,
+ request: Some(ContractToolRequest::CreateEmptyFile {
+ name: name.to_string(),
+ description,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_available_templates(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let phase = call
+ .arguments
+ .get("phase")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Listing available templates...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ListAvailableTemplates { phase }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Task Management Tool Parsing
+// =============================================================================
+
+fn parse_create_contract_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let name = call.arguments.get("name").and_then(|v| v.as_str());
+ let plan = call.arguments.get("plan").and_then(|v| v.as_str());
+
+ let Some(name) = name else {
+ return error_result("Missing required parameter: name");
+ };
+ let Some(plan) = plan else {
+ return error_result("Missing required parameter: plan");
+ };
+
+ let repository_url = call
+ .arguments
+ .get("repository_url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let base_branch = call
+ .arguments
+ .get("base_branch")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Creating task '{}'...", name),
+ data: None,
+ request: Some(ContractToolRequest::CreateContractTask {
+ name: name.to_string(),
+ plan: plan.to_string(),
+ repository_url,
+ base_branch,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_delegate_content_generation(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let instruction = call.arguments.get("instruction").and_then(|v| v.as_str());
+
+ let Some(instruction) = instruction else {
+ return error_result("Missing required parameter: instruction");
+ };
+
+ let file_id = parse_uuid_arg(call, "file_id");
+ let context = call
+ .arguments
+ .get("context")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Creating content generation task...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::DelegateContentGeneration {
+ file_id,
+ instruction: instruction.to_string(),
+ context,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_start_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Starting task...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::StartTask { task_id }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Phase Management Tool Parsing
+// =============================================================================
+
+fn parse_get_phase_info() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Getting phase information...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::GetPhaseInfo),
+ pending_questions: None,
+ }
+}
+
+fn parse_suggest_phase_transition() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Analyzing phase transition readiness...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::SuggestPhaseTransition),
+ pending_questions: None,
+ }
+}
+
+fn parse_advance_phase(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let new_phase = call.arguments.get("new_phase").and_then(|v| v.as_str());
+
+ let Some(new_phase) = new_phase else {
+ return error_result("Missing required parameter: new_phase");
+ };
+
+ let valid_phases = ["research", "specify", "plan", "execute", "review"];
+ if !valid_phases.contains(&new_phase) {
+ return error_result("Invalid phase. Must be one of: research, specify, plan, execute, review");
+ }
+
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Advancing to '{}' phase...", new_phase),
+ data: None,
+ request: Some(ContractToolRequest::AdvancePhase {
+ new_phase: new_phase.to_string(),
+ }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Repository Management Tool Parsing
+// =============================================================================
+
+fn parse_list_daemon_directories() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Listing daemon directories...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ListDaemonDirectories),
+ pending_questions: None,
+ }
+}
+
+fn parse_add_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let repo_type = call.arguments.get("type").and_then(|v| v.as_str());
+ let name = call.arguments.get("name").and_then(|v| v.as_str());
+
+ let Some(repo_type) = repo_type else {
+ return error_result("Missing required parameter: type");
+ };
+ let Some(name) = name else {
+ return error_result("Missing required parameter: name");
+ };
+
+ let valid_types = ["remote", "local", "managed"];
+ if !valid_types.contains(&repo_type) {
+ return error_result("Invalid type. Must be one of: remote, local, managed");
+ }
+
+ let url = call
+ .arguments
+ .get("url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ // Validate URL is provided for remote and local types
+ if (repo_type == "remote" || repo_type == "local") && url.is_none() {
+ return error_result("URL/path is required for remote and local repository types");
+ }
+
+ let is_primary = call
+ .arguments
+ .get("is_primary")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Adding {} repository '{}'...", repo_type, name),
+ data: None,
+ request: Some(ContractToolRequest::AddRepository {
+ repo_type: repo_type.to_string(),
+ name: name.to_string(),
+ url,
+ is_primary,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_set_primary_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let repository_id = parse_uuid_arg(call, "repository_id");
+ let Some(repository_id) = repository_id else {
+ return error_result("Missing or invalid required parameter: repository_id");
+ };
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Setting primary repository...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::SetPrimaryRepository { repository_id }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Interactive Tool Parsing
+// =============================================================================
+
+fn parse_ask_user(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let questions_value = call.arguments.get("questions");
+
+ let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else {
+ return error_result("Missing or invalid 'questions' parameter");
+ };
+
+ let mut questions: Vec<super::tools::UserQuestion> = Vec::new();
+
+ for q in questions_array {
+ let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let options: Vec<String> = q
+ .get("options")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|o| o.as_str())
+ .map(|s| s.to_string())
+ .collect()
+ })
+ .unwrap_or_default();
+ let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false);
+ let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true);
+
+ if id.is_empty() || question.is_empty() || options.is_empty() {
+ continue;
+ }
+
+ questions.push(super::tools::UserQuestion {
+ id,
+ question,
+ options,
+ allow_multiple,
+ allow_custom,
+ });
+ }
+
+ if questions.is_empty() {
+ return error_result("No valid questions provided");
+ }
+
+ let question_count = questions.len();
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Asking user {} question(s). Waiting for response...", question_count),
+ data: None,
+ request: None,
+ pending_questions: Some(questions),
+ }
+}
+
+// =============================================================================
+// Phase Guidance Tool Parsing
+// =============================================================================
+
+fn parse_get_phase_checklist() -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: true,
+ message: "Getting phase checklist...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::GetPhaseChecklist),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Task Derivation Tool Parsing
+// =============================================================================
+
+fn parse_derive_tasks_from_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let file_id = parse_uuid_arg(call, "file_id");
+ let Some(file_id) = file_id else {
+ return error_result("Missing or invalid required parameter: file_id");
+ };
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Deriving tasks from file...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::DeriveTasksFromFile { file_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_create_chained_tasks(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let tasks_value = call.arguments.get("tasks");
+
+ let Some(tasks_array) = tasks_value.and_then(|v| v.as_array()) else {
+ return error_result("Missing or invalid 'tasks' parameter");
+ };
+
+ let mut tasks: Vec<ChainedTaskDef> = Vec::new();
+
+ for task in tasks_array {
+ let name = task.get("name").and_then(|v| v.as_str());
+ let plan = task.get("plan").and_then(|v| v.as_str());
+
+ match (name, plan) {
+ (Some(n), Some(p)) => {
+ tasks.push(ChainedTaskDef {
+ name: n.to_string(),
+ plan: p.to_string(),
+ });
+ }
+ _ => {
+ return error_result("Each task must have 'name' and 'plan' fields");
+ }
+ }
+ }
+
+ if tasks.is_empty() {
+ return error_result("No valid tasks provided");
+ }
+
+ let task_count = tasks.len();
+ ContractToolExecutionResult {
+ success: true,
+ message: format!("Creating {} chained task(s)...", task_count),
+ data: None,
+ request: Some(ContractToolRequest::CreateChainedTasks { tasks }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Task Completion Processing Tool Parsing
+// =============================================================================
+
+fn parse_process_task_completion(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Processing task completion...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::ProcessTaskCompletion { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_update_file_from_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult {
+ let file_id = parse_uuid_arg(call, "file_id");
+ let task_id = parse_uuid_arg(call, "task_id");
+
+ let Some(file_id) = file_id else {
+ return error_result("Missing or invalid required parameter: file_id");
+ };
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let section_title = call
+ .arguments
+ .get("section_title")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ ContractToolExecutionResult {
+ success: true,
+ message: "Updating file from task...".to_string(),
+ data: None,
+ request: Some(ContractToolRequest::UpdateFileFromTask {
+ file_id,
+ task_id,
+ section_title,
+ }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+fn parse_uuid_arg(call: &super::tools::ToolCall, key: &str) -> Option<Uuid> {
+ call.arguments
+ .get(key)
+ .and_then(|v| v.as_str())
+ .and_then(|s| Uuid::parse_str(s).ok())
+}
+
+fn error_result(message: &str) -> ContractToolExecutionResult {
+ ContractToolExecutionResult {
+ success: false,
+ message: message.to_string(),
+ data: None,
+ request: None,
+ pending_questions: None,
+ }
+}
diff --git a/makima/src/llm/markdown.rs b/makima/src/llm/markdown.rs
new file mode 100644
index 0000000..482dc8c
--- /dev/null
+++ b/makima/src/llm/markdown.rs
@@ -0,0 +1,334 @@
+//! Markdown conversion utilities for BodyElement arrays.
+//!
+//! Provides bidirectional conversion between structured BodyElement[] and markdown strings.
+
+use crate::db::models::BodyElement;
+
+/// Convert a slice of BodyElements to a markdown string.
+///
+/// Handles:
+/// - Headings: `# heading` through `###### heading` based on level
+/// - Paragraphs: plain text with blank lines between
+/// - Code blocks: ````language\ncontent\n````
+/// - Lists: ordered (1. 2. 3.) and unordered (- - -)
+/// - Charts: rendered as fenced JSON with chart type
+/// - Images: rendered as markdown image syntax
+pub fn body_to_markdown(elements: &[BodyElement]) -> String {
+ elements
+ .iter()
+ .filter_map(|elem| match elem {
+ BodyElement::Heading { level, text } => {
+ let hashes = "#".repeat((*level).min(6) as usize);
+ Some(format!("{} {}", hashes, text))
+ }
+ BodyElement::Paragraph { text } => Some(text.clone()),
+ BodyElement::Code { language, content } => {
+ let lang = language.as_deref().unwrap_or("");
+ Some(format!("```{}\n{}\n```", lang, content))
+ }
+ BodyElement::List { ordered, items } => {
+ let list: Vec<String> = items
+ .iter()
+ .enumerate()
+ .map(|(i, item)| {
+ if *ordered {
+ format!("{}. {}", i + 1, item)
+ } else {
+ format!("- {}", item)
+ }
+ })
+ .collect();
+ Some(list.join("\n"))
+ }
+ BodyElement::Chart {
+ chart_type,
+ title,
+ data,
+ config: _,
+ } => {
+ // Render chart as a fenced block with metadata
+ let title_str = title
+ .as_ref()
+ .map(|t| format!(" - {}", t))
+ .unwrap_or_default();
+ let data_str = serde_json::to_string_pretty(data).unwrap_or_default();
+ Some(format!(
+ "```chart:{:?}{}\n{}\n```",
+ chart_type, title_str, data_str
+ ))
+ }
+ BodyElement::Image { src, alt, caption } => {
+ let alt_text = alt.as_deref().unwrap_or("image");
+ let caption_str = caption
+ .as_ref()
+ .map(|c| format!("\n*{}*", c))
+ .unwrap_or_default();
+ Some(format!("![{}]({}){}", alt_text, src, caption_str))
+ }
+ // Markdown elements output their content directly - it's already markdown
+ BodyElement::Markdown { content } => Some(content.clone()),
+ })
+ .collect::<Vec<_>>()
+ .join("\n\n")
+}
+
+/// Parse a markdown string into a vector of BodyElements.
+///
+/// Handles:
+/// - Headings: lines starting with # through ######
+/// - Code blocks: ````language ... ````
+/// - Ordered lists: lines starting with 1. 2. etc.
+/// - Unordered lists: lines starting with - or *
+/// - Paragraphs: all other non-empty lines
+pub fn markdown_to_body(markdown: &str) -> Vec<BodyElement> {
+ let mut elements = Vec::new();
+ let lines: Vec<&str> = markdown.lines().collect();
+ let mut i = 0;
+
+ while i < lines.len() {
+ let line = lines[i];
+ let trimmed = line.trim();
+
+ // Skip empty lines
+ if trimmed.is_empty() {
+ i += 1;
+ continue;
+ }
+
+ // Check for code blocks
+ if trimmed.starts_with("```") {
+ let language = trimmed.trim_start_matches('`').trim();
+ let language = if language.is_empty() {
+ None
+ } else {
+ Some(language.to_string())
+ };
+
+ let mut content_lines = Vec::new();
+ i += 1;
+
+ // Collect content until closing ```
+ while i < lines.len() && !lines[i].trim().starts_with("```") {
+ content_lines.push(lines[i]);
+ i += 1;
+ }
+
+ // Skip the closing ```
+ if i < lines.len() {
+ i += 1;
+ }
+
+ elements.push(BodyElement::Code {
+ language,
+ content: content_lines.join("\n"),
+ });
+ continue;
+ }
+
+ // Check for headings
+ if trimmed.starts_with('#') {
+ let level = trimmed.chars().take_while(|&c| c == '#').count() as u8;
+ let text = trimmed.trim_start_matches('#').trim().to_string();
+ elements.push(BodyElement::Heading { level, text });
+ i += 1;
+ continue;
+ }
+
+ // Check for unordered lists (- or *)
+ if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
+ let mut items = Vec::new();
+ while i < lines.len() {
+ let current = lines[i].trim();
+ if current.starts_with("- ") || current.starts_with("* ") {
+ items.push(current[2..].to_string());
+ i += 1;
+ } else if current.is_empty() {
+ i += 1;
+ break;
+ } else {
+ break;
+ }
+ }
+ elements.push(BodyElement::List {
+ ordered: false,
+ items,
+ });
+ continue;
+ }
+
+ // Check for ordered lists (1. 2. etc.)
+ if let Some(rest) = try_parse_ordered_list_item(trimmed) {
+ let mut items = Vec::new();
+ items.push(rest.to_string());
+ i += 1;
+
+ while i < lines.len() {
+ let current = lines[i].trim();
+ if let Some(item_rest) = try_parse_ordered_list_item(current) {
+ items.push(item_rest.to_string());
+ i += 1;
+ } else if current.is_empty() {
+ i += 1;
+ break;
+ } else {
+ break;
+ }
+ }
+ elements.push(BodyElement::List {
+ ordered: true,
+ items,
+ });
+ continue;
+ }
+
+ // Default: paragraph (collect consecutive non-empty lines)
+ let mut para_lines = Vec::new();
+ while i < lines.len() {
+ let current = lines[i].trim();
+ if current.is_empty()
+ || current.starts_with('#')
+ || current.starts_with("```")
+ || current.starts_with("- ")
+ || current.starts_with("* ")
+ || try_parse_ordered_list_item(current).is_some()
+ {
+ break;
+ }
+ para_lines.push(current);
+ i += 1;
+ }
+
+ if !para_lines.is_empty() {
+ elements.push(BodyElement::Paragraph {
+ text: para_lines.join(" "),
+ });
+ }
+ }
+
+ elements
+}
+
+/// Try to parse an ordered list item (e.g., "1. Item text")
+/// Returns the text after the number and period, or None if not a list item.
+fn try_parse_ordered_list_item(s: &str) -> Option<&str> {
+ let mut chars = s.char_indices();
+
+ // Must start with a digit
+ let (_, first) = chars.next()?;
+ if !first.is_ascii_digit() {
+ return None;
+ }
+
+ // Consume remaining digits
+ let mut last_digit_end = 1;
+ for (idx, c) in chars.by_ref() {
+ if c.is_ascii_digit() {
+ last_digit_end = idx + 1;
+ } else if c == '.' {
+ // Found the period - check for space after
+ let rest = &s[last_digit_end + 1..];
+ let rest = rest.trim_start();
+ if !rest.is_empty() || s.ends_with(". ") {
+ return Some(rest);
+ }
+ return None;
+ } else {
+ return None;
+ }
+ }
+
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_body_to_markdown_heading() {
+ let elements = vec![BodyElement::Heading {
+ level: 2,
+ text: "Hello World".to_string(),
+ }];
+ assert_eq!(body_to_markdown(&elements), "## Hello World");
+ }
+
+ #[test]
+ fn test_body_to_markdown_paragraph() {
+ let elements = vec![BodyElement::Paragraph {
+ text: "This is a paragraph.".to_string(),
+ }];
+ assert_eq!(body_to_markdown(&elements), "This is a paragraph.");
+ }
+
+ #[test]
+ fn test_body_to_markdown_code() {
+ let elements = vec![BodyElement::Code {
+ language: Some("rust".to_string()),
+ content: "fn main() {}".to_string(),
+ }];
+ assert_eq!(
+ body_to_markdown(&elements),
+ "```rust\nfn main() {}\n```"
+ );
+ }
+
+ #[test]
+ fn test_body_to_markdown_list() {
+ let elements = vec![BodyElement::List {
+ ordered: false,
+ items: vec!["Item 1".to_string(), "Item 2".to_string()],
+ }];
+ assert_eq!(body_to_markdown(&elements), "- Item 1\n- Item 2");
+ }
+
+ #[test]
+ fn test_markdown_to_body_heading() {
+ let md = "## Hello World";
+ let elements = markdown_to_body(md);
+ assert_eq!(elements.len(), 1);
+ match &elements[0] {
+ BodyElement::Heading { level, text } => {
+ assert_eq!(*level, 2);
+ assert_eq!(text, "Hello World");
+ }
+ _ => panic!("Expected Heading"),
+ }
+ }
+
+ #[test]
+ fn test_markdown_to_body_code() {
+ let md = "```rust\nfn main() {}\n```";
+ let elements = markdown_to_body(md);
+ assert_eq!(elements.len(), 1);
+ match &elements[0] {
+ BodyElement::Code { language, content } => {
+ assert_eq!(language.as_deref(), Some("rust"));
+ assert_eq!(content, "fn main() {}");
+ }
+ _ => panic!("Expected Code"),
+ }
+ }
+
+ #[test]
+ fn test_roundtrip() {
+ let original = vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Title".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Some text here.".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["A".to_string(), "B".to_string()],
+ },
+ ];
+
+ let markdown = body_to_markdown(&original);
+ let parsed = markdown_to_body(&markdown);
+
+ assert_eq!(parsed.len(), 3);
+ }
+}
diff --git a/makima/src/llm/mesh_tools.rs b/makima/src/llm/mesh_tools.rs
index 1d12c66..ec9dd01 100644
--- a/makima/src/llm/mesh_tools.rs
+++ b/makima/src/llm/mesh_tools.rs
@@ -418,6 +418,140 @@ pub static MESH_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy:
"required": ["questions"]
}),
},
+ // =============================================================================
+ // Supervisor Tools (only available to supervisor tasks)
+ // =============================================================================
+ Tool {
+ name: "get_all_contract_tasks".to_string(),
+ description: "Get status of all tasks in the contract tree. Only available to supervisor tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "contract_id": {
+ "type": "string",
+ "description": "ID of the contract to query tasks for"
+ }
+ },
+ "required": ["contract_id"]
+ }),
+ },
+ Tool {
+ name: "wait_for_task_completion".to_string(),
+ description: "Block until a task completes or timeout. Only available to supervisor tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to wait for"
+ },
+ "timeout_seconds": {
+ "type": "integer",
+ "description": "Maximum time to wait in seconds (default: 300)",
+ "default": 300
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "read_task_worktree".to_string(),
+ description: "Read a file from any task's worktree. Only available to supervisor tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task whose worktree to read from"
+ },
+ "file_path": {
+ "type": "string",
+ "description": "Path to the file within the worktree"
+ }
+ },
+ "required": ["task_id", "file_path"]
+ }),
+ },
+ Tool {
+ name: "spawn_task".to_string(),
+ description: "Create and start a child task (fire and forget). Only available to supervisor tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the task"
+ },
+ "plan": {
+ "type": "string",
+ "description": "Detailed instructions/plan for what the task should accomplish"
+ },
+ "parent_task_id": {
+ "type": "string",
+ "description": "Optional parent task to branch from"
+ },
+ "checkpoint_sha": {
+ "type": "string",
+ "description": "Optional checkpoint SHA to branch from"
+ },
+ "repository_url": {
+ "type": "string",
+ "description": "Git repository URL (optional - inherits from contract if not provided)"
+ },
+ "base_branch": {
+ "type": "string",
+ "description": "Optional base branch to start from"
+ }
+ },
+ "required": ["name", "plan"]
+ }),
+ },
+ Tool {
+ name: "create_checkpoint".to_string(),
+ description: "Create a git checkpoint (commit) in the current task's worktree. Only available to supervisor tasks.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to checkpoint"
+ },
+ "message": {
+ "type": "string",
+ "description": "Commit message for the checkpoint"
+ }
+ },
+ "required": ["task_id", "message"]
+ }),
+ },
+ Tool {
+ name: "list_task_checkpoints".to_string(),
+ description: "List all checkpoints for a task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to list checkpoints for"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "get_task_tree".to_string(),
+ description: "Get the full task tree starting from a specific task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the root task"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
]
});
@@ -506,6 +640,37 @@ pub enum MeshToolRequest {
task_id: Uuid,
mode: String,
},
+
+ // Supervisor tools (only for supervisor tasks)
+ GetAllContractTasks {
+ contract_id: Uuid,
+ },
+ WaitForTaskCompletion {
+ task_id: Uuid,
+ timeout_seconds: i32,
+ },
+ ReadTaskWorktree {
+ task_id: Uuid,
+ file_path: String,
+ },
+ SpawnTask {
+ name: String,
+ plan: String,
+ parent_task_id: Option<Uuid>,
+ checkpoint_sha: Option<String>,
+ repository_url: Option<String>,
+ base_branch: Option<String>,
+ },
+ CreateCheckpoint {
+ task_id: Uuid,
+ message: String,
+ },
+ ListTaskCheckpoints {
+ task_id: Uuid,
+ },
+ GetTaskTree {
+ task_id: Uuid,
+ },
}
/// Result from executing a mesh tool
@@ -560,6 +725,15 @@ pub fn parse_mesh_tool_call(
// Interactive tools
"ask_user" => parse_ask_user(call),
+ // Supervisor tools
+ "get_all_contract_tasks" => parse_get_all_contract_tasks(call),
+ "wait_for_task_completion" => parse_wait_for_task_completion(call),
+ "read_task_worktree" => parse_read_task_worktree(call),
+ "spawn_task" => parse_spawn_task(call),
+ "create_checkpoint" => parse_create_checkpoint(call),
+ "list_task_checkpoints" => parse_list_task_checkpoints(call),
+ "get_task_tree" => parse_get_task_tree(call),
+
_ => MeshToolExecutionResult {
success: false,
message: format!("Unknown mesh tool: {}", call.name),
@@ -1059,6 +1233,163 @@ fn parse_ask_user(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
}
// =============================================================================
+// Supervisor Tool Parsing Functions
+// =============================================================================
+
+fn parse_get_all_contract_tasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(contract_id) = parse_uuid_arg(call, "contract_id") else {
+ return error_result("Missing or invalid contract_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Querying all contract tasks...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::GetAllContractTasks { contract_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_wait_for_task_completion(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(task_id) = parse_uuid_arg(call, "task_id") else {
+ return error_result("Missing or invalid task_id");
+ };
+
+ let timeout_seconds = call
+ .arguments
+ .get("timeout_seconds")
+ .and_then(|v| v.as_i64())
+ .map(|v| v as i32)
+ .unwrap_or(300);
+
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Waiting for task completion (timeout: {}s)...", timeout_seconds),
+ data: None,
+ request: Some(MeshToolRequest::WaitForTaskCompletion {
+ task_id,
+ timeout_seconds,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_read_task_worktree(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(task_id) = parse_uuid_arg(call, "task_id") else {
+ return error_result("Missing or invalid task_id");
+ };
+
+ let Some(file_path) = call.arguments.get("file_path").and_then(|v| v.as_str()) else {
+ return error_result("Missing required parameter: file_path");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Reading file from task worktree: {}", file_path),
+ data: None,
+ request: Some(MeshToolRequest::ReadTaskWorktree {
+ task_id,
+ file_path: file_path.to_string(),
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_spawn_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(name) = call.arguments.get("name").and_then(|v| v.as_str()) else {
+ return error_result("Missing required parameter: name");
+ };
+
+ let Some(plan) = call.arguments.get("plan").and_then(|v| v.as_str()) else {
+ return error_result("Missing required parameter: plan");
+ };
+
+ let parent_task_id = parse_uuid_arg(call, "parent_task_id");
+
+ let checkpoint_sha = call
+ .arguments
+ .get("checkpoint_sha")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let repository_url = call
+ .arguments
+ .get("repository_url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let base_branch = call
+ .arguments
+ .get("base_branch")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Spawning task: {}", name),
+ data: None,
+ request: Some(MeshToolRequest::SpawnTask {
+ name: name.to_string(),
+ plan: plan.to_string(),
+ parent_task_id,
+ checkpoint_sha,
+ repository_url,
+ base_branch,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_create_checkpoint(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(task_id) = parse_uuid_arg(call, "task_id") else {
+ return error_result("Missing or invalid task_id");
+ };
+
+ let Some(message) = call.arguments.get("message").and_then(|v| v.as_str()) else {
+ return error_result("Missing required parameter: message");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Creating checkpoint: {}", message),
+ data: None,
+ request: Some(MeshToolRequest::CreateCheckpoint {
+ task_id,
+ message: message.to_string(),
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_task_checkpoints(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(task_id) = parse_uuid_arg(call, "task_id") else {
+ return error_result("Missing or invalid task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing task checkpoints...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListTaskCheckpoints { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_get_task_tree(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let Some(task_id) = parse_uuid_arg(call, "task_id") else {
+ return error_result("Missing or invalid task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Getting task tree...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::GetTaskTree { task_id }),
+ pending_questions: None,
+ }
+}
+
+// =============================================================================
// Helper Functions
// =============================================================================
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs
index 39cdbdd..da8c0a4 100644
--- a/makima/src/llm/mod.rs
+++ b/makima/src/llm/mod.rs
@@ -1,13 +1,33 @@
//! LLM integration module for file editing via tool calling.
pub mod claude;
+pub mod contract_tools;
pub mod groq;
+pub mod markdown;
pub mod mesh_tools;
+pub mod phase_guidance;
+pub mod task_output;
+pub mod templates;
pub mod tools;
pub use claude::{ClaudeClient, ClaudeModel};
+pub use contract_tools::{
+ parse_contract_tool_call, ChainedTaskDef, ContractToolExecutionResult, ContractToolRequest,
+ CONTRACT_TOOLS,
+};
pub use groq::GroqClient;
pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS};
+pub use phase_guidance::{
+ check_phase_completion, format_checklist_markdown, get_phase_checklist, get_phase_deliverables,
+ DeliverableStatus, FileInfo, FilePriority, PhaseChecklist, PhaseDeliverables, RecommendedFile,
+ TaskInfo, TaskStats,
+};
+pub use task_output::{
+ analyze_task_output, format_parsed_tasks, parse_tasks_from_breakdown, ParsedTask,
+ PhaseImpact, SuggestedAction, TaskOutputAnalysis, TaskParseResult,
+};
+pub use markdown::{body_to_markdown, markdown_to_body};
+pub use templates::{all_templates, templates_for_phase, FileTemplate};
pub use tools::{
execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest,
AVAILABLE_TOOLS,
diff --git a/makima/src/llm/phase_guidance.rs b/makima/src/llm/phase_guidance.rs
new file mode 100644
index 0000000..e2d6cd8
--- /dev/null
+++ b/makima/src/llm/phase_guidance.rs
@@ -0,0 +1,594 @@
+//! Phase guidance and deliverables tracking for contract management.
+//!
+//! This module provides structured guidance for each contract phase, tracking
+//! expected deliverables and completion criteria.
+
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+/// Priority level for recommended deliverables
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum FilePriority {
+ /// Must exist before advancing phase
+ Required,
+ /// Strongly suggested for phase completion
+ Recommended,
+ /// Nice to have, not blocking
+ Optional,
+}
+
+/// A recommended file for a phase
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RecommendedFile {
+ /// Template ID to create from
+ pub template_id: String,
+ /// Suggested file name
+ pub name_suggestion: String,
+ /// Priority level
+ pub priority: FilePriority,
+ /// Brief description of purpose
+ pub description: String,
+}
+
+/// Expected deliverables for a phase
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PhaseDeliverables {
+ /// Phase name
+ pub phase: String,
+ /// Recommended files to create
+ pub recommended_files: Vec<RecommendedFile>,
+ /// Whether a repository is required for this phase
+ pub requires_repository: bool,
+ /// Whether tasks should exist in this phase
+ pub requires_tasks: bool,
+ /// Guidance text for this phase
+ pub guidance: String,
+}
+
+/// Status of a deliverable item
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct DeliverableStatus {
+ /// Template ID
+ pub template_id: String,
+ /// Expected name
+ pub name: String,
+ /// Priority
+ pub priority: FilePriority,
+ /// Whether it has been created
+ pub completed: bool,
+ /// File ID if created
+ pub file_id: Option<Uuid>,
+ /// Actual file name if created
+ pub actual_name: Option<String>,
+}
+
+/// Checklist for phase completion
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct PhaseChecklist {
+ /// Current phase
+ pub phase: String,
+ /// File deliverables status
+ pub file_deliverables: Vec<DeliverableStatus>,
+ /// Whether repository is configured
+ pub has_repository: bool,
+ /// Whether repository was required
+ pub repository_required: bool,
+ /// Task statistics (for execute phase)
+ pub task_stats: Option<TaskStats>,
+ /// Overall completion percentage (0-100)
+ pub completion_percentage: u8,
+ /// Summary message
+ pub summary: String,
+ /// Suggestions for next actions
+ pub suggestions: Vec<String>,
+}
+
+/// Task statistics for execute phase
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct TaskStats {
+ pub total: usize,
+ pub pending: usize,
+ pub running: usize,
+ pub done: usize,
+ pub failed: usize,
+}
+
+/// Minimal file info for checklist building
+pub struct FileInfo {
+ pub id: Uuid,
+ pub name: String,
+ pub contract_phase: Option<String>,
+}
+
+/// Minimal task info for checklist building
+pub struct TaskInfo {
+ pub id: Uuid,
+ pub name: String,
+ pub status: String,
+}
+
+/// Get phase deliverables configuration
+pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables {
+ match phase {
+ "research" => PhaseDeliverables {
+ phase: "research".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "research-notes".to_string(),
+ name_suggestion: "Research Notes".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Document findings and insights during research".to_string(),
+ },
+ RecommendedFile {
+ template_id: "competitor-analysis".to_string(),
+ name_suggestion: "Competitor Analysis".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Analyze competitors and market positioning".to_string(),
+ },
+ RecommendedFile {
+ template_id: "user-research".to_string(),
+ name_suggestion: "User Research".to_string(),
+ priority: FilePriority::Optional,
+ description: "Document user interviews and persona insights".to_string(),
+ },
+ ],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Focus on understanding the problem space, gathering information, and documenting findings. Create at least one research document before moving to Specify phase.".to_string(),
+ },
+ "specify" => PhaseDeliverables {
+ phase: "specify".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "requirements".to_string(),
+ name_suggestion: "Requirements Document".to_string(),
+ priority: FilePriority::Required,
+ description: "Define functional and non-functional requirements".to_string(),
+ },
+ RecommendedFile {
+ template_id: "user-stories".to_string(),
+ name_suggestion: "User Stories".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Define features from the user's perspective".to_string(),
+ },
+ RecommendedFile {
+ template_id: "acceptance-criteria".to_string(),
+ name_suggestion: "Acceptance Criteria".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Define testable conditions for completion".to_string(),
+ },
+ ],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Define what needs to be built with clear requirements and acceptance criteria. Ensure specifications are detailed enough for planning.".to_string(),
+ },
+ "plan" => PhaseDeliverables {
+ phase: "plan".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "architecture".to_string(),
+ name_suggestion: "Architecture Document".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Document system architecture and design decisions".to_string(),
+ },
+ RecommendedFile {
+ template_id: "task-breakdown".to_string(),
+ name_suggestion: "Task Breakdown".to_string(),
+ priority: FilePriority::Required,
+ description: "Break down work into implementable tasks".to_string(),
+ },
+ RecommendedFile {
+ template_id: "technical-design".to_string(),
+ name_suggestion: "Technical Design".to_string(),
+ priority: FilePriority::Optional,
+ description: "Detailed technical specification".to_string(),
+ },
+ ],
+ requires_repository: true,
+ requires_tasks: false,
+ guidance: "Design the solution and break down work into tasks. A repository must be configured before moving to Execute phase.".to_string(),
+ },
+ "execute" => PhaseDeliverables {
+ phase: "execute".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "dev-notes".to_string(),
+ name_suggestion: "Development Notes".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Track implementation details and decisions".to_string(),
+ },
+ RecommendedFile {
+ template_id: "test-plan".to_string(),
+ name_suggestion: "Test Plan".to_string(),
+ priority: FilePriority::Optional,
+ description: "Document testing strategy and test cases".to_string(),
+ },
+ RecommendedFile {
+ template_id: "implementation-log".to_string(),
+ name_suggestion: "Implementation Log".to_string(),
+ priority: FilePriority::Optional,
+ description: "Chronological log of implementation progress".to_string(),
+ },
+ ],
+ requires_repository: true,
+ requires_tasks: true,
+ guidance: "Execute the planned tasks, implement features, and track progress. Complete all tasks before moving to Review phase.".to_string(),
+ },
+ "review" => PhaseDeliverables {
+ phase: "review".to_string(),
+ recommended_files: vec![
+ RecommendedFile {
+ template_id: "release-notes".to_string(),
+ name_suggestion: "Release Notes".to_string(),
+ priority: FilePriority::Required,
+ description: "Document changes for release communication".to_string(),
+ },
+ RecommendedFile {
+ template_id: "review-checklist".to_string(),
+ name_suggestion: "Review Checklist".to_string(),
+ priority: FilePriority::Recommended,
+ description: "Comprehensive checklist for code and feature review".to_string(),
+ },
+ RecommendedFile {
+ template_id: "retrospective".to_string(),
+ name_suggestion: "Retrospective".to_string(),
+ priority: FilePriority::Optional,
+ description: "Reflect on the project and capture learnings".to_string(),
+ },
+ ],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Review completed work, document the release, and conduct a retrospective. The contract can be completed after review.".to_string(),
+ },
+ _ => PhaseDeliverables {
+ phase: phase.to_string(),
+ recommended_files: vec![],
+ requires_repository: false,
+ requires_tasks: false,
+ guidance: "Unknown phase".to_string(),
+ },
+ }
+}
+
+/// Build a phase checklist comparing expected vs actual deliverables
+pub fn get_phase_checklist(
+ phase: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+) -> PhaseChecklist {
+ let deliverables = get_phase_deliverables(phase);
+
+ // Match files to expected deliverables
+ let file_deliverables: Vec<DeliverableStatus> = deliverables
+ .recommended_files
+ .iter()
+ .map(|rec| {
+ // Check if a file with matching template ID or similar name exists
+ let matched_file = files.iter().find(|f| {
+ // Match by phase first
+ f.contract_phase.as_deref() == Some(phase) &&
+ // Then by name similarity (case-insensitive contains)
+ (f.name.to_lowercase().contains(&rec.name_suggestion.to_lowercase()) ||
+ rec.name_suggestion.to_lowercase().contains(&f.name.to_lowercase()) ||
+ f.name.to_lowercase().contains(&rec.template_id.replace("-", " ")))
+ });
+
+ DeliverableStatus {
+ template_id: rec.template_id.clone(),
+ name: rec.name_suggestion.clone(),
+ priority: rec.priority,
+ completed: matched_file.is_some(),
+ file_id: matched_file.map(|f| f.id),
+ actual_name: matched_file.map(|f| f.name.clone()),
+ }
+ })
+ .collect();
+
+ // Calculate task stats for execute phase
+ let task_stats = if phase == "execute" {
+ let total = tasks.len();
+ let pending = tasks.iter().filter(|t| t.status == "pending").count();
+ let running = tasks.iter().filter(|t| t.status == "running").count();
+ let done = tasks.iter().filter(|t| t.status == "done").count();
+ let failed = tasks.iter().filter(|t| t.status == "failed" || t.status == "error").count();
+
+ Some(TaskStats { total, pending, running, done, failed })
+ } else {
+ None
+ };
+
+ // Calculate completion percentage
+ let mut completed_items = 0;
+ let mut total_items = 0;
+
+ // Count required and recommended files (not optional)
+ for status in &file_deliverables {
+ if status.priority != FilePriority::Optional {
+ total_items += 1;
+ if status.completed {
+ completed_items += 1;
+ }
+ }
+ }
+
+ // Count repository if required
+ if deliverables.requires_repository {
+ total_items += 1;
+ if has_repository {
+ completed_items += 1;
+ }
+ }
+
+ // Count tasks if in execute phase
+ if let Some(ref stats) = task_stats {
+ if stats.total > 0 {
+ total_items += 1;
+ if stats.done == stats.total && stats.total > 0 {
+ completed_items += 1;
+ }
+ }
+ }
+
+ let completion_percentage = if total_items > 0 {
+ ((completed_items as f64 / total_items as f64) * 100.0) as u8
+ } else {
+ 100 // No requirements means complete
+ };
+
+ // Generate suggestions
+ let mut suggestions = Vec::new();
+
+ // Suggest missing required files
+ for status in &file_deliverables {
+ if !status.completed {
+ match status.priority {
+ FilePriority::Required => {
+ suggestions.push(format!("Create {} (required)", status.name));
+ }
+ FilePriority::Recommended => {
+ suggestions.push(format!("Consider creating {} (recommended)", status.name));
+ }
+ FilePriority::Optional => {
+ // Don't suggest optional items
+ }
+ }
+ }
+ }
+
+ // Suggest repository if needed
+ if deliverables.requires_repository && !has_repository {
+ suggestions.push("Configure a repository for task execution".to_string());
+ }
+
+ // Suggest task actions for execute phase
+ if let Some(ref stats) = task_stats {
+ if stats.total == 0 {
+ suggestions.push("Create tasks from the Task Breakdown document".to_string());
+ } else if stats.pending > 0 {
+ suggestions.push(format!("Run {} pending task(s)", stats.pending));
+ } else if stats.running > 0 {
+ suggestions.push(format!("Wait for {} running task(s) to complete", stats.running));
+ } else if stats.failed > 0 {
+ suggestions.push(format!("Address {} failed task(s)", stats.failed));
+ }
+ }
+
+ // Generate summary
+ let summary = generate_phase_summary(phase, &file_deliverables, has_repository, &task_stats, completion_percentage);
+
+ PhaseChecklist {
+ phase: phase.to_string(),
+ file_deliverables,
+ has_repository,
+ repository_required: deliverables.requires_repository,
+ task_stats,
+ completion_percentage,
+ summary,
+ suggestions,
+ }
+}
+
+fn generate_phase_summary(
+ phase: &str,
+ deliverables: &[DeliverableStatus],
+ has_repository: bool,
+ task_stats: &Option<TaskStats>,
+ completion_percentage: u8,
+) -> String {
+ let completed_count = deliverables.iter().filter(|d| d.completed).count();
+ let total_count = deliverables.len();
+
+ match phase {
+ "research" => {
+ if completed_count == 0 {
+ "Research phase needs documentation. Create research notes or competitor analysis.".to_string()
+ } else {
+ format!("{}/{} research documents created. Consider transitioning to Specify phase.", completed_count, total_count)
+ }
+ }
+ "specify" => {
+ let has_required = deliverables.iter()
+ .filter(|d| d.priority == FilePriority::Required)
+ .all(|d| d.completed);
+
+ if !has_required {
+ "Specify phase requires a Requirements Document before transitioning.".to_string()
+ } else if completion_percentage >= 66 {
+ "Specifications are ready. Consider transitioning to Plan phase.".to_string()
+ } else {
+ format!("{}/{} specification documents created.", completed_count, total_count)
+ }
+ }
+ "plan" => {
+ let has_task_breakdown = deliverables.iter()
+ .any(|d| d.template_id == "task-breakdown" && d.completed);
+
+ if !has_task_breakdown {
+ "Plan phase requires a Task Breakdown document.".to_string()
+ } else if !has_repository {
+ "Repository not configured. Configure a repository before Execute phase.".to_string()
+ } else {
+ "Planning complete. Ready to transition to Execute phase.".to_string()
+ }
+ }
+ "execute" => {
+ if let Some(stats) = task_stats {
+ if stats.total == 0 {
+ "No tasks created. Create tasks from the Task Breakdown document.".to_string()
+ } else if stats.done == stats.total {
+ "All tasks complete! Ready for Review phase.".to_string()
+ } else {
+ format!("{}/{} tasks completed ({}% done)", stats.done, stats.total,
+ if stats.total > 0 { (stats.done * 100) / stats.total } else { 0 })
+ }
+ } else {
+ "Execute phase in progress.".to_string()
+ }
+ }
+ "review" => {
+ let has_release_notes = deliverables.iter()
+ .any(|d| d.template_id == "release-notes" && d.completed);
+
+ if !has_release_notes {
+ "Review phase requires Release Notes before completion.".to_string()
+ } else {
+ "Review documentation complete. Contract can be marked as done.".to_string()
+ }
+ }
+ _ => format!("Phase {} - {}% complete", phase, completion_percentage),
+ }
+}
+
+/// Check if phase targets are met for transition
+pub fn check_phase_completion(
+ phase: &str,
+ files: &[FileInfo],
+ tasks: &[TaskInfo],
+ has_repository: bool,
+) -> bool {
+ let checklist = get_phase_checklist(phase, files, tasks, has_repository);
+
+ // Check required files are complete
+ let required_files_complete = checklist.file_deliverables.iter()
+ .filter(|d| d.priority == FilePriority::Required)
+ .all(|d| d.completed);
+
+ // Check repository if required
+ let repository_ok = !checklist.repository_required || checklist.has_repository;
+
+ // Check tasks if in execute phase
+ let tasks_ok = if let Some(stats) = &checklist.task_stats {
+ stats.total > 0 && stats.done == stats.total
+ } else {
+ true
+ };
+
+ required_files_complete && repository_ok && tasks_ok
+}
+
+/// Format checklist as markdown for LLM context
+pub fn format_checklist_markdown(checklist: &PhaseChecklist) -> String {
+ let mut md = format!("## Phase Progress ({} Phase)\n\n", capitalize(&checklist.phase));
+
+ // File deliverables
+ md.push_str("### Deliverables\n");
+ for status in &checklist.file_deliverables {
+ let check = if status.completed { "+" } else { "-" };
+ let priority_label = match status.priority {
+ FilePriority::Required => " (required)",
+ FilePriority::Recommended => " (recommended)",
+ FilePriority::Optional => " (optional)",
+ };
+
+ if status.completed {
+ md.push_str(&format!("[{}] {} - \"{}\"\n", check, status.name, status.actual_name.as_deref().unwrap_or("created")));
+ } else {
+ md.push_str(&format!("[{}] {}{}\n", check, status.name, priority_label));
+ }
+ }
+
+ // Repository status
+ if checklist.repository_required {
+ let check = if checklist.has_repository { "+" } else { "-" };
+ md.push_str(&format!("[{}] Repository configured (required)\n", check));
+ }
+
+ // Task stats for execute phase
+ if let Some(ref stats) = checklist.task_stats {
+ md.push_str(&format!("\n### Task Progress\n"));
+ md.push_str(&format!("- Total: {}\n", stats.total));
+ md.push_str(&format!("- Done: {}\n", stats.done));
+ if stats.pending > 0 {
+ md.push_str(&format!("- Pending: {}\n", stats.pending));
+ }
+ if stats.running > 0 {
+ md.push_str(&format!("- Running: {}\n", stats.running));
+ }
+ if stats.failed > 0 {
+ md.push_str(&format!("- Failed: {}\n", stats.failed));
+ }
+ }
+
+ // Summary
+ md.push_str(&format!("\n**Status**: {} ({}% complete)\n", checklist.summary, checklist.completion_percentage));
+
+ // Suggestions
+ if !checklist.suggestions.is_empty() {
+ md.push_str("\n**Next Steps**:\n");
+ for suggestion in &checklist.suggestions {
+ md.push_str(&format!("- {}\n", suggestion));
+ }
+ }
+
+ md
+}
+
+fn capitalize(s: &str) -> String {
+ let mut chars = s.chars();
+ match chars.next() {
+ None => String::new(),
+ Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_get_phase_deliverables() {
+ let research = get_phase_deliverables("research");
+ assert_eq!(research.phase, "research");
+ assert!(!research.requires_repository);
+ assert_eq!(research.recommended_files.len(), 3);
+
+ let plan = get_phase_deliverables("plan");
+ assert!(plan.requires_repository);
+ assert!(plan.recommended_files.iter().any(|f| f.template_id == "task-breakdown"));
+ }
+
+ #[test]
+ fn test_phase_checklist_empty() {
+ let checklist = get_phase_checklist("research", &[], &[], false);
+ assert_eq!(checklist.completion_percentage, 0);
+ assert!(!checklist.suggestions.is_empty());
+ }
+
+ #[test]
+ fn test_check_phase_completion() {
+ let files = vec![
+ FileInfo {
+ id: Uuid::new_v4(),
+ name: "Requirements Document".to_string(),
+ contract_phase: Some("specify".to_string()),
+ },
+ ];
+
+ // Specify phase has required file
+ let complete = check_phase_completion("specify", &files, &[], false);
+ assert!(complete);
+ }
+}
diff --git a/makima/src/llm/task_output.rs b/makima/src/llm/task_output.rs
new file mode 100644
index 0000000..c71c05a
--- /dev/null
+++ b/makima/src/llm/task_output.rs
@@ -0,0 +1,461 @@
+//! Task output processing and task derivation utilities.
+//!
+//! This module provides utilities for:
+//! - Parsing task lists from markdown documents
+//! - Analyzing completed task outputs
+//! - Suggesting follow-up actions based on task results
+
+use regex::Regex;
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// A parsed task from a markdown document
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ParsedTask {
+ /// Task name/title
+ pub name: String,
+ /// Task description or plan
+ pub description: Option<String>,
+ /// Group/phase this task belongs to
+ pub group: Option<String>,
+ /// Order within the group (0-indexed)
+ pub order: usize,
+ /// Whether this task was marked as completed in source
+ pub completed: bool,
+ /// Dependencies (names of other tasks)
+ pub dependencies: Vec<String>,
+}
+
+/// Result of parsing tasks from a document
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TaskParseResult {
+ /// Successfully parsed tasks
+ pub tasks: Vec<ParsedTask>,
+ /// Groups/phases found
+ pub groups: Vec<String>,
+ /// Total tasks found
+ pub total: usize,
+ /// Any parsing warnings
+ pub warnings: Vec<String>,
+}
+
+/// Impact on contract phase
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PhaseImpact {
+ /// Current phase
+ pub phase: String,
+ /// Whether phase targets are now met
+ pub targets_met: bool,
+ /// Tasks remaining in phase
+ pub tasks_remaining: usize,
+ /// Suggestion for phase transition
+ pub transition_suggestion: Option<String>,
+}
+
+/// Suggested action based on task output
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum SuggestedAction {
+ /// Create a follow-up task
+ CreateTask {
+ name: String,
+ plan: String,
+ chain_from: Option<Uuid>,
+ },
+ /// Create a new file from template
+ CreateFile {
+ template_id: String,
+ name: String,
+ seed_content: Option<String>,
+ },
+ /// Update an existing file
+ UpdateFile {
+ file_id: Uuid,
+ file_name: String,
+ additions: String,
+ },
+ /// Advance to next phase
+ AdvancePhase {
+ to_phase: String,
+ },
+ /// Run the next chained task
+ RunNextTask {
+ task_id: Uuid,
+ task_name: String,
+ },
+}
+
+/// Analysis of a completed task's output
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TaskOutputAnalysis {
+ /// Summary of what was accomplished
+ pub summary: String,
+ /// Files that were created/modified (from diff)
+ pub files_affected: Vec<String>,
+ /// Suggested next actions
+ pub next_steps: Vec<SuggestedAction>,
+ /// Impact on contract phase
+ pub phase_impact: Option<PhaseImpact>,
+}
+
+/// Parse tasks from a markdown task breakdown document
+///
+/// Supports formats like:
+/// - `[ ] Task name`
+/// - `[x] Completed task`
+/// - `1. Task name`
+/// - `- Task name`
+///
+/// Groups are detected from `## Phase/Section` headings.
+pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult {
+ let mut tasks = Vec::new();
+ let mut groups = Vec::new();
+ let mut warnings = Vec::new();
+ let mut current_group: Option<String> = None;
+ let mut task_order = 0;
+
+ // Patterns for task items
+ let checkbox_pattern = Regex::new(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$").unwrap();
+ let numbered_checkbox = Regex::new(r"^\s*\d+\.\s*\[([ xX])\]\s*(.+)$").unwrap();
+ let numbered_pattern = Regex::new(r"^\s*\d+\.\s+(.+)$").unwrap();
+ let bullet_pattern = Regex::new(r"^\s*[-*]\s+(.+)$").unwrap();
+ let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap();
+
+ // Patterns for dependencies (inline)
+ let depends_pattern = Regex::new(r"(?i)(?:depends on|after|requires):?\s*(.+)").unwrap();
+
+ for line in content.lines() {
+ let trimmed = line.trim();
+
+ // Skip empty lines
+ if trimmed.is_empty() {
+ continue;
+ }
+
+ // Check for section headings
+ if let Some(caps) = heading_pattern.captures(trimmed) {
+ let group_name = caps[1].trim().to_string();
+ if !groups.contains(&group_name) {
+ groups.push(group_name.clone());
+ }
+ current_group = Some(group_name);
+ task_order = 0;
+ continue;
+ }
+
+ // Try to parse as a task
+ let mut task_name: Option<String> = None;
+ let mut completed = false;
+
+ // Try checkbox patterns first (more specific)
+ if let Some(caps) = checkbox_pattern.captures(trimmed) {
+ completed = &caps[1] != " ";
+ task_name = Some(caps[2].trim().to_string());
+ } else if let Some(caps) = numbered_checkbox.captures(trimmed) {
+ completed = &caps[1] != " ";
+ task_name = Some(caps[2].trim().to_string());
+ } else if let Some(caps) = numbered_pattern.captures(trimmed) {
+ task_name = Some(caps[1].trim().to_string());
+ } else if let Some(caps) = bullet_pattern.captures(trimmed) {
+ // Only treat as task if it looks like a task (has actionable verbs)
+ let text = caps[1].trim();
+ if looks_like_task(text) {
+ task_name = Some(text.to_string());
+ }
+ }
+
+ if let Some(name) = task_name {
+ // Skip items that are clearly not tasks
+ if name.to_lowercase().starts_with("note:") ||
+ name.to_lowercase().starts_with("todo:") && name.len() < 10 ||
+ name.starts_with('#') {
+ continue;
+ }
+
+ // Extract dependencies if present
+ let dependencies = if let Some(dep_caps) = depends_pattern.captures(&name) {
+ dep_caps[1]
+ .split(',')
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect()
+ } else {
+ Vec::new()
+ };
+
+ // Clean task name (remove dependency info)
+ let clean_name = depends_pattern.replace(&name, "").trim().to_string();
+
+ // Extract description if there's a colon
+ let (final_name, description) = if let Some(idx) = clean_name.find(':') {
+ let (n, d) = clean_name.split_at(idx);
+ (n.trim().to_string(), Some(d[1..].trim().to_string()))
+ } else {
+ (clean_name, None)
+ };
+
+ tasks.push(ParsedTask {
+ name: final_name,
+ description,
+ group: current_group.clone(),
+ order: task_order,
+ completed,
+ dependencies,
+ });
+
+ task_order += 1;
+ }
+ }
+
+ let total = tasks.len();
+
+ // Add warnings
+ if tasks.is_empty() {
+ warnings.push("No tasks found in document. Ensure tasks are formatted as checkbox items (- [ ] Task) or numbered lists (1. Task).".to_string());
+ }
+
+ TaskParseResult {
+ tasks,
+ groups,
+ total,
+ warnings,
+ }
+}
+
+/// Check if text looks like a task (has action verbs)
+fn looks_like_task(text: &str) -> bool {
+ let lower = text.to_lowercase();
+ let action_verbs = [
+ "add", "create", "implement", "build", "write", "fix", "update",
+ "refactor", "test", "configure", "set up", "setup", "deploy",
+ "integrate", "migrate", "design", "review", "document", "remove",
+ "delete", "modify", "change", "improve", "optimize", "enable",
+ "disable", "install", "initialize", "define", "extend", "extract",
+ ];
+
+ action_verbs.iter().any(|verb| lower.starts_with(verb) || lower.contains(&format!(" {}", verb)))
+}
+
+/// Analyze a completed task's output to suggest next actions
+pub fn analyze_task_output(
+ _task_id: Uuid,
+ task_name: &str,
+ task_result: Option<&str>,
+ task_diff: Option<&str>,
+ contract_phase: &str,
+ total_tasks: usize,
+ completed_tasks: usize,
+ next_task: Option<(Uuid, String)>,
+ dev_notes_file: Option<(Uuid, String)>,
+) -> TaskOutputAnalysis {
+ let mut next_steps = Vec::new();
+ let mut files_affected = Vec::new();
+
+ // Parse files from diff if available
+ if let Some(diff) = task_diff {
+ files_affected = extract_files_from_diff(diff);
+ }
+
+ // Generate summary
+ let summary = if let Some(result) = task_result {
+ if result.len() > 200 {
+ format!("{}...", &result[..200])
+ } else {
+ result.to_string()
+ }
+ } else {
+ format!("Task '{}' completed", task_name)
+ };
+
+ // If there's a next chained task, suggest running it
+ if let Some((next_id, next_name)) = next_task {
+ next_steps.push(SuggestedAction::RunNextTask {
+ task_id: next_id,
+ task_name: next_name,
+ });
+ }
+
+ // Suggest updating Dev Notes if in execute phase and file exists
+ if contract_phase == "execute" {
+ if let Some((file_id, file_name)) = dev_notes_file {
+ let additions = format!(
+ "\n## Task: {}\n\n{}\n\n### Files Modified\n{}\n",
+ task_name,
+ summary,
+ files_affected.iter()
+ .map(|f| format!("- {}", f))
+ .collect::<Vec<_>>()
+ .join("\n")
+ );
+
+ next_steps.push(SuggestedAction::UpdateFile {
+ file_id,
+ file_name,
+ additions,
+ });
+ } else {
+ // Suggest creating Dev Notes
+ next_steps.push(SuggestedAction::CreateFile {
+ template_id: "dev-notes".to_string(),
+ name: "Development Notes".to_string(),
+ seed_content: Some(format!(
+ "# Development Notes\n\n## Task: {}\n\n{}\n",
+ task_name, summary
+ )),
+ });
+ }
+ }
+
+ // Calculate phase impact
+ let new_completed = completed_tasks + 1;
+ let targets_met = new_completed >= total_tasks && total_tasks > 0;
+ let tasks_remaining = total_tasks.saturating_sub(new_completed);
+
+ let transition_suggestion = if targets_met && contract_phase == "execute" {
+ Some("All tasks complete. Ready to advance to Review phase.".to_string())
+ } else {
+ None
+ };
+
+ // If targets are met, suggest phase transition
+ if targets_met && contract_phase == "execute" {
+ next_steps.push(SuggestedAction::AdvancePhase {
+ to_phase: "review".to_string(),
+ });
+ }
+
+ let phase_impact = Some(PhaseImpact {
+ phase: contract_phase.to_string(),
+ targets_met,
+ tasks_remaining,
+ transition_suggestion,
+ });
+
+ TaskOutputAnalysis {
+ summary,
+ files_affected,
+ next_steps,
+ phase_impact,
+ }
+}
+
+/// Extract file paths from a git diff
+fn extract_files_from_diff(diff: &str) -> Vec<String> {
+ let mut files = Vec::new();
+ let file_pattern = Regex::new(r"^(?:diff --git a/|[+]{3} b/|[-]{3} a/)(.+)$").unwrap();
+
+ for line in diff.lines() {
+ if let Some(caps) = file_pattern.captures(line) {
+ let path = caps[1].trim().to_string();
+ // Skip /dev/null and duplicates
+ if path != "/dev/null" && !files.contains(&path) {
+ // Clean up path (remove a/ or b/ prefix from git diff)
+ let clean_path = path.trim_start_matches("a/").trim_start_matches("b/").to_string();
+ if !files.contains(&clean_path) {
+ files.push(clean_path);
+ }
+ }
+ }
+ }
+
+ files
+}
+
+/// Format parsed tasks for display
+pub fn format_parsed_tasks(result: &TaskParseResult) -> String {
+ let mut output = String::new();
+
+ if result.tasks.is_empty() {
+ output.push_str("No tasks found in the document.\n");
+ for warning in &result.warnings {
+ output.push_str(&format!("Warning: {}\n", warning));
+ }
+ return output;
+ }
+
+ output.push_str(&format!("Found {} task(s)", result.total));
+ if !result.groups.is_empty() {
+ output.push_str(&format!(" in {} group(s)", result.groups.len()));
+ }
+ output.push_str(":\n\n");
+
+ let mut current_group: Option<&str> = None;
+ for (i, task) in result.tasks.iter().enumerate() {
+ // Print group header if changed
+ if task.group.as_deref() != current_group {
+ current_group = task.group.as_deref();
+ if let Some(group) = current_group {
+ output.push_str(&format!("**{}**\n", group));
+ }
+ }
+
+ let status = if task.completed { "[x]" } else { "[ ]" };
+ output.push_str(&format!("{}. {} {}", i + 1, status, task.name));
+
+ if !task.dependencies.is_empty() {
+ output.push_str(&format!(" (depends on: {})", task.dependencies.join(", ")));
+ }
+
+ output.push('\n');
+ }
+
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_checkbox_tasks() {
+ let content = r#"
+## Phase 1: Setup
+- [ ] Set up project structure
+- [x] Configure dev environment
+
+## Phase 2: Features
+1. [ ] Implement authentication
+2. [ ] Add user dashboard
+"#;
+
+ let result = parse_tasks_from_breakdown(content);
+ assert_eq!(result.total, 4);
+ assert_eq!(result.groups.len(), 2);
+ assert!(!result.tasks[0].completed);
+ assert!(result.tasks[1].completed);
+ }
+
+ #[test]
+ fn test_parse_with_dependencies() {
+ let content = r#"
+- [ ] Task A
+- [ ] Task B (depends on: Task A)
+"#;
+
+ let result = parse_tasks_from_breakdown(content);
+ assert_eq!(result.tasks[1].dependencies, vec!["Task A"]);
+ }
+
+ #[test]
+ fn test_extract_files_from_diff() {
+ let diff = r#"
+diff --git a/src/main.rs b/src/main.rs
+--- a/src/main.rs
++++ b/src/main.rs
+@@ -1,3 +1,4 @@
++fn new_function() {}
+"#;
+
+ let files = extract_files_from_diff(diff);
+ assert!(files.contains(&"src/main.rs".to_string()));
+ }
+
+ #[test]
+ fn test_looks_like_task() {
+ assert!(looks_like_task("Add authentication"));
+ assert!(looks_like_task("Create user model"));
+ assert!(looks_like_task("implement feature X"));
+ assert!(!looks_like_task("This is a note"));
+ assert!(!looks_like_task("Summary of changes"));
+ }
+}
diff --git a/makima/src/llm/templates.rs b/makima/src/llm/templates.rs
new file mode 100644
index 0000000..18ef46d
--- /dev/null
+++ b/makima/src/llm/templates.rs
@@ -0,0 +1,1011 @@
+//! Template definitions for phase-appropriate file structures.
+//!
+//! Templates provide starting structures for files based on the contract phase.
+//! Each phase has templates suited for that stage of work.
+
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::db::models::BodyElement;
+
+/// A file template with suggested structure
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct FileTemplate {
+ /// Template identifier
+ pub id: String,
+ /// Display name
+ pub name: String,
+ /// Contract phase this template is designed for
+ pub phase: String,
+ /// Brief description of what this template is for
+ pub description: String,
+ /// Suggested body elements (structure only - content to be filled by LLM)
+ pub suggested_body: Vec<BodyElement>,
+}
+
+/// Get templates appropriate for a given contract phase
+pub fn templates_for_phase(phase: &str) -> Vec<FileTemplate> {
+ match phase {
+ "research" => vec![
+ research_notes_template(),
+ competitor_analysis_template(),
+ user_research_template(),
+ ],
+ "specify" => vec![
+ requirements_template(),
+ user_stories_template(),
+ acceptance_criteria_template(),
+ ],
+ "plan" => vec![
+ architecture_template(),
+ technical_design_template(),
+ task_breakdown_template(),
+ ],
+ "execute" => vec![
+ dev_notes_template(),
+ test_plan_template(),
+ implementation_log_template(),
+ ],
+ "review" => vec![
+ review_checklist_template(),
+ release_notes_template(),
+ retrospective_template(),
+ ],
+ _ => vec![],
+ }
+}
+
+/// Get all available templates across all phases
+pub fn all_templates() -> Vec<FileTemplate> {
+ vec![
+ // Research phase
+ research_notes_template(),
+ competitor_analysis_template(),
+ user_research_template(),
+ // Specify phase
+ requirements_template(),
+ user_stories_template(),
+ acceptance_criteria_template(),
+ // Plan phase
+ architecture_template(),
+ technical_design_template(),
+ task_breakdown_template(),
+ // Execute phase
+ dev_notes_template(),
+ test_plan_template(),
+ implementation_log_template(),
+ // Review phase
+ review_checklist_template(),
+ release_notes_template(),
+ retrospective_template(),
+ ]
+}
+
+// =============================================================================
+// Research Phase Templates
+// =============================================================================
+
+fn research_notes_template() -> FileTemplate {
+ FileTemplate {
+ id: "research-notes".to_string(),
+ name: "Research Notes".to_string(),
+ phase: "research".to_string(),
+ description: "Document findings, insights, and questions during research".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Research Notes".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Context".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Describe the research objective and scope...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Key Findings".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Finding 1...".to_string(),
+ "Finding 2...".to_string(),
+ "Finding 3...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Open Questions".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "Question to investigate...".to_string(),
+ "Area needing more research...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Next Steps".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Outline follow-up actions...".to_string(),
+ },
+ ],
+ }
+}
+
+fn competitor_analysis_template() -> FileTemplate {
+ FileTemplate {
+ id: "competitor-analysis".to_string(),
+ name: "Competitor Analysis".to_string(),
+ phase: "research".to_string(),
+ description: "Analyze competitors and market positioning".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Competitor Analysis".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Market Overview".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Describe the market landscape...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Competitor 1: [Name]".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Strengths: ...".to_string(),
+ "Weaknesses: ...".to_string(),
+ "Key Features: ...".to_string(),
+ "Pricing: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Competitive Advantages".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Our differentiation strategy...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Gaps & Opportunities".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Opportunity 1...".to_string(), "Opportunity 2...".to_string()],
+ },
+ ],
+ }
+}
+
+fn user_research_template() -> FileTemplate {
+ FileTemplate {
+ id: "user-research".to_string(),
+ name: "User Research".to_string(),
+ phase: "research".to_string(),
+ description: "Document user interviews, surveys, and persona insights".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "User Research".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Research Method".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Describe the research methodology used...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "User Personas".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Persona 1: [Name]".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Role: ...".to_string(),
+ "Goals: ...".to_string(),
+ "Pain Points: ...".to_string(),
+ "Behaviors: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Key Insights".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec!["Insight from research...".to_string()],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Recommendations".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Based on research findings...".to_string(),
+ },
+ ],
+ }
+}
+
+// =============================================================================
+// Specify Phase Templates
+// =============================================================================
+
+fn requirements_template() -> FileTemplate {
+ FileTemplate {
+ id: "requirements".to_string(),
+ name: "Requirements Document".to_string(),
+ phase: "specify".to_string(),
+ description: "Define functional and non-functional requirements".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Requirements Document".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Overview".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Brief description of the feature/project...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Functional Requirements".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "FR-001: The system shall...".to_string(),
+ "FR-002: Users must be able to...".to_string(),
+ "FR-003: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Non-Functional Requirements".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "NFR-001: Performance - ...".to_string(),
+ "NFR-002: Security - ...".to_string(),
+ "NFR-003: Scalability - ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Constraints".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Technical constraints...".to_string(),
+ "Business constraints...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Dependencies".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "External dependencies and integrations...".to_string(),
+ },
+ ],
+ }
+}
+
+fn user_stories_template() -> FileTemplate {
+ FileTemplate {
+ id: "user-stories".to_string(),
+ name: "User Stories".to_string(),
+ phase: "specify".to_string(),
+ description: "Define features from the user's perspective".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "User Stories".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Epic: [Feature Name]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "High-level description of the epic...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "US-001: [Story Title]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "As a [user type], I want to [action], so that [benefit].".to_string(),
+ },
+ BodyElement::Heading {
+ level: 4,
+ text: "Acceptance Criteria".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Given... When... Then...".to_string(),
+ "Given... When... Then...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "US-002: [Story Title]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "As a [user type], I want to [action], so that [benefit].".to_string(),
+ },
+ ],
+ }
+}
+
+fn acceptance_criteria_template() -> FileTemplate {
+ FileTemplate {
+ id: "acceptance-criteria".to_string(),
+ name: "Acceptance Criteria".to_string(),
+ phase: "specify".to_string(),
+ description: "Define testable conditions for feature completion".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Acceptance Criteria".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Feature: [Name]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Description of the feature being specified...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Scenarios".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Scenario 1: [Happy Path]".to_string(),
+ },
+ BodyElement::Code {
+ language: Some("gherkin".to_string()),
+ content: "Given [precondition]\nWhen [action]\nThen [expected result]".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Scenario 2: [Edge Case]".to_string(),
+ },
+ BodyElement::Code {
+ language: Some("gherkin".to_string()),
+ content: "Given [precondition]\nWhen [action]\nThen [expected result]".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Out of Scope".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Items explicitly not included...".to_string()],
+ },
+ ],
+ }
+}
+
+// =============================================================================
+// Plan Phase Templates
+// =============================================================================
+
+fn architecture_template() -> FileTemplate {
+ FileTemplate {
+ id: "architecture".to_string(),
+ name: "Architecture Document".to_string(),
+ phase: "plan".to_string(),
+ description: "Document system architecture and design decisions".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Architecture Document".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Overview".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "High-level architecture description...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "System Components".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Component A: Description and responsibility".to_string(),
+ "Component B: Description and responsibility".to_string(),
+ "Component C: Description and responsibility".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Data Flow".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Describe how data flows through the system...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Technology Stack".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Frontend: ...".to_string(),
+ "Backend: ...".to_string(),
+ "Database: ...".to_string(),
+ "Infrastructure: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Design Decisions".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "ADR-001: [Decision Title]".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Context: ...".to_string(),
+ "Decision: ...".to_string(),
+ "Consequences: ...".to_string(),
+ ],
+ },
+ ],
+ }
+}
+
+fn technical_design_template() -> FileTemplate {
+ FileTemplate {
+ id: "technical-design".to_string(),
+ name: "Technical Design".to_string(),
+ phase: "plan".to_string(),
+ description: "Detailed technical specification for implementation".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Technical Design".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Purpose".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "What this design document covers...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "API Design".to_string(),
+ },
+ BodyElement::Code {
+ language: Some("typescript".to_string()),
+ content: "// Interface definitions\ninterface Example {\n // ...\n}".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Database Schema".to_string(),
+ },
+ BodyElement::Code {
+ language: Some("sql".to_string()),
+ content: "-- Table definitions\nCREATE TABLE example (\n -- ...\n);".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Implementation Notes".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Key implementation consideration...".to_string(),
+ "Performance consideration...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Migration Strategy".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "How to migrate from current state...".to_string(),
+ },
+ ],
+ }
+}
+
+fn task_breakdown_template() -> FileTemplate {
+ FileTemplate {
+ id: "task-breakdown".to_string(),
+ name: "Task Breakdown".to_string(),
+ phase: "plan".to_string(),
+ description: "Break down work into implementable tasks".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Task Breakdown".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Overview".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Summary of the work to be done...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Phase 1: Foundation".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "[ ] Task 1: Set up project structure".to_string(),
+ "[ ] Task 2: Configure development environment".to_string(),
+ "[ ] Task 3: Create base components".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Phase 2: Core Features".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "[ ] Task 4: Implement feature A".to_string(),
+ "[ ] Task 5: Implement feature B".to_string(),
+ "[ ] Task 6: Add tests".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Phase 3: Polish & Deploy".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "[ ] Task 7: Error handling".to_string(),
+ "[ ] Task 8: Documentation".to_string(),
+ "[ ] Task 9: Deployment".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Dependencies".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Task dependencies and blockers...".to_string(),
+ },
+ ],
+ }
+}
+
+// =============================================================================
+// Execute Phase Templates
+// =============================================================================
+
+fn dev_notes_template() -> FileTemplate {
+ FileTemplate {
+ id: "dev-notes".to_string(),
+ name: "Development Notes".to_string(),
+ phase: "execute".to_string(),
+ description: "Track implementation details, decisions, and learnings".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Development Notes".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Current Status".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Brief summary of implementation progress...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Implementation Details".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "[Component/Feature Name]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "How this was implemented and why...".to_string(),
+ },
+ BodyElement::Code {
+ language: Some("typescript".to_string()),
+ content: "// Key code snippet or example".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Challenges & Solutions".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Challenge: ... | Solution: ...".to_string(),
+ "Challenge: ... | Solution: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "TODOs".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] Remaining item...".to_string(),
+ "[ ] Follow-up task...".to_string(),
+ ],
+ },
+ ],
+ }
+}
+
+fn test_plan_template() -> FileTemplate {
+ FileTemplate {
+ id: "test-plan".to_string(),
+ name: "Test Plan".to_string(),
+ phase: "execute".to_string(),
+ description: "Document testing strategy and test cases".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Test Plan".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Test Scope".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "What is being tested and the testing approach...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Test Types".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Unit Tests: Component-level testing".to_string(),
+ "Integration Tests: API and service integration".to_string(),
+ "E2E Tests: User flow testing".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Test Cases".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "TC-001: [Test Name]".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Preconditions: ...".to_string(),
+ "Steps: ...".to_string(),
+ "Expected Result: ...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Test Data".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Required test data and fixtures...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Test Results".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] TC-001: Pending".to_string(),
+ "[ ] TC-002: Pending".to_string(),
+ ],
+ },
+ ],
+ }
+}
+
+fn implementation_log_template() -> FileTemplate {
+ FileTemplate {
+ id: "implementation-log".to_string(),
+ name: "Implementation Log".to_string(),
+ phase: "execute".to_string(),
+ description: "Chronological log of implementation progress".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Implementation Log".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Tracking daily progress and decisions...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "[Date]".to_string(),
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Completed".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["What was accomplished...".to_string()],
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "In Progress".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Current work...".to_string()],
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Blockers".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Any blockers or issues...".to_string()],
+ },
+ BodyElement::Heading {
+ level: 3,
+ text: "Notes".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Additional context or decisions made...".to_string(),
+ },
+ ],
+ }
+}
+
+// =============================================================================
+// Review Phase Templates
+// =============================================================================
+
+fn review_checklist_template() -> FileTemplate {
+ FileTemplate {
+ id: "review-checklist".to_string(),
+ name: "Review Checklist".to_string(),
+ phase: "review".to_string(),
+ description: "Comprehensive checklist for code and feature review".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Review Checklist".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Code Quality".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] Code follows style guidelines".to_string(),
+ "[ ] No unnecessary complexity".to_string(),
+ "[ ] Functions are well-named and focused".to_string(),
+ "[ ] No dead code or commented-out code".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Testing".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] Unit tests pass".to_string(),
+ "[ ] Integration tests pass".to_string(),
+ "[ ] Edge cases covered".to_string(),
+ "[ ] Test coverage acceptable".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Security".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] No hardcoded credentials".to_string(),
+ "[ ] Input validation in place".to_string(),
+ "[ ] Authentication/authorization correct".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Documentation".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] README updated".to_string(),
+ "[ ] API documentation complete".to_string(),
+ "[ ] Inline comments where needed".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Review Notes".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Additional review comments and feedback...".to_string(),
+ },
+ ],
+ }
+}
+
+fn release_notes_template() -> FileTemplate {
+ FileTemplate {
+ id: "release-notes".to_string(),
+ name: "Release Notes".to_string(),
+ phase: "review".to_string(),
+ description: "Document changes for release communication".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Release Notes - v[X.Y.Z]".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Release date: [DATE]".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Highlights".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Key features and improvements in this release...".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "New Features".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Feature 1: Description".to_string(),
+ "Feature 2: Description".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Improvements".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Improvement 1: Description".to_string(),
+ "Improvement 2: Description".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Bug Fixes".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Fixed: Issue description".to_string(),
+ "Fixed: Issue description".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Breaking Changes".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Breaking change description (if any)...".to_string()],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Known Issues".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec!["Known issue (if any)...".to_string()],
+ },
+ ],
+ }
+}
+
+fn retrospective_template() -> FileTemplate {
+ FileTemplate {
+ id: "retrospective".to_string(),
+ name: "Retrospective".to_string(),
+ phase: "review".to_string(),
+ description: "Reflect on the project and capture learnings".to_string(),
+ suggested_body: vec![
+ BodyElement::Heading {
+ level: 1,
+ text: "Retrospective".to_string(),
+ },
+ BodyElement::Paragraph {
+ text: "Project: [Name] | Date: [DATE]".to_string(),
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "What Went Well".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Success 1...".to_string(),
+ "Success 2...".to_string(),
+ "Success 3...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "What Could Be Improved".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Area for improvement 1...".to_string(),
+ "Area for improvement 2...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Lessons Learned".to_string(),
+ },
+ BodyElement::List {
+ ordered: true,
+ items: vec![
+ "Key lesson from this project...".to_string(),
+ "Technical insight gained...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Action Items".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "[ ] Action to improve future projects...".to_string(),
+ "[ ] Process change to implement...".to_string(),
+ ],
+ },
+ BodyElement::Heading {
+ level: 2,
+ text: "Metrics".to_string(),
+ },
+ BodyElement::List {
+ ordered: false,
+ items: vec![
+ "Timeline: Planned vs Actual".to_string(),
+ "Scope: Delivered vs Planned".to_string(),
+ "Quality: Bug count, test coverage".to_string(),
+ ],
+ },
+ ],
+ }
+}
diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs
index 649633e..ae1dc5a 100644
--- a/makima/src/llm/tools.rs
+++ b/makima/src/llm/tools.rs
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::db::models::{BodyElement, ChartType, TranscriptEntry};
+use crate::llm::templates;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
@@ -411,6 +412,36 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
"required": ["target_version"]
}),
},
+ // Template tools
+ Tool {
+ name: "suggest_templates".to_string(),
+ description: "Get suggested file templates based on a contract phase. Returns templates with predefined structures appropriate for research, specify, plan, execute, or review phases. Use this to help users start documents with proper structure.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "phase": {
+ "type": "string",
+ "enum": ["research", "specify", "plan", "execute", "review"],
+ "description": "The contract phase to get templates for. If not provided, returns all templates."
+ }
+ },
+ "required": []
+ }),
+ },
+ Tool {
+ name: "apply_template".to_string(),
+ description: "Apply a template to the current file, replacing the body with the template structure. The template provides a starting structure that should be customized for the user's needs.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "template_id": {
+ "type": "string",
+ "description": "The template ID to apply (e.g., 'research-notes', 'requirements', 'architecture')"
+ }
+ },
+ "required": ["template_id"]
+ }),
+ },
]
});
@@ -500,6 +531,9 @@ pub fn execute_tool_call(
"list_versions" => execute_list_versions(),
"read_version" => execute_read_version(call),
"restore_version" => execute_restore_version(call),
+ // Template tools
+ "suggest_templates" => execute_suggest_templates(call),
+ "apply_template" => execute_apply_template(call),
_ => ToolExecutionResult {
result: ToolResult {
success: false,
@@ -1350,6 +1384,11 @@ fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult {
"alt": alt,
"caption": caption
}),
+ BodyElement::Markdown { content } => json!({
+ "index": i,
+ "type": "markdown",
+ "content": content
+ }),
}
})
.collect();
@@ -1439,6 +1478,11 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx
"alt": alt,
"caption": caption
}),
+ BodyElement::Markdown { content } => json!({
+ "index": index,
+ "type": "markdown",
+ "content": content
+ }),
};
let type_str = match element {
@@ -1448,6 +1492,7 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx
BodyElement::List { .. } => "list",
BodyElement::Chart { .. } => "chart",
BodyElement::Image { .. } => "image",
+ BodyElement::Markdown { .. } => "markdown",
};
ToolExecutionResult {
@@ -1603,6 +1648,131 @@ fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult {
}
}
+// =============================================================================
+// Template Tool Execution Functions
+// =============================================================================
+
+fn execute_suggest_templates(call: &ToolCall) -> ToolExecutionResult {
+ let phase = call.arguments.get("phase").and_then(|v| v.as_str());
+
+ let template_list = match phase {
+ Some(p) => templates::templates_for_phase(p),
+ None => templates::all_templates(),
+ };
+
+ if template_list.is_empty() {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "No templates available for phase: {}",
+ phase.unwrap_or("(none)")
+ ),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: Some(json!([])),
+ version_request: None,
+ pending_questions: None,
+ };
+ }
+
+ // Convert templates to JSON (without the full body for display)
+ let templates_json: Vec<serde_json::Value> = template_list
+ .iter()
+ .map(|t| {
+ json!({
+ "id": t.id,
+ "name": t.name,
+ "phase": t.phase,
+ "description": t.description,
+ "elementCount": t.suggested_body.len()
+ })
+ })
+ .collect();
+
+ let phase_msg = phase
+ .map(|p| format!(" for '{}' phase", p))
+ .unwrap_or_default();
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "Found {} template(s){}. Use apply_template with a template_id to apply one.",
+ templates_json.len(),
+ phase_msg
+ ),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: Some(json!(templates_json)),
+ version_request: None,
+ pending_questions: None,
+ }
+}
+
+fn execute_apply_template(call: &ToolCall) -> ToolExecutionResult {
+ let template_id = call
+ .arguments
+ .get("template_id")
+ .and_then(|v| v.as_str());
+
+ let Some(template_id) = template_id else {
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: "Missing template_id parameter".to_string(),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ pending_questions: None,
+ };
+ };
+
+ // Find the template
+ let all = templates::all_templates();
+ let template = all.iter().find(|t| t.id == template_id);
+
+ let Some(template) = template else {
+ let available: Vec<String> = all.iter().map(|t| t.id.clone()).collect();
+ return ToolExecutionResult {
+ result: ToolResult {
+ success: false,
+ message: format!(
+ "Template '{}' not found. Available: {}",
+ template_id,
+ available.join(", ")
+ ),
+ },
+ new_body: None,
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ pending_questions: None,
+ };
+ };
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!(
+ "Applied template '{}' ({}) with {} elements. You can now customize the content.",
+ template.name,
+ template.phase,
+ template.suggested_body.len()
+ ),
+ },
+ new_body: Some(template.suggested_body.clone()),
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ pending_questions: None,
+ }
+}
+
/// Convert serde_json::Value to jaq_interpret::Val
fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val {
match value {
diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs
index dfdb64e..9d8cd19 100644
--- a/makima/src/server/handlers/chat.rs
+++ b/makima/src/server/handlers/chat.rs
@@ -245,11 +245,12 @@ pub async fn chat_handler(
## Your Capabilities
You have access to tools for:
- **Viewing content**: view_body (see all elements), read_element (inspect specific element), view_transcript (read full transcript)
-- **Adding content**: add_heading, add_paragraph, add_chart
+- **Adding content**: add_heading, add_paragraph, add_code, add_list, add_chart
- **Modifying content**: update_element, remove_element, reorder_elements, clear_body
- **Document metadata**: set_summary
- **Data processing**: parse_csv (convert CSV to JSON), jq (transform JSON data)
- **Version history**: list_versions, read_version, restore_version
+- **Templates**: suggest_templates (get phase-appropriate templates), apply_template (apply a template structure)
## Agentic Behavior Guidelines
@@ -611,6 +612,7 @@ You have access to tools for:
summary: current_summary.clone(),
body: Some(current_body.clone()),
version: None, // Internal update, skip version check
+ repo_file_path: None,
};
match repository::update_file(pool, id, update_req).await {
@@ -687,7 +689,27 @@ fn build_file_context(file: &crate::db::models::File) -> String {
context.push_str(&format!("Summary: {}\n", summary));
}
- context.push_str(&format!("Transcript entries: {}\n", file.transcript.len()));
+ // Include contract phase context if file belongs to a contract
+ if let Some(ref phase) = file.contract_phase {
+ context.push_str(&format!("\n## Contract Context\n"));
+ context.push_str(&format!("This file belongs to a contract in the '{}' phase.\n", phase));
+ context.push_str("You can use 'suggest_templates' to get phase-appropriate templates, ");
+ context.push_str("or 'apply_template' to apply a template structure.\n");
+ context.push_str(&format!(
+ "Templates for '{}' phase include: {}\n",
+ phase,
+ match phase.as_str() {
+ "research" => "research-notes, competitor-analysis, user-research",
+ "specify" => "requirements, user-stories, acceptance-criteria",
+ "plan" => "architecture, technical-design, task-breakdown",
+ "execute" => "dev-notes, test-plan, implementation-log",
+ "review" => "review-checklist, release-notes, retrospective",
+ _ => "(use suggest_templates to see available)",
+ }
+ ));
+ }
+
+ context.push_str(&format!("\nTranscript entries: {}\n", file.transcript.len()));
context.push_str(&format!("Body elements: {}\n", file.body.len()));
// Add body overview
@@ -727,6 +749,14 @@ fn build_file_context(file: &crate::db::models::File) -> String {
BodyElement::Image { alt, .. } => {
format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
}
+ BodyElement::Markdown { content } => {
+ let preview: String = content.chars().take(50).collect();
+ if content.chars().count() > 50 {
+ format!("Markdown: {}...", preview)
+ } else {
+ format!("Markdown: {}", preview)
+ }
+ }
};
context.push_str(&format!(" [{}] {}\n", i, desc));
}
@@ -788,6 +818,9 @@ fn build_focused_element_context(body: &[BodyElement], focused_index: Option<usi
let desc = alt.as_deref().or(caption.as_deref()).unwrap_or("no description");
("Image".to_string(), desc.to_string())
}
+ BodyElement::Markdown { content } => {
+ ("Markdown".to_string(), content.clone())
+ }
};
format!(
@@ -903,6 +936,14 @@ async fn handle_version_request(
BodyElement::Image { alt, .. } => {
format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default())
}
+ BodyElement::Markdown { content } => {
+ let preview: String = content.chars().take(100).collect();
+ if content.chars().count() > 100 {
+ format!("Markdown: {}...", preview)
+ } else {
+ format!("Markdown: {}", preview)
+ }
+ }
};
format!("[{}] {}", i, desc)
})
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
new file mode 100644
index 0000000..d090999
--- /dev/null
+++ b/makima/src/server/handlers/contract_chat.rs
@@ -0,0 +1,2592 @@
+//! Chat endpoint for LLM-powered contract management.
+//!
+//! This handler provides an agentic loop for managing contracts: creating tasks,
+//! adding files, managing repositories, and handling phase transitions.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::{
+ models::{ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest},
+ repository,
+};
+use crate::llm::{
+ all_templates, analyze_task_output, body_to_markdown, format_checklist_markdown,
+ format_parsed_tasks, get_phase_checklist, parse_tasks_from_breakdown,
+ claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
+ groq::{GroqClient, GroqError, Message, ToolCallResponse},
+ parse_contract_tool_call, templates_for_phase, ContractToolRequest, FileInfo,
+ LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS,
+};
+use crate::server::auth::Authenticated;
+use crate::server::state::{DaemonCommand, SharedState};
+
+/// Maximum number of tool-calling rounds to prevent infinite loops
+const MAX_TOOL_ROUNDS: usize = 30;
+
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatHistoryMessage {
+ /// Role: "user" or "assistant"
+ pub role: String,
+ /// Message content
+ pub content: String,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatRequest {
+ /// The user's message/instruction
+ pub message: String,
+ /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq"
+ #[serde(default)]
+ pub model: Option<String>,
+ /// Optional conversation history for context continuity
+ #[serde(default)]
+ pub history: Option<Vec<ContractChatHistoryMessage>>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractChatResponse {
+ /// The LLM's response message
+ pub response: String,
+ /// Tool calls that were executed
+ pub tool_calls: Vec<ContractToolCallInfo>,
+ /// Questions pending user answers (pauses conversation)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pending_questions: Option<Vec<UserQuestion>>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractToolCallInfo {
+ pub name: String,
+ pub result: ToolResult,
+}
+
+/// Enum to hold LLM clients
+enum LlmClient {
+ Groq(GroqClient),
+ Claude(ClaudeClient),
+}
+
+/// Unified result from LLM call
+struct LlmResult {
+ content: Option<String>,
+ tool_calls: Vec<ToolCall>,
+ raw_tool_calls: Vec<ToolCallResponse>,
+ finish_reason: String,
+}
+
+/// Helper to get contract with all relations
+async fn get_contract_with_relations(
+ pool: &sqlx::PgPool,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<ContractWithRelations>, sqlx::Error> {
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? {
+ Some(c) => c,
+ None => return Ok(None),
+ };
+
+ let repositories = repository::list_contract_repositories(pool, contract_id)
+ .await
+ .unwrap_or_default();
+
+ let files = repository::list_files_in_contract(pool, contract_id, owner_id)
+ .await
+ .unwrap_or_default();
+
+ let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id)
+ .await
+ .unwrap_or_default();
+
+ Ok(Some(ContractWithRelations {
+ contract,
+ repositories,
+ files,
+ tasks,
+ }))
+}
+
+/// Chat with a contract using LLM tool calling for management
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/chat",
+ request_body = ContractChatRequest,
+ responses(
+ (status = 200, description = "Chat completed successfully", body = ContractChatResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Contract not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn contract_chat_handler(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(contract_id): Path<Uuid>,
+ Json(request): Json<ContractChatRequest>,
+) -> impl IntoResponse {
+ // Check if database is configured
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ // Get the contract (scoped by owner)
+ let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "Contract not found" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Database error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Database error: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ // Parse model selection (default to Claude Sonnet)
+ let model = request
+ .model
+ .as_ref()
+ .and_then(|m| LlmModel::from_str(m))
+ .unwrap_or(LlmModel::ClaudeSonnet);
+
+ tracing::info!("Contract chat using LLM model: {:?}", model);
+
+ // Initialize the appropriate LLM client
+ let llm_client = match model {
+ LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::GroqKimi => match GroqClient::from_env() {
+ Ok(client) => LlmClient::Groq(client),
+ Err(GroqError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "GROQ_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Groq client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ };
+
+ // Build contract context
+ let contract_context = build_contract_context(&contract);
+
+ // Build system prompt for contract management
+ let system_prompt = format!(
+ r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks.
+
+## Your Capabilities
+You have access to tools for:
+- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file
+- **File Management**: create_file_from_template, create_empty_file, list_available_templates
+- **Task Management**: create_contract_task, delegate_content_generation, start_task
+- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase
+- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository
+- **Interactive**: ask_user
+
+## Content Generation Deferral
+When asked to write substantial content, fill templates, or generate documentation:
+- **Use delegate_content_generation** to create a task for the content generation
+- This delegates the work to a task agent that can do more thorough research and writing
+
+**Use delegation for:**
+- Filling in template content with real data
+- Writing documentation based on requirements
+- Generating user stories or specifications
+- Creating detailed design documents
+- Any substantial writing that requires research or analysis
+
+**Direct actions (no delegation needed):**
+- Listing files/tasks/repos
+- Reading files
+- Phase transitions
+- Creating empty files or templates
+- Simple queries and status checks
+- Asking user questions
+
+## Contract Lifecycle Phases
+
+### 1. RESEARCH Phase
+**Purpose**: Gather information and understand the problem space
+**Key Activities**:
+- Conduct user research and interviews
+- Analyze competitors and existing solutions
+- Document findings and insights
+- Identify opportunities and constraints
+**Suggested Actions**:
+- Create a "Research Notes" document to capture findings
+- Create a "Competitor Analysis" document
+- When research is complete, suggest transitioning to Specify phase
+
+### 2. SPECIFY Phase
+**Purpose**: Define what needs to be built
+**Key Activities**:
+- Write clear requirements
+- Create user stories with acceptance criteria
+- Define scope and constraints
+- Document technical constraints
+**Suggested Actions**:
+- Create a "Requirements" document
+- Create "User Stories" with acceptance criteria
+- When specifications are clear, suggest transitioning to Plan phase
+
+### 3. PLAN Phase
+**Purpose**: Design the solution and break down the work
+**Key Activities**:
+- Design system architecture
+- Create technical specifications
+- Break work into implementable tasks
+- Set up repositories for development
+**Suggested Actions**:
+- Create an "Architecture" document
+- Create a "Task Breakdown" document
+- **IMPORTANT**: Help set up a repository if not already configured
+- When planning is complete and a repository is set, suggest transitioning to Execute phase
+
+### 4. EXECUTE Phase
+**Purpose**: Implement the solution
+**Key Activities**:
+- Create and run tasks to implement features
+- Write and run tests
+- Track progress
+- Document implementation decisions
+**Suggested Actions**:
+- Create tasks based on the task breakdown
+- Monitor task progress and help resolve blockers
+- When all tasks are complete, suggest transitioning to Review phase
+
+### 5. REVIEW Phase
+**Purpose**: Validate and document the completed work
+**Key Activities**:
+- Review completed work
+- Create release notes
+- Conduct retrospective
+- Document learnings
+**Suggested Actions**:
+- Create a "Release Notes" document
+- Create a "Retrospective" document
+- Help mark the contract as complete when review is done
+
+## Current Contract
+{contract_context}
+
+## Proactive Guidance
+
+### Repository Setup (Critical for Plan/Execute phases)
+When the user wants to add a local repository or set up for execution:
+1. **First call list_daemon_directories** to get available paths from connected agents
+2. Present the suggested directories to the user
+3. Ask which path they want to use, or let them specify a custom path
+4. Then call add_repository with the chosen path
+
+Example flow:
+```
+User: "Set up a repository for this contract"
+You: Call list_daemon_directories first
+You: "I found these directories from your connected agent:
+ - /Users/alice/projects (Working Directory)
+ - /Users/alice/.makima/home (Makima Home)
+ Which would you like to use, or provide a custom path?"
+```
+
+### Phase Transitions
+- Phases progress in order: research -> specify -> plan -> execute -> review
+- You can ONLY advance forward one step at a time to the NEXT phase
+- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value
+- Then use advance_phase with that exact nextPhase value
+- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan"
+- NEVER suggest advancing to the same phase the contract is already in
+
+### New Users
+When a new contract is created or the user seems unsure:
+1. Explain the current phase and what should be done
+2. Suggest creating appropriate documents
+3. Guide them toward the next milestone
+
+## Agentic Behavior Guidelines
+
+### 1. Understand Before Acting
+- For complex requests, first gather information about the contract's current state
+- Use get_contract_status or list_contract_files to understand what exists
+- Consider the current phase when suggesting actions
+
+### 2. Phase-Appropriate Suggestions
+- Suggest templates and actions appropriate for the current phase
+- When creating files, prefer templates that match the contract's phase
+- Advise when the contract might be ready for the next phase
+
+### 3. Help Plan Work
+- When asked to plan work, read existing files to understand context
+- Suggest creating tasks based on requirements or plans in files
+- Offer to create task breakdowns from design documents
+
+### 4. Repository Management
+- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions
+- This provides the user with valid paths from their connected agents
+- Don't ask users to manually type paths when suggestions are available
+
+### 5. Task Creation and Execution
+- When creating tasks, derive plans from existing contract files when possible
+- Use the contract's primary repository for tasks by default
+- Create clear, actionable task plans
+- After creating a task, you can use **start_task** to immediately begin execution
+- A daemon must be connected for start_task to work
+
+### 6. Be Proactive but Efficient
+- Guide users through the contract flow
+- Don't over-analyze simple requests
+- Use the minimum number of tool calls needed
+- Provide clear summaries of actions taken
+
+## Important Notes
+- This contract's ID is: {contract_id}
+- All operations are scoped to this contract
+- When creating tasks or files, they are automatically associated with this contract"#,
+ contract_context = contract_context,
+ contract_id = contract_id
+ );
+
+ // Run the agentic loop
+ run_contract_agentic_loop(
+ pool,
+ &state,
+ &llm_client,
+ system_prompt,
+ &request,
+ contract_id,
+ auth.owner_id,
+ )
+ .await
+}
+
+fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String {
+ let c = &contract.contract;
+ let mut context = format!(
+ "Name: {}\nID: {}\nPhase: {}\nStatus: {}\n",
+ c.name, c.id, c.phase, c.status
+ );
+
+ if let Some(ref desc) = c.description {
+ context.push_str(&format!("Description: {}\n", desc));
+ }
+
+ // Build phase checklist
+ let file_infos: Vec<FileInfo> = contract.files.iter().map(|f| FileInfo {
+ id: f.id,
+ name: f.name.clone(),
+ contract_phase: f.contract_phase.clone(),
+ }).collect();
+
+ let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo {
+ id: t.id,
+ name: t.name.clone(),
+ status: t.status.clone(),
+ }).collect();
+
+ let has_repository = !contract.repositories.is_empty();
+ let phase_checklist = get_phase_checklist(&c.phase, &file_infos, &task_infos, has_repository);
+
+ // Add phase checklist to context
+ context.push_str("\n");
+ context.push_str(&format_checklist_markdown(&phase_checklist));
+
+ // Files summary
+ context.push_str(&format!("\n### Files ({} total)\n", contract.files.len()));
+ if !contract.files.is_empty() {
+ for file in contract.files.iter().take(5) {
+ let phase_label = file.contract_phase.as_deref().unwrap_or("none");
+ context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id));
+ }
+ if contract.files.len() > 5 {
+ context.push_str(&format!("... and {} more\n", contract.files.len() - 5));
+ }
+ }
+
+ // Tasks summary
+ context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len()));
+ if !contract.tasks.is_empty() {
+ let pending = contract.tasks.iter().filter(|t| t.status == "pending").count();
+ let running = contract.tasks.iter().filter(|t| t.status == "running").count();
+ let done = contract.tasks.iter().filter(|t| t.status == "done").count();
+ context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done));
+ for task in contract.tasks.iter().take(5) {
+ context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id));
+ }
+ if contract.tasks.len() > 5 {
+ context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5));
+ }
+ }
+
+ // Repositories summary
+ context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len()));
+ if !contract.repositories.is_empty() {
+ for repo in &contract.repositories {
+ let primary = if repo.is_primary { " (primary)" } else { "" };
+ let url_or_path = repo.repository_url.as_deref()
+ .or(repo.local_path.as_deref())
+ .unwrap_or("managed");
+ context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary));
+ }
+ }
+
+ context
+}
+
+/// Summarize older conversation history to reduce token usage
+async fn summarize_conversation_history(
+ llm_client: &LlmClient,
+ messages: &[&crate::db::models::ContractChatMessageRecord],
+) -> String {
+ // Build conversation text for summarization
+ let mut conversation_text = String::new();
+ for msg in messages {
+ let role_label = if msg.role == "user" { "User" } else { "Assistant" };
+ // Limit each message to avoid overwhelming the summarizer
+ let content = if msg.content.len() > 500 {
+ format!("{}...", &msg.content[..500])
+ } else {
+ msg.content.clone()
+ };
+ conversation_text.push_str(&format!("{}: {}\n", role_label, content));
+ }
+
+ // Limit total text to summarize
+ if conversation_text.len() > 8000 {
+ conversation_text = format!("{}...", &conversation_text[..8000]);
+ }
+
+ let summary_prompt = format!(
+ "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}",
+ conversation_text
+ );
+
+ // Use a simple chat call without tools for summarization
+ let summary = match llm_client {
+ LlmClient::Claude(client) => {
+ let claude_messages = vec![claude::Message {
+ role: "user".to_string(),
+ content: claude::MessageContent::Text(summary_prompt.clone()),
+ }];
+ match client.chat_with_tools(claude_messages, &[]).await {
+ Ok(response) => response.content.unwrap_or_default(),
+ Err(e) => {
+ tracing::warn!("Failed to summarize conversation: {}", e);
+ "Previous conversation covered contract management tasks.".to_string()
+ }
+ }
+ }
+ LlmClient::Groq(client) => {
+ let groq_messages = vec![Message {
+ role: "user".to_string(),
+ content: Some(summary_prompt.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ }];
+ match client.chat_with_tools(groq_messages, &[]).await {
+ Ok(response) => response.content.unwrap_or_default(),
+ Err(e) => {
+ tracing::warn!("Failed to summarize conversation: {}", e);
+ "Previous conversation covered contract management tasks.".to_string()
+ }
+ }
+ }
+ };
+
+ // Limit summary length
+ if summary.len() > 500 {
+ format!("{}...", &summary[..500])
+ } else {
+ summary
+ }
+}
+
+/// Run the agentic loop for contract chat
+async fn run_contract_agentic_loop(
+ pool: &sqlx::PgPool,
+ state: &SharedState,
+ llm_client: &LlmClient,
+ system_prompt: String,
+ request: &ContractChatRequest,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> axum::response::Response {
+ // Get or create the conversation for persistent history
+ let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await {
+ Ok(conv) => conv,
+ Err(e) => {
+ tracing::error!("Failed to get/create contract conversation: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ // Load ALL existing messages from database
+ let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await {
+ Ok(msgs) => msgs,
+ Err(e) => {
+ tracing::warn!("Failed to load contract chat history: {}", e);
+ Vec::new()
+ }
+ };
+
+ // Build initial messages
+ let mut messages = vec![Message {
+ role: "system".to_string(),
+ content: Some(system_prompt),
+ tool_calls: None,
+ tool_call_id: None,
+ }];
+
+ // Add saved conversation history, summarizing older messages if needed
+ // to stay under rate limits (~25k chars ≈ ~6k tokens for history)
+ const MAX_HISTORY_CHARS: usize = 25000;
+ const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact
+
+ // Filter to user/assistant messages only
+ let history_messages: Vec<_> = saved_messages
+ .iter()
+ .filter(|m| m.role == "user" || m.role == "assistant")
+ .collect();
+
+ // Calculate total character count
+ let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum();
+
+ if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP {
+ // Need to summarize older messages
+ let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP);
+ let older_messages = &history_messages[..split_point];
+ let recent_messages = &history_messages[split_point..];
+
+ // Generate summary of older conversation
+ let summary = summarize_conversation_history(&llm_client, older_messages).await;
+
+ // Add summary as context
+ messages.push(Message {
+ role: "user".to_string(),
+ content: Some(format!("[Previous conversation summary: {}]", summary)),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ messages.push(Message {
+ role: "assistant".to_string(),
+ content: Some("I understand the previous context. Let's continue.".to_string()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+
+ // Add recent messages in full
+ for saved_msg in recent_messages {
+ messages.push(Message {
+ role: saved_msg.role.clone(),
+ content: Some(saved_msg.content.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ }
+
+ tracing::info!(
+ total_messages = history_messages.len(),
+ summarized = older_messages.len(),
+ kept_recent = recent_messages.len(),
+ "Summarized older conversation history"
+ );
+ } else {
+ // Add all messages directly
+ for saved_msg in history_messages {
+ messages.push(Message {
+ role: saved_msg.role.clone(),
+ content: Some(saved_msg.content.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ }
+ }
+
+ // Add current user message
+ messages.push(Message {
+ role: "user".to_string(),
+ content: Some(request.message.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+
+ // Save the user message to database
+ if let Err(e) = repository::add_contract_chat_message(
+ pool,
+ conversation.id,
+ "user",
+ &request.message,
+ None,
+ None,
+ ).await {
+ tracing::warn!("Failed to save user message to contract chat history: {}", e);
+ }
+
+ // State for tracking
+ let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new();
+ let mut final_response: Option<String> = None;
+ let mut consecutive_failures = 0;
+ const MAX_CONSECUTIVE_FAILURES: usize = 3;
+ let mut pending_questions: Option<Vec<UserQuestion>> = None;
+
+ // Multi-turn agentic tool calling loop
+ for round in 0..MAX_TOOL_ROUNDS {
+ tracing::info!(
+ round = round,
+ total_tool_calls = all_tool_call_infos.len(),
+ "Contract agentic loop iteration"
+ );
+
+ // Check consecutive failures
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
+ tracing::warn!(
+ "Breaking contract loop due to {} consecutive failures",
+ consecutive_failures
+ );
+ final_response = Some(
+ "I encountered multiple consecutive errors and stopped. \
+ Please check the contract state and try again."
+ .to_string(),
+ );
+ break;
+ }
+
+ // Call the appropriate LLM API
+ let result = match llm_client {
+ LlmClient::Groq(groq) => {
+ match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await {
+ Ok(r) => LlmResult {
+ content: r.content,
+ tool_calls: r.tool_calls,
+ raw_tool_calls: r.raw_tool_calls,
+ finish_reason: r.finish_reason,
+ },
+ Err(e) => {
+ tracing::error!("Groq API error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("LLM API error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+ }
+ LlmClient::Claude(claude_client) => {
+ let claude_messages = claude::groq_messages_to_claude(&messages);
+ match claude_client
+ .chat_with_tools(claude_messages, &CONTRACT_TOOLS)
+ .await
+ {
+ Ok(r) => {
+ let raw_tool_calls: Vec<ToolCallResponse> = r
+ .tool_calls
+ .iter()
+ .map(|tc| ToolCallResponse {
+ id: tc.id.clone(),
+ call_type: "function".to_string(),
+ function: crate::llm::groq::FunctionCall {
+ name: tc.name.clone(),
+ arguments: tc.arguments.to_string(),
+ },
+ })
+ .collect();
+
+ LlmResult {
+ content: r.content,
+ tool_calls: r.tool_calls,
+ raw_tool_calls,
+ finish_reason: r.stop_reason,
+ }
+ }
+ Err(e) => {
+ tracing::error!("Claude API error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("LLM API error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Check if there are tool calls to execute
+ if result.tool_calls.is_empty() {
+ final_response = result.content;
+ break;
+ }
+
+ // Add assistant message with tool calls to conversation
+ messages.push(Message {
+ role: "assistant".to_string(),
+ content: result.content.clone(),
+ tool_calls: Some(result.raw_tool_calls.clone()),
+ tool_call_id: None,
+ });
+
+ // Execute each tool call
+ for (i, tool_call) in result.tool_calls.iter().enumerate() {
+ tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call");
+
+ // Parse the tool call
+ let mut execution_result = parse_contract_tool_call(tool_call);
+
+ // Handle async contract tool requests
+ if let Some(contract_request) = execution_result.request.take() {
+ let async_result =
+ handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await;
+ execution_result.success = async_result.success;
+ execution_result.message = async_result.message;
+ execution_result.data = async_result.data;
+ }
+
+ // Track consecutive failures
+ if execution_result.success {
+ consecutive_failures = 0;
+ } else {
+ consecutive_failures += 1;
+ tracing::warn!(
+ tool = %tool_call.name,
+ consecutive_failures = consecutive_failures,
+ "Contract tool call failed"
+ );
+ }
+
+ // Check for pending user questions
+ if let Some(questions) = execution_result.pending_questions {
+ tracing::info!(
+ question_count = questions.len(),
+ "Contract LLM requesting user input"
+ );
+ pending_questions = Some(questions);
+ all_tool_call_infos.push(ContractToolCallInfo {
+ name: tool_call.name.clone(),
+ result: ToolResult {
+ success: execution_result.success,
+ message: execution_result.message.clone(),
+ },
+ });
+ break;
+ }
+
+ // Build tool result message
+ let result_content = if let Some(data) = &execution_result.data {
+ json!({
+ "success": execution_result.success,
+ "message": execution_result.message,
+ "data": data
+ })
+ .to_string()
+ } else {
+ json!({
+ "success": execution_result.success,
+ "message": execution_result.message
+ })
+ .to_string()
+ };
+
+ // Add tool result message
+ let tool_call_id = match llm_client {
+ LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
+ LlmClient::Claude(_) => tool_call.id.clone(),
+ };
+
+ messages.push(Message {
+ role: "tool".to_string(),
+ content: Some(result_content),
+ tool_calls: None,
+ tool_call_id: Some(tool_call_id),
+ });
+
+ // Track for response
+ all_tool_call_infos.push(ContractToolCallInfo {
+ name: tool_call.name.clone(),
+ result: ToolResult {
+ success: execution_result.success,
+ message: execution_result.message,
+ },
+ });
+ }
+
+ // If user questions are pending, pause
+ if pending_questions.is_some() {
+ final_response = result.content;
+ break;
+ }
+
+ // If finish reason indicates completion, exit loop
+ let finish_lower = result.finish_reason.to_lowercase();
+ if finish_lower == "stop" || finish_lower == "end_turn" {
+ final_response = result.content;
+ break;
+ }
+ }
+
+ // Build response
+ let response_text = final_response.unwrap_or_else(|| {
+ if all_tool_call_infos.is_empty() {
+ "I couldn't understand your request. Please try rephrasing.".to_string()
+ } else {
+ format!(
+ "Done! Executed {} tool{}.",
+ all_tool_call_infos.len(),
+ if all_tool_call_infos.len() == 1 { "" } else { "s" }
+ )
+ }
+ });
+
+ // Save assistant response to database
+ let tool_calls_json = if all_tool_call_infos.is_empty() {
+ None
+ } else {
+ serde_json::to_value(&all_tool_call_infos).ok()
+ };
+
+ let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok());
+
+ if let Err(e) = repository::add_contract_chat_message(
+ pool,
+ conversation.id,
+ "assistant",
+ &response_text,
+ tool_calls_json,
+ pending_questions_json,
+ ).await {
+ tracing::warn!("Failed to save assistant response to contract chat history: {}", e);
+ }
+
+ (
+ StatusCode::OK,
+ Json(ContractChatResponse {
+ response: response_text,
+ tool_calls: all_tool_call_infos,
+ pending_questions,
+ }),
+ )
+ .into_response()
+}
+
+/// Result from handling an async contract tool request
+struct ContractRequestResult {
+ success: bool,
+ message: String,
+ data: Option<serde_json::Value>,
+}
+
+/// Handle async contract tool requests that require database access
+async fn handle_contract_request(
+ pool: &sqlx::PgPool,
+ daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>,
+ request: ContractToolRequest,
+ contract_id: Uuid,
+ owner_id: Uuid,
+) -> ContractRequestResult {
+ match request {
+ ContractToolRequest::ListDaemonDirectories => {
+ let mut directories = Vec::new();
+
+ // Iterate over connected daemons belonging to this owner
+ for entry in daemon_connections.iter() {
+ let daemon = entry.value();
+
+ // Only include daemons belonging to this owner
+ if daemon.owner_id != owner_id {
+ continue;
+ }
+
+ // Add working directory if available
+ if let Some(ref working_dir) = daemon.working_directory {
+ directories.push(json!({
+ "path": working_dir,
+ "label": "Working Directory",
+ "type": "working",
+ "hostname": daemon.hostname,
+ }));
+ }
+
+ // Add home directory if available
+ if let Some(ref home_dir) = daemon.home_directory {
+ directories.push(json!({
+ "path": home_dir,
+ "label": "Makima Home",
+ "type": "home",
+ "hostname": daemon.hostname,
+ }));
+ }
+ }
+
+ if directories.is_empty() {
+ ContractRequestResult {
+ success: true,
+ message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(),
+ data: Some(json!({ "directories": [] })),
+ }
+ } else {
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} suggested directories from connected daemons", directories.len()),
+ data: Some(json!({ "directories": directories })),
+ }
+ }
+ }
+
+ ContractToolRequest::GetContractStatus => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let c = &cwr.contract;
+ ContractRequestResult {
+ success: true,
+ message: format!(
+ "Contract '{}' is in '{}' phase with status '{}'",
+ c.name, c.phase, c.status
+ ),
+ data: Some(json!({
+ "name": c.name,
+ "phase": c.phase,
+ "status": c.status,
+ "description": c.description,
+ "fileCount": cwr.files.len(),
+ "taskCount": cwr.tasks.len(),
+ "repositoryCount": cwr.repositories.len(),
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::ListContractFiles => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let files: Vec<serde_json::Value> = cwr
+ .files
+ .iter()
+ .map(|f| {
+ json!({
+ "fileId": f.id,
+ "name": f.name,
+ "description": f.description,
+ "phase": f.contract_phase,
+ })
+ })
+ .collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} files", files.len()),
+ data: Some(json!({ "files": files })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::ListContractTasks => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let tasks: Vec<serde_json::Value> = cwr
+ .tasks
+ .iter()
+ .map(|t| {
+ json!({
+ "taskId": t.id,
+ "name": t.name,
+ "status": t.status,
+ "priority": t.priority,
+ })
+ })
+ .collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} tasks", tasks.len()),
+ data: Some(json!({ "tasks": tasks })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::ListContractRepositories => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let repos: Vec<serde_json::Value> = cwr
+ .repositories
+ .iter()
+ .map(|r| {
+ json!({
+ "repositoryId": r.id,
+ "name": r.name,
+ "repositoryUrl": r.repository_url,
+ "localPath": r.local_path,
+ "isPrimary": r.is_primary,
+ })
+ })
+ .collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} repositories", repos.len()),
+ data: Some(json!({ "repositories": repos })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::ReadFile { file_id } => {
+ match repository::get_file_for_owner(pool, file_id, owner_id).await {
+ Ok(Some(file)) => {
+ // Verify file belongs to this contract
+ if file.contract_id != Some(contract_id) {
+ return ContractRequestResult {
+ success: false,
+ message: "File does not belong to this contract".to_string(),
+ data: None,
+ };
+ }
+
+ // Convert body to markdown for LLM consumption
+ let markdown = body_to_markdown(&file.body);
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Read file '{}'", file.name),
+ data: Some(json!({
+ "fileId": file.id,
+ "name": file.name,
+ "description": file.description,
+ "summary": file.summary,
+ "plainText": markdown,
+ "phase": file.contract_phase,
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "File not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::CreateFileFromTemplate {
+ template_id,
+ name,
+ description,
+ } => {
+ // Find the template
+ let templates = all_templates();
+ let template = templates.iter().find(|t| t.id == template_id);
+
+ let Some(template) = template else {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Template '{}' not found", template_id),
+ data: None,
+ };
+ };
+
+ // Verify contract exists and get current phase
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Use template's phase if available, otherwise use contract's current phase
+ let contract_phase = Some(template.phase.clone()).or(Some(contract.phase.clone()));
+
+ // Create the file (contract_id is now required)
+ let create_req = crate::db::models::CreateFileRequest {
+ contract_id,
+ name: Some(name.clone()),
+ description,
+ body: template.suggested_body.clone(),
+ transcript: Vec::new(),
+ location: None,
+ repo_file_path: None,
+ contract_phase,
+ };
+
+ match repository::create_file_for_owner(pool, owner_id, create_req).await {
+ Ok(file) => ContractRequestResult {
+ success: true,
+ message: format!(
+ "Created file '{}' from template '{}'",
+ name, template.name
+ ),
+ data: Some(json!({
+ "fileId": file.id,
+ "name": file.name,
+ "templateId": template_id,
+ })),
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to create file: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::CreateEmptyFile { name, description } => {
+ // Verify contract exists and get current phase
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Create the file with current contract phase
+ let create_req = crate::db::models::CreateFileRequest {
+ contract_id,
+ name: Some(name.clone()),
+ description,
+ body: Vec::new(),
+ transcript: Vec::new(),
+ location: None,
+ repo_file_path: None,
+ contract_phase: Some(contract.phase.clone()),
+ };
+
+ match repository::create_file_for_owner(pool, owner_id, create_req).await {
+ Ok(file) => ContractRequestResult {
+ success: true,
+ message: format!("Created empty file '{}'", name),
+ data: Some(json!({
+ "fileId": file.id,
+ "name": file.name,
+ })),
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to create file: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::ListAvailableTemplates { phase } => {
+ let templates = if let Some(p) = phase {
+ templates_for_phase(&p)
+ } else {
+ all_templates()
+ };
+
+ let template_data: Vec<serde_json::Value> = templates
+ .iter()
+ .map(|t| {
+ json!({
+ "id": t.id,
+ "name": t.name,
+ "phase": t.phase,
+ "description": t.description,
+ })
+ })
+ .collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} templates", templates.len()),
+ data: Some(json!({ "templates": template_data })),
+ }
+ }
+
+ ContractToolRequest::CreateContractTask {
+ name,
+ plan,
+ repository_url,
+ base_branch,
+ } => {
+ // Get primary repository if not specified
+ let repo_url = if repository_url.is_some() {
+ repository_url
+ } else {
+ // Find primary repository
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(contract)) => {
+ contract
+ .repositories
+ .iter()
+ .find(|r| r.is_primary)
+ .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
+ }
+ _ => None,
+ }
+ };
+
+ let create_req = CreateTaskRequest {
+ contract_id,
+ name: name.clone(),
+ description: None,
+ plan,
+ parent_task_id: None,
+ repository_url: repo_url,
+ base_branch,
+ target_branch: None,
+ merge_mode: None,
+ priority: 0,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ is_supervisor: false,
+ checkpoint_sha: None,
+ };
+
+ match repository::create_task_for_owner(pool, owner_id, create_req).await {
+ Ok(task) => ContractRequestResult {
+ success: true,
+ message: format!("Created task '{}' in contract", name),
+ data: Some(json!({
+ "taskId": task.id,
+ "name": task.name,
+ "status": task.status,
+ })),
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to create task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::DelegateContentGeneration {
+ file_id,
+ instruction,
+ context,
+ } => {
+ // Build a task plan that includes the content generation instruction
+ let mut plan = format!(
+ "Content Generation Task\n\n\
+ ## Instruction\n{}\n\n",
+ instruction
+ );
+
+ if let Some(ctx) = context {
+ plan.push_str(&format!("## Context\n{}\n\n", ctx));
+ }
+
+ // If file_id is provided, get file details and include them
+ let (file_name, file_info) = if let Some(fid) = file_id {
+ match repository::get_file_for_owner(pool, fid, owner_id).await {
+ Ok(Some(file)) => {
+ let info = format!(
+ "## Target File\n\
+ - File ID: {}\n\
+ - Name: {}\n\
+ - Description: {}\n\n\
+ The generated content should be structured to update this file.\n",
+ fid,
+ file.name,
+ file.description.as_deref().unwrap_or("(no description)")
+ );
+ (Some(file.name.clone()), Some(info))
+ }
+ _ => (None, None),
+ }
+ } else {
+ (None, None)
+ };
+
+ if let Some(info) = file_info {
+ plan.push_str(&info);
+ }
+
+ // Get primary repository
+ let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(contract)) => contract
+ .repositories
+ .iter()
+ .find(|r| r.is_primary)
+ .and_then(|r| r.repository_url.clone().or(r.local_path.clone())),
+ _ => None,
+ };
+
+ let task_name = format!(
+ "Generate content{}",
+ file_name.map(|n| format!(": {}", n)).unwrap_or_default()
+ );
+
+ let create_req = CreateTaskRequest {
+ contract_id,
+ name: task_name.clone(),
+ description: Some(instruction.clone()),
+ plan,
+ parent_task_id: None,
+ repository_url: repo_url,
+ base_branch: None,
+ target_branch: None,
+ merge_mode: None,
+ priority: 0,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ is_supervisor: false,
+ checkpoint_sha: None,
+ };
+
+ match repository::create_task_for_owner(pool, owner_id, create_req).await {
+ Ok(task) => ContractRequestResult {
+ success: true,
+ message: format!(
+ "Created content generation task '{}'. Start the task to generate the content.",
+ task_name
+ ),
+ data: Some(json!({
+ "taskId": task.id,
+ "name": task.name,
+ "status": task.status,
+ "targetFileId": file_id,
+ })),
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to create content generation task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::StartTask { task_id } => {
+ // Get the task
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Failed to get task: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Check if task can be started
+ let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"];
+ if !startable_statuses.contains(&task.status.as_str()) {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Task cannot be started from status: {}", task.status),
+ data: None,
+ };
+ }
+
+ // Find a connected daemon for this owner
+ let daemon_entry = daemon_connections
+ .iter()
+ .find(|d| d.value().owner_id == owner_id);
+
+ let (target_daemon_id, command_sender) = match daemon_entry {
+ Some(entry) => {
+ let daemon = entry.value();
+ (daemon.id, daemon.command_sender.clone())
+ }
+ None => {
+ return ContractRequestResult {
+ success: false,
+ message: "No daemon connected. Start a daemon to run tasks.".to_string(),
+ data: None,
+ };
+ }
+ };
+
+ // Check if this is an orchestrator
+ let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await {
+ Ok(subtasks) => subtasks.len(),
+ Err(_) => 0,
+ };
+ let is_orchestrator = task.depth == 0 && subtask_count > 0;
+
+ // Update task status to 'starting' and assign daemon_id
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("starting".to_string()),
+ daemon_id: Some(target_daemon_id),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ };
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Failed to update task: {}", e),
+ data: None,
+ };
+ }
+ };
+
+ // Send SpawnTask command to daemon
+ let command = DaemonCommand::SpawnTask {
+ task_id,
+ task_name: task.name.clone(),
+ plan: task.plan.clone(),
+ repo_url: task.repository_url.clone(),
+ base_branch: task.base_branch.clone(),
+ target_branch: task.target_branch.clone(),
+ parent_task_id: task.parent_task_id,
+ depth: task.depth,
+ is_orchestrator,
+ target_repo_path: task.target_repo_path.clone(),
+ completion_action: task.completion_action.clone(),
+ continue_from_task_id: task.continue_from_task_id,
+ copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ contract_id: task.contract_id,
+ is_supervisor: task.is_supervisor,
+ };
+
+ if let Err(e) = command_sender.send(command).await {
+ // Rollback: reset status since command failed
+ let rollback_req = crate::db::models::UpdateTaskRequest {
+ status: Some("pending".to_string()),
+ clear_daemon_id: true,
+ ..Default::default()
+ };
+ let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await;
+ return ContractRequestResult {
+ success: false,
+ message: format!("Failed to send task to daemon: {}", e),
+ data: None,
+ };
+ }
+
+ // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status
+ ContractRequestResult {
+ success: true,
+ message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name),
+ data: Some(json!({
+ "taskId": task_id,
+ "name": task.name,
+ "status": "starting",
+ })),
+ }
+ }
+
+ ContractToolRequest::GetPhaseInfo => {
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ let phase_info = get_phase_description(&contract.phase);
+ let templates = templates_for_phase(&contract.phase);
+ let template_names: Vec<String> = templates.iter().map(|t| t.name.clone()).collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Contract is in '{}' phase", contract.phase),
+ data: Some(json!({
+ "phase": contract.phase,
+ "description": phase_info.0,
+ "activities": phase_info.1,
+ "suggestedTemplates": template_names,
+ "nextPhase": get_next_phase(&contract.phase),
+ })),
+ }
+ }
+
+ ContractToolRequest::SuggestPhaseTransition => {
+ let contract = match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ let analysis = analyze_phase_readiness(&contract);
+
+ ContractRequestResult {
+ success: true,
+ message: analysis.summary.clone(),
+ data: Some(json!({
+ "currentPhase": contract.contract.phase,
+ "nextPhase": get_next_phase(&contract.contract.phase),
+ "ready": analysis.ready,
+ "summary": analysis.summary,
+ "reasons": analysis.reasons,
+ "suggestions": analysis.suggestions,
+ })),
+ }
+ }
+
+ ContractToolRequest::AdvancePhase { new_phase } => {
+ let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Validate phase transition
+ let current_phase = &contract.phase;
+ let valid_next = get_next_phase(current_phase);
+
+ if valid_next.as_deref() != Some(&new_phase) {
+ return ContractRequestResult {
+ success: false,
+ message: format!(
+ "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}",
+ current_phase, new_phase, valid_next
+ ),
+ data: None,
+ };
+ }
+
+ // Update phase
+ match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await {
+ Ok(Some(updated)) => {
+ // Get deliverables for the new phase
+ let deliverables = crate::llm::get_phase_deliverables(&new_phase);
+
+ // Build suggested files list
+ let suggested_files: Vec<serde_json::Value> = deliverables
+ .recommended_files
+ .iter()
+ .map(|f| json!({
+ "templateId": f.template_id,
+ "name": f.name_suggestion,
+ "priority": format!("{:?}", f.priority).to_lowercase(),
+ "description": f.description,
+ }))
+ .collect();
+
+ ContractRequestResult {
+ success: true,
+ message: format!(
+ "Advanced contract from '{}' to '{}' phase. {}",
+ current_phase, new_phase, deliverables.guidance
+ ),
+ data: Some(json!({
+ "previousPhase": current_phase,
+ "newPhase": updated.phase,
+ "phaseGuidance": deliverables.guidance,
+ "suggestedFiles": suggested_files,
+ "requiresRepository": deliverables.requires_repository,
+ "requiresTasks": deliverables.requires_tasks,
+ })),
+ }
+ },
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Failed to update phase".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to update phase: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::AddRepository {
+ repo_type,
+ name,
+ url,
+ is_primary,
+ } => {
+ let add_result = match repo_type.as_str() {
+ "remote" => {
+ let url = url.unwrap_or_default();
+ repository::add_remote_repository(
+ pool,
+ contract_id,
+ &name,
+ &url,
+ is_primary,
+ )
+ .await
+ }
+ "local" => {
+ let path = url.unwrap_or_default();
+ repository::add_local_repository(
+ pool,
+ contract_id,
+ &name,
+ &path,
+ is_primary,
+ )
+ .await
+ }
+ "managed" => {
+ repository::create_managed_repository(pool, contract_id, &name, is_primary)
+ .await
+ }
+ _ => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Invalid repository type: {}", repo_type),
+ data: None,
+ }
+ }
+ };
+
+ match add_result {
+ Ok(repo) => ContractRequestResult {
+ success: true,
+ message: format!("Added {} repository '{}'", repo_type, name),
+ data: Some(json!({
+ "repositoryId": repo.id,
+ "name": repo.name,
+ "isPrimary": repo.is_primary,
+ })),
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to add repository: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::SetPrimaryRepository { repository_id } => {
+ match repository::set_repository_primary(pool, repository_id, contract_id).await {
+ Ok(true) => ContractRequestResult {
+ success: true,
+ message: "Set repository as primary".to_string(),
+ data: Some(json!({
+ "repositoryId": repository_id,
+ })),
+ },
+ Ok(false) => ContractRequestResult {
+ success: false,
+ message: "Repository not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Failed to set primary repository: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ // =============================================================================
+ // Phase Guidance Handlers
+ // =============================================================================
+
+ ContractToolRequest::GetPhaseChecklist => {
+ match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(cwr)) => {
+ let file_infos: Vec<FileInfo> = cwr.files.iter().map(|f| FileInfo {
+ id: f.id,
+ name: f.name.clone(),
+ contract_phase: f.contract_phase.clone(),
+ }).collect();
+
+ let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
+ id: t.id,
+ name: t.name.clone(),
+ status: t.status.clone(),
+ }).collect();
+
+ let has_repository = !cwr.repositories.is_empty();
+ let checklist = get_phase_checklist(&cwr.contract.phase, &file_infos, &task_infos, has_repository);
+
+ ContractRequestResult {
+ success: true,
+ message: checklist.summary.clone(),
+ data: Some(json!({
+ "phase": checklist.phase,
+ "completionPercentage": checklist.completion_percentage,
+ "deliverables": checklist.file_deliverables,
+ "hasRepository": checklist.has_repository,
+ "repositoryRequired": checklist.repository_required,
+ "taskStats": checklist.task_stats,
+ "suggestions": checklist.suggestions,
+ "summary": checklist.summary,
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Contract not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ // =============================================================================
+ // Task Derivation Handlers
+ // =============================================================================
+
+ ContractToolRequest::DeriveTasksFromFile { file_id } => {
+ // First get the file
+ match repository::get_file_for_owner(pool, file_id, owner_id).await {
+ Ok(Some(file)) => {
+ // Verify file belongs to this contract
+ if file.contract_id != Some(contract_id) {
+ return ContractRequestResult {
+ success: false,
+ message: "File does not belong to this contract".to_string(),
+ data: None,
+ };
+ }
+
+ // Convert body to markdown for task parsing
+ let markdown = body_to_markdown(&file.body);
+
+ // Parse tasks from the content
+ let parse_result = parse_tasks_from_breakdown(&markdown);
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Found {} tasks in file '{}'", parse_result.total, file.name),
+ data: Some(json!({
+ "fileId": file_id,
+ "fileName": file.name,
+ "tasks": parse_result.tasks,
+ "groups": parse_result.groups,
+ "total": parse_result.total,
+ "warnings": parse_result.warnings,
+ "formatted": format_parsed_tasks(&parse_result),
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "File not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::CreateChainedTasks { tasks } => {
+ // Get primary repository for tasks
+ let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
+ Ok(Some(contract)) => {
+ contract
+ .repositories
+ .iter()
+ .find(|r| r.is_primary)
+ .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
+ }
+ _ => None,
+ };
+
+ let mut created_tasks = Vec::new();
+ let mut previous_task_id: Option<Uuid> = None;
+
+ for task_def in &tasks {
+ let create_req = CreateTaskRequest {
+ contract_id,
+ name: task_def.name.clone(),
+ description: None,
+ plan: task_def.plan.clone(),
+ parent_task_id: None,
+ repository_url: repo_url.clone(),
+ base_branch: None,
+ target_branch: None,
+ merge_mode: None,
+ priority: 0,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: previous_task_id,
+ copy_files: None,
+ is_supervisor: false,
+ checkpoint_sha: None,
+ };
+
+ match repository::create_task_for_owner(pool, owner_id, create_req).await {
+ Ok(task) => {
+ created_tasks.push(json!({
+ "taskId": task.id,
+ "name": task.name,
+ "status": task.status,
+ "chainedFrom": previous_task_id,
+ }));
+ previous_task_id = Some(task.id);
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Failed to create task '{}': {}", task_def.name, e),
+ data: Some(json!({
+ "createdSoFar": created_tasks,
+ })),
+ };
+ }
+ }
+ }
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Created {} chained tasks", created_tasks.len()),
+ data: Some(json!({
+ "tasks": created_tasks,
+ "total": created_tasks.len(),
+ })),
+ }
+ }
+
+ // =============================================================================
+ // Task Completion Processing Handlers
+ // =============================================================================
+
+ ContractToolRequest::ProcessTaskCompletion { task_id } => {
+ // Get the task
+ match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(task)) => {
+ // Verify task belongs to this contract
+ if task.contract_id != Some(contract_id) {
+ return ContractRequestResult {
+ success: false,
+ message: "Task does not belong to this contract".to_string(),
+ data: None,
+ };
+ }
+
+ // Get contract for context
+ let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten();
+
+ let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0);
+ let completed_tasks = contract.as_ref()
+ .map(|c| c.tasks.iter().filter(|t| t.status == "done").count())
+ .unwrap_or(0);
+
+ // Note: Finding next chained task would require querying full Task objects
+ // Since TaskSummary doesn't have continue_from_task_id, we skip this for now
+ let next_task: Option<(Uuid, String)> = None;
+
+ // Find Dev Notes file if exists
+ let dev_notes = if let Some(ref c) = contract {
+ c.files.iter()
+ .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes"))
+ .map(|f| (f.id, f.name.clone()))
+ } else {
+ None
+ };
+
+ let contract_phase = contract.as_ref()
+ .map(|c| c.contract.phase.clone())
+ .unwrap_or_else(|| "execute".to_string());
+
+ // Analyze the task output
+ let analysis = analyze_task_output(
+ task_id,
+ &task.name,
+ task.last_output.as_deref(),
+ task.progress_summary.as_deref(),
+ &contract_phase,
+ total_tasks,
+ completed_tasks,
+ next_task,
+ dev_notes,
+ );
+
+ ContractRequestResult {
+ success: true,
+ message: format!("Analyzed completion of task '{}'", task.name),
+ data: Some(json!({
+ "taskId": task_id,
+ "taskName": task.name,
+ "taskStatus": task.status,
+ "summary": analysis.summary,
+ "filesAffected": analysis.files_affected,
+ "nextSteps": analysis.next_steps,
+ "phaseImpact": analysis.phase_impact,
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => {
+ // Get the task
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ };
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ };
+ }
+ };
+
+ // Get the file
+ let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
+ Ok(Some(f)) => f,
+ Ok(None) => {
+ return ContractRequestResult {
+ success: false,
+ message: "File not found".to_string(),
+ data: None,
+ };
+ }
+ Err(e) => {
+ return ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ };
+ }
+ };
+
+ // Verify file belongs to this contract
+ if file.contract_id != Some(contract_id) {
+ return ContractRequestResult {
+ success: false,
+ message: "File does not belong to this contract".to_string(),
+ data: None,
+ };
+ }
+
+ // Build the section to add
+ let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name));
+ let result_text = task.last_output.as_deref().unwrap_or("Task completed");
+
+ // Create new body elements to append
+ let mut new_body = file.body.clone();
+ new_body.push(crate::db::models::BodyElement::Heading {
+ level: 2,
+ text: title,
+ });
+ new_body.push(crate::db::models::BodyElement::Paragraph {
+ text: format!("Status: {}", task.status),
+ });
+ new_body.push(crate::db::models::BodyElement::Paragraph {
+ text: result_text.to_string(),
+ });
+
+ // Update the file using UpdateFileRequest
+ let update_req = UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: None,
+ summary: None,
+ body: Some(new_body),
+ version: None, // Don't require version for this update
+ repo_file_path: None,
+ };
+
+ match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await {
+ Ok(Some(updated_file)) => {
+ ContractRequestResult {
+ success: true,
+ message: format!("Updated file '{}' with task summary", file.name),
+ data: Some(json!({
+ "fileId": file_id,
+ "fileName": updated_file.name,
+ "taskId": task_id,
+ "taskName": task.name,
+ })),
+ }
+ }
+ Ok(None) => ContractRequestResult {
+ success: false,
+ message: "Failed to update file".to_string(),
+ data: None,
+ },
+ Err(e) => ContractRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+ }
+}
+
+/// Get description and activities for a phase
+fn get_phase_description(phase: &str) -> (String, Vec<String>) {
+ match phase {
+ "research" => (
+ "Gather information, analyze competitors, and understand user needs".to_string(),
+ vec![
+ "Conduct user research".to_string(),
+ "Analyze competitors".to_string(),
+ "Document findings".to_string(),
+ "Identify opportunities".to_string(),
+ ],
+ ),
+ "specify" => (
+ "Define requirements, user stories, and acceptance criteria".to_string(),
+ vec![
+ "Write requirements".to_string(),
+ "Create user stories".to_string(),
+ "Define acceptance criteria".to_string(),
+ "Document constraints".to_string(),
+ ],
+ ),
+ "plan" => (
+ "Design architecture, create task breakdowns, and technical designs".to_string(),
+ vec![
+ "Design system architecture".to_string(),
+ "Create technical specifications".to_string(),
+ "Break down into tasks".to_string(),
+ "Plan implementation order".to_string(),
+ ],
+ ),
+ "execute" => (
+ "Implement features, write code, and run tasks".to_string(),
+ vec![
+ "Implement features".to_string(),
+ "Write tests".to_string(),
+ "Track progress".to_string(),
+ "Document implementation details".to_string(),
+ ],
+ ),
+ "review" => (
+ "Review work, create release notes, and conduct retrospectives".to_string(),
+ vec![
+ "Review code and features".to_string(),
+ "Create release notes".to_string(),
+ "Conduct retrospective".to_string(),
+ "Document learnings".to_string(),
+ ],
+ ),
+ _ => (
+ "Unknown phase".to_string(),
+ vec![],
+ ),
+ }
+}
+
+/// Get the next phase in the lifecycle
+fn get_next_phase(current: &str) -> Option<String> {
+ match current {
+ "research" => Some("specify".to_string()),
+ "specify" => Some("plan".to_string()),
+ "plan" => Some("execute".to_string()),
+ "execute" => Some("review".to_string()),
+ "review" => None, // Final phase
+ _ => None,
+ }
+}
+
+/// Phase readiness analysis result
+struct PhaseReadinessAnalysis {
+ ready: bool,
+ summary: String,
+ reasons: Vec<String>,
+ suggestions: Vec<String>,
+}
+
+/// Analyze if the contract is ready to transition to the next phase
+fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis {
+ let mut reasons = Vec::new();
+ let mut suggestions = Vec::new();
+
+ match contract.contract.phase.as_str() {
+ "research" => {
+ // Check for research files
+ let research_files = contract.files.iter()
+ .filter(|f| f.contract_phase.as_deref() == Some("research"))
+ .count();
+
+ if research_files == 0 {
+ reasons.push("No research documents created yet".to_string());
+ suggestions.push("Create research notes or competitor analysis documents".to_string());
+ } else {
+ reasons.push(format!("{} research document(s) created", research_files));
+ }
+
+ let ready = research_files > 0;
+ PhaseReadinessAnalysis {
+ ready,
+ summary: if ready {
+ "Research phase has documentation. Consider transitioning to Specify phase.".to_string()
+ } else {
+ "Research phase needs more documentation before transitioning.".to_string()
+ },
+ reasons,
+ suggestions,
+ }
+ }
+ "specify" => {
+ let spec_files = contract.files.iter()
+ .filter(|f| f.contract_phase.as_deref() == Some("specify"))
+ .count();
+
+ if spec_files == 0 {
+ reasons.push("No specification documents created yet".to_string());
+ suggestions.push("Create requirements or user stories documents".to_string());
+ } else {
+ reasons.push(format!("{} specification document(s) created", spec_files));
+ }
+
+ let ready = spec_files > 0;
+ PhaseReadinessAnalysis {
+ ready,
+ summary: if ready {
+ "Specification phase has documentation. Consider transitioning to Plan phase.".to_string()
+ } else {
+ "Specification phase needs requirements or user stories.".to_string()
+ },
+ reasons,
+ suggestions,
+ }
+ }
+ "plan" => {
+ let plan_files = contract.files.iter()
+ .filter(|f| f.contract_phase.as_deref() == Some("plan"))
+ .count();
+
+ let has_repos = !contract.repositories.is_empty();
+
+ if plan_files == 0 {
+ reasons.push("No planning documents created yet".to_string());
+ suggestions.push("Create architecture or task breakdown documents".to_string());
+ } else {
+ reasons.push(format!("{} planning document(s) created", plan_files));
+ }
+
+ if !has_repos {
+ reasons.push("No repositories configured".to_string());
+ suggestions.push("Add a repository for task execution".to_string());
+ } else {
+ reasons.push(format!("{} repository(ies) configured", contract.repositories.len()));
+ }
+
+ let ready = plan_files > 0 && has_repos;
+ PhaseReadinessAnalysis {
+ ready,
+ summary: if ready {
+ "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string()
+ } else {
+ "Planning phase needs documentation and/or repository configuration.".to_string()
+ },
+ reasons,
+ suggestions,
+ }
+ }
+ "execute" => {
+ let total_tasks = contract.tasks.len();
+ let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count();
+ let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count();
+
+ if total_tasks == 0 {
+ reasons.push("No tasks created yet".to_string());
+ suggestions.push("Create tasks to implement the planned work".to_string());
+ } else {
+ reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks));
+ }
+
+ if running_tasks > 0 {
+ reasons.push(format!("{} task(s) still running", running_tasks));
+ suggestions.push("Wait for running tasks to complete".to_string());
+ }
+
+ let ready = total_tasks > 0 && done_tasks == total_tasks;
+ PhaseReadinessAnalysis {
+ ready,
+ summary: if ready {
+ "All tasks completed. Ready for Review phase.".to_string()
+ } else if total_tasks == 0 {
+ "No tasks created yet. Create and complete tasks before reviewing.".to_string()
+ } else {
+ format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks)
+ },
+ reasons,
+ suggestions,
+ }
+ }
+ "review" => {
+ let review_files = contract.files.iter()
+ .filter(|f| f.contract_phase.as_deref() == Some("review"))
+ .count();
+
+ if review_files == 0 {
+ suggestions.push("Create review checklist or release notes".to_string());
+ }
+
+ PhaseReadinessAnalysis {
+ ready: false,
+ summary: "Review is the final phase. Contract can be marked as complete when review is done.".to_string(),
+ reasons: vec!["Review phase is the final phase".to_string()],
+ suggestions,
+ }
+ }
+ _ => PhaseReadinessAnalysis {
+ ready: false,
+ summary: "Unknown phase".to_string(),
+ reasons: vec!["Phase not recognized".to_string()],
+ suggestions: vec![],
+ },
+ }
+}
+
+// =============================================================================
+// Contract Chat History Endpoints
+// =============================================================================
+
+/// Get contract chat history
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/chat/history",
+ responses(
+ (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Contract not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn get_contract_chat_history(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(contract_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "Contract not found" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Database error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Database error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+
+ // Get or create conversation
+ let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await {
+ Ok(conv) => conv,
+ Err(e) => {
+ tracing::error!("Failed to get contract conversation: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Failed to get conversation: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ // Get messages
+ let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await {
+ Ok(msgs) => msgs,
+ Err(e) => {
+ tracing::error!("Failed to list contract chat messages: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Failed to list messages: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ (
+ StatusCode::OK,
+ Json(ContractChatHistoryResponse {
+ contract_id,
+ conversation_id: conversation.id,
+ messages,
+ }),
+ )
+ .into_response()
+}
+
+/// Clear contract chat history (creates a new conversation)
+#[utoipa::path(
+ delete,
+ path = "/api/v1/contracts/{id}/chat/history",
+ responses(
+ (status = 200, description = "Chat history cleared successfully"),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Contract not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn clear_contract_chat_history(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(contract_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "Contract not found" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Database error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Database error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+
+ // Clear conversation (archives existing and creates new)
+ match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await {
+ Ok(new_conversation) => {
+ (
+ StatusCode::OK,
+ Json(json!({
+ "message": "Chat history cleared",
+ "newConversationId": new_conversation.id
+ })),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to clear contract conversation: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Failed to clear history: {}", e) })),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs
new file mode 100644
index 0000000..13c5640
--- /dev/null
+++ b/makima/src/server/handlers/contract_daemon.rs
@@ -0,0 +1,960 @@
+//! HTTP handlers for daemon-to-contract interaction.
+//!
+//! These endpoints allow tasks running in daemons to interact with their
+//! associated contracts via the contract.sh script. Authentication is via
+//! tool keys registered by the daemon when starting a task.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::{models::FileSummary, repository};
+use crate::llm::phase_guidance::{self, FileInfo, PhaseChecklist, TaskInfo};
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Request/Response Types
+// =============================================================================
+
+/// Contract status response for daemon.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractStatusResponse {
+ pub id: Uuid,
+ pub name: String,
+ pub phase: String,
+ pub status: String,
+ pub description: Option<String>,
+}
+
+/// Contract goals response.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ContractGoalsResponse {
+ /// Description serves as goals for the contract
+ pub description: Option<String>,
+ pub phase: String,
+ pub phase_guidance: String,
+}
+
+/// Progress report request from daemon.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ProgressReportRequest {
+ pub message: String,
+ #[serde(default)]
+ pub task_id: Option<Uuid>,
+}
+
+/// Suggested action from server.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SuggestedActionResponse {
+ pub action: String,
+ pub description: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub data: Option<serde_json::Value>,
+}
+
+/// Completion action request.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CompletionActionRequest {
+ #[serde(default)]
+ pub task_id: Option<Uuid>,
+ #[serde(default)]
+ pub files_modified: Vec<String>,
+ #[serde(default)]
+ pub lines_added: i32,
+ #[serde(default)]
+ pub lines_removed: i32,
+ #[serde(default)]
+ pub has_code_changes: bool,
+}
+
+/// Recommended completion action.
+#[derive(Debug, Clone, Serialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum CompletionAction {
+ Branch,
+ Merge,
+ Pr,
+ None,
+}
+
+impl std::fmt::Display for CompletionAction {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ CompletionAction::Branch => write!(f, "branch"),
+ CompletionAction::Merge => write!(f, "merge"),
+ CompletionAction::Pr => write!(f, "pr"),
+ CompletionAction::None => write!(f, "none"),
+ }
+ }
+}
+
+/// Completion action response.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CompletionActionResponse {
+ pub action: String,
+ pub reason: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub branch_name: Option<String>,
+}
+
+/// Create file request from daemon.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateFileRequest {
+ pub name: String,
+ pub content: String,
+ #[serde(default)]
+ pub template_id: Option<String>,
+}
+
+/// Update file request from daemon.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonUpdateFileRequest {
+ /// Content to update in the file (as markdown body element)
+ pub content: String,
+}
+
+// =============================================================================
+// Handlers
+// =============================================================================
+
+/// Get contract status for daemon.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/daemon/status",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Contract status", body = ContractStatusResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_contract_status(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(contract)) => Json(ContractStatusResponse {
+ id: contract.id,
+ name: contract.name,
+ phase: contract.phase,
+ status: contract.status,
+ description: contract.description,
+ })
+ .into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get phase deliverables checklist.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/daemon/checklist",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Phase checklist", body = PhaseChecklist),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_contract_checklist(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get contract
+ let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Get files for this contract
+ let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await {
+ Ok(f) => f
+ .into_iter()
+ .map(|f| FileInfo {
+ id: f.id,
+ name: f.name,
+ contract_phase: f.contract_phase,
+ })
+ .collect::<Vec<_>>(),
+ Err(e) => {
+ tracing::warn!("Failed to get files for contract {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ // Get tasks for this contract
+ let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
+ Ok(t) => t
+ .into_iter()
+ .map(|t| TaskInfo {
+ id: t.id,
+ name: t.name,
+ status: t.status,
+ })
+ .collect::<Vec<_>>(),
+ Err(e) => {
+ tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ // Check if repository is configured
+ let has_repository = match repository::list_contract_repositories(pool, id).await {
+ Ok(repos) => !repos.is_empty(),
+ Err(_) => false,
+ };
+
+ let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository);
+
+ Json(checklist).into_response()
+}
+
+/// Get contract goals.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/daemon/goals",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Contract goals", body = ContractGoalsResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_contract_goals(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(contract)) => {
+ let deliverables = phase_guidance::get_phase_deliverables(&contract.phase);
+ Json(ContractGoalsResponse {
+ description: contract.description,
+ phase: contract.phase,
+ phase_guidance: deliverables.guidance,
+ })
+ .into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Post progress report to contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/daemon/report",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = ProgressReportRequest,
+ responses(
+ (status = 200, description = "Report received"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn post_progress_report(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<ProgressReportRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Log the report as a contract event
+ let event_type = "progress_report";
+ let payload = serde_json::json!({
+ "message": req.message,
+ "task_id": req.task_id,
+ });
+
+ if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await {
+ tracing::warn!("Failed to create contract event: {}", e);
+ }
+
+ Json(serde_json::json!({"status": "received"})).into_response()
+}
+
+/// Get suggested action based on contract state.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/daemon/suggest-action",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Suggested action", body = SuggestedActionResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_suggest_action(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get contract
+ let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Get files and tasks for checklist
+ let files = repository::list_files_in_contract(pool, id, auth.owner_id)
+ .await
+ .unwrap_or_default()
+ .into_iter()
+ .map(|f| FileInfo {
+ id: f.id,
+ name: f.name,
+ contract_phase: f.contract_phase,
+ })
+ .collect::<Vec<_>>();
+
+ let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id)
+ .await
+ .unwrap_or_default()
+ .into_iter()
+ .map(|t| TaskInfo {
+ id: t.id,
+ name: t.name,
+ status: t.status,
+ })
+ .collect::<Vec<_>>();
+
+ let has_repository = repository::list_contract_repositories(pool, id)
+ .await
+ .map(|r| !r.is_empty())
+ .unwrap_or(false);
+
+ let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository);
+
+ // Determine suggested action based on checklist
+ let (action, description) = if !checklist.suggestions.is_empty() {
+ ("follow_suggestion", checklist.suggestions.first().unwrap().clone())
+ } else if checklist.completion_percentage >= 100 {
+ ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase))
+ } else {
+ ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage))
+ };
+
+ Json(SuggestedActionResponse {
+ action: action.to_string(),
+ description,
+ data: None,
+ })
+ .into_response()
+}
+
+/// Get recommended completion action.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/daemon/completion-action",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = CompletionActionRequest,
+ responses(
+ (status = 200, description = "Recommended completion action", body = CompletionActionResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_completion_action(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CompletionActionRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get contract
+ let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Determine completion action based on phase and changes
+ let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0;
+ let has_significant_changes = req.lines_added + req.lines_removed > 50;
+
+ let (action, reason) = match contract.phase.as_str() {
+ "research" | "specify" => {
+ if has_changes {
+ (CompletionAction::Merge, "Early phase changes can be merged directly".to_string())
+ } else {
+ (CompletionAction::None, "No changes to commit".to_string())
+ }
+ }
+ "plan" => {
+ if has_significant_changes {
+ (CompletionAction::Pr, "Significant planning changes require review".to_string())
+ } else if has_changes {
+ (CompletionAction::Merge, "Minor planning changes can be merged".to_string())
+ } else {
+ (CompletionAction::None, "No changes to commit".to_string())
+ }
+ }
+ "execute" => {
+ if req.has_code_changes {
+ (CompletionAction::Pr, "Code changes in execute phase require review".to_string())
+ } else if has_changes {
+ (CompletionAction::Branch, "Documentation changes can be branched".to_string())
+ } else {
+ (CompletionAction::None, "No changes to commit".to_string())
+ }
+ }
+ "review" => {
+ if has_changes {
+ (CompletionAction::Pr, "Review phase changes should be reviewed".to_string())
+ } else {
+ (CompletionAction::None, "No changes to commit".to_string())
+ }
+ }
+ _ => (CompletionAction::None, "Unknown phase".to_string()),
+ };
+
+ // Generate branch name based on contract
+ let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) {
+ let slug = contract.name.to_lowercase().replace(' ', "-");
+ Some(format!("contract/{}", slug))
+ } else {
+ None
+ };
+
+ Json(CompletionActionResponse {
+ action: action.to_string(),
+ reason,
+ branch_name,
+ })
+ .into_response()
+}
+
+/// List contract files for daemon.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/daemon/files",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "List of contract files", body = Vec<FileSummary>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn list_contract_files(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_files_in_contract(pool, id, auth.owner_id).await {
+ Ok(files) => Json(files).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to list files for contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a specific contract file.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("file_id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 200, description = "File content"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or file not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn get_contract_file(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, file_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Get file and verify it belongs to this contract
+ match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
+ Ok(Some(file)) => {
+ if file.contract_id != Some(id) {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
+ )
+ .into_response();
+ }
+ Json(file).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get file {}: {}", file_id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update a contract file.
+#[utoipa::path(
+ put,
+ path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("file_id" = Uuid, Path, description = "File ID")
+ ),
+ request_body = DaemonUpdateFileRequest,
+ responses(
+ (status = 200, description = "File updated"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or file not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn update_contract_file(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, file_id)): Path<(Uuid, Uuid)>,
+ Json(req): Json<DaemonUpdateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Get file and verify it belongs to this contract
+ let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
+ Ok(Some(f)) => f,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get file {}: {}", file_id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ if file.contract_id != Some(id) {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
+ )
+ .into_response();
+ }
+
+ // Update the file with content parsed as markdown
+ let body = crate::llm::markdown_to_body(&req.content);
+ let update_req = crate::db::models::UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: None,
+ summary: None,
+ body: Some(body),
+ version: None,
+ repo_file_path: None,
+ };
+
+ match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await {
+ Ok(Some(updated)) => Json(updated).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update file {}: {}", file_id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", format!("{}", e))),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new contract file.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/daemon/files",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = CreateFileRequest,
+ responses(
+ (status = 201, description = "File created"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("tool_key" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contract Daemon"
+)]
+pub async fn create_contract_file(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Create the file with content parsed as markdown
+ let body = crate::llm::markdown_to_body(&req.content);
+ let create_req = crate::db::models::CreateFileRequest {
+ contract_id: id,
+ name: Some(req.name),
+ description: None,
+ transcript: vec![],
+ location: None,
+ body,
+ repo_file_path: None,
+ contract_phase: None, // Will be looked up from contract's current phase
+ };
+
+ match repository::create_file_for_owner(pool, auth.owner_id, create_req).await {
+ Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create file for contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
new file mode 100644
index 0000000..3d726df
--- /dev/null
+++ b/makima/src/server/handlers/contracts.rs
@@ -0,0 +1,1284 @@
+//! HTTP handlers for contract CRUD operations.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest,
+ ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
+ CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest,
+ UpdateTaskRequest,
+};
+use crate::db::repository::{self, RepositoryError};
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+/// Helper function to update the supervisor task with repository info when a primary repo is added.
+/// This ensures the supervisor has access to the repository when it starts.
+async fn update_supervisor_with_repo_if_needed(
+ pool: &sqlx::PgPool,
+ contract_id: uuid::Uuid,
+ owner_id: uuid::Uuid,
+ repo: &ContractRepository,
+) {
+ // Only update for primary repositories
+ if !repo.is_primary {
+ return;
+ }
+
+ // Get the supervisor task
+ let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await {
+ Ok(Some(s)) => s,
+ Ok(None) => {
+ tracing::debug!(contract_id = %contract_id, "No supervisor task found");
+ return;
+ }
+ Err(e) => {
+ tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task");
+ return;
+ }
+ };
+
+ // Only update if supervisor doesn't have a repository URL yet
+ if supervisor.repository_url.is_some() {
+ tracing::debug!(
+ supervisor_id = %supervisor.id,
+ "Supervisor already has repository URL"
+ );
+ return;
+ }
+
+ // Get repository URL (for remote repos) or local path (for local repos)
+ let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone());
+
+ if repo_url.is_none() && repo.source_type != "managed" {
+ tracing::debug!(
+ supervisor_id = %supervisor.id,
+ "Repository has no URL or path to assign"
+ );
+ return;
+ }
+
+ // Update supervisor task with repository info
+ let update_req = UpdateTaskRequest {
+ repository_url: repo_url,
+ version: Some(supervisor.version),
+ ..Default::default()
+ };
+
+ match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await {
+ Ok(Some(updated)) => {
+ tracing::info!(
+ supervisor_id = %updated.id,
+ repository_url = ?updated.repository_url,
+ "Updated supervisor task with repository URL"
+ );
+ }
+ Ok(None) => {
+ tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update");
+ }
+ Err(e) => {
+ tracing::warn!(
+ supervisor_id = %supervisor.id,
+ error = %e,
+ "Failed to update supervisor with repository URL"
+ );
+ }
+ }
+}
+
+/// List all root contracts (no parent) for the authenticated user's owner.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts",
+ responses(
+ (status = 200, description = "List of root contracts", body = ContractListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn list_contracts(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_contracts_for_owner(pool, auth.owner_id).await {
+ Ok(contracts) => {
+ let total = contracts.len() as i64;
+ Json(ContractListResponse { contracts, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list contracts: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a contract by ID with all its relations (repositories, files, tasks).
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Contract details with relations", body = ContractWithRelations),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn get_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the contract
+ let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Get repositories
+ let repositories = match repository::list_contract_repositories(pool, id).await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!("Failed to get repositories for {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ // Get files
+ let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await {
+ Ok(f) => f,
+ Err(e) => {
+ tracing::warn!("Failed to get files for contract {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ // Get tasks
+ let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
+ Ok(t) => t,
+ Err(e) => {
+ tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
+ Vec::new()
+ }
+ };
+
+ Json(ContractWithRelations {
+ contract,
+ repositories,
+ files,
+ tasks,
+ })
+ .into_response()
+}
+
+/// Create a new contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts",
+ request_body = CreateContractRequest,
+ responses(
+ (status = 201, description = "Contract created", body = ContractSummary),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn create_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateContractRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await {
+ Ok(contract) => {
+ // Create supervisor task for this contract
+ let supervisor_name = format!("{} Supervisor", contract.name);
+ let supervisor_plan = format!(
+ "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}",
+ contract.name,
+ contract.description.as_deref().unwrap_or("No description provided.")
+ );
+
+ // Get repository info from contract if available
+ let repo_url = {
+ // Try to get the first repository associated with this contract
+ match repository::list_contract_repositories(pool, contract.id).await {
+ Ok(repos) if !repos.is_empty() => {
+ let repo = &repos[0];
+ repo.repository_url.clone()
+ }
+ _ => None,
+ }
+ };
+
+ let supervisor_req = crate::db::models::CreateTaskRequest {
+ name: supervisor_name,
+ description: None,
+ plan: supervisor_plan,
+ repository_url: repo_url,
+ base_branch: None,
+ target_branch: None,
+ parent_task_id: None,
+ contract_id: contract.id,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ is_supervisor: true,
+ checkpoint_sha: None,
+ priority: 0,
+ merge_mode: None,
+ };
+
+ match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await {
+ Ok(supervisor_task) => {
+ tracing::info!(
+ contract_id = %contract.id,
+ supervisor_task_id = %supervisor_task.id,
+ is_supervisor = supervisor_task.is_supervisor,
+ "Created supervisor task for contract"
+ );
+
+ // Update contract with supervisor_task_id
+ let update_req = crate::db::models::UpdateContractRequest {
+ supervisor_task_id: Some(supervisor_task.id),
+ version: Some(contract.version),
+ ..Default::default()
+ };
+ if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await {
+ tracing::warn!(
+ contract_id = %contract.id,
+ error = %e,
+ "Failed to link supervisor task to contract"
+ );
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ contract_id = %contract.id,
+ error = %e,
+ "Failed to create supervisor task for contract"
+ );
+ }
+ }
+
+ // Get the summary version with counts
+ match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await
+ {
+ Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(),
+ Ok(None) => {
+ // Shouldn't happen, but return basic info if it does
+ (
+ StatusCode::CREATED,
+ Json(ContractSummary {
+ id: contract.id,
+ name: contract.name,
+ description: contract.description,
+ phase: contract.phase,
+ status: contract.status,
+ file_count: 0,
+ task_count: 0,
+ repository_count: 0,
+ version: contract.version,
+ created_at: contract.created_at,
+ }),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::warn!("Failed to get contract summary: {}", e);
+ (
+ StatusCode::CREATED,
+ Json(ContractSummary {
+ id: contract.id,
+ name: contract.name,
+ description: contract.description,
+ phase: contract.phase,
+ status: contract.status,
+ file_count: 0,
+ task_count: 0,
+ repository_count: 0,
+ version: contract.version,
+ created_at: contract.created_at,
+ }),
+ )
+ .into_response()
+ }
+ }
+ }
+ Err(e) => {
+ tracing::error!("Failed to create contract: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update a contract.
+#[utoipa::path(
+ put,
+ path = "/api/v1/contracts/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = UpdateContractRequest,
+ responses(
+ (status = 200, description = "Contract updated", body = ContractSummary),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 409, description = "Version conflict", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn update_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateContractRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await {
+ Ok(Some(contract)) => {
+ // If contract is completed, stop the supervisor task
+ if contract.status == "completed" {
+ if let Some(supervisor_task_id) = contract.supervisor_task_id {
+ // Get the supervisor task to find its daemon
+ if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await {
+ if let Some(daemon_id) = supervisor.daemon_id {
+ let state_clone = state.clone();
+ tokio::spawn(async move {
+ // Gracefully interrupt the supervisor
+ let cmd = crate::server::state::DaemonCommand::InterruptTask {
+ task_id: supervisor_task_id,
+ graceful: true,
+ };
+ if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await {
+ tracing::warn!(
+ supervisor_task_id = %supervisor_task_id,
+ daemon_id = %daemon_id,
+ error = %e,
+ "Failed to stop supervisor task on contract completion"
+ );
+ } else {
+ tracing::info!(
+ supervisor_task_id = %supervisor_task_id,
+ contract_id = %id,
+ "Stopped supervisor task on contract completion"
+ );
+ }
+ });
+ }
+ }
+ }
+ }
+
+ // Get summary with counts
+ match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await
+ {
+ Ok(Some(summary)) => Json(summary).into_response(),
+ _ => Json(ContractSummary {
+ id: contract.id,
+ name: contract.name,
+ description: contract.description,
+ phase: contract.phase,
+ status: contract.status,
+ file_count: 0,
+ task_count: 0,
+ repository_count: 0,
+ version: contract.version,
+ created_at: contract.created_at,
+ })
+ .into_response(),
+ }
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => {
+ tracing::info!(
+ "Version conflict on contract {}: expected {}, actual {}",
+ id,
+ expected,
+ actual
+ );
+ (
+ StatusCode::CONFLICT,
+ Json(serde_json::json!({
+ "code": "VERSION_CONFLICT",
+ "message": format!(
+ "Contract was modified. Expected version {}, actual version {}",
+ expected, actual
+ ),
+ "expectedVersion": expected,
+ "actualVersion": actual,
+ })),
+ )
+ .into_response()
+ }
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Failed to update contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a contract.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/contracts/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 204, description = "Contract deleted"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn delete_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::delete_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Repository Management
+// =============================================================================
+
+/// Add a remote repository to a contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/repositories/remote",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = AddRemoteRepositoryRequest,
+ responses(
+ (status = 201, description = "Repository added", body = ContractRepository),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn add_remote_repository(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<AddRemoteRepositoryRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary)
+ .await
+ {
+ Ok(repo) => {
+ // Update supervisor task with repository info if this is a primary repo
+ update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
+ (StatusCode::CREATED, Json(repo)).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to add remote repository to contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Add a local repository to a contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/repositories/local",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = AddLocalRepositoryRequest,
+ responses(
+ (status = 201, description = "Repository added", body = ContractRepository),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn add_local_repository(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<AddLocalRepositoryRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary)
+ .await
+ {
+ Ok(repo) => {
+ // Update supervisor task with repository info if this is a primary repo
+ update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
+ (StatusCode::CREATED, Json(repo)).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to add local repository to contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a managed repository (daemon will create it).
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/repositories/managed",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = CreateManagedRepositoryRequest,
+ responses(
+ (status = 201, description = "Repository creation requested", body = ContractRepository),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn create_managed_repository(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreateManagedRepositoryRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await {
+ Ok(repo) => {
+ // For managed repos, the daemon will create the repo and we'll update later
+ // For now, just mark that this is a managed repo configuration
+ // The helper handles the case where repo has no URL yet
+ update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await;
+ (StatusCode::CREATED, Json(repo)).into_response()
+ }
+ Err(e) => {
+ tracing::error!(
+ "Failed to create managed repository for contract {}: {}",
+ id,
+ e
+ );
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a repository from a contract.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/contracts/{id}/repositories/{repo_id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("repo_id" = Uuid, Path, description = "Repository ID")
+ ),
+ responses(
+ (status = 204, description = "Repository removed"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or repository not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn delete_repository(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, repo_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::delete_contract_repository(pool, repo_id, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Repository not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!(
+ "Failed to delete repository {} from contract {}: {}",
+ repo_id,
+ id,
+ e
+ );
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Set a repository as primary for a contract.
+#[utoipa::path(
+ put,
+ path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("repo_id" = Uuid, Path, description = "Repository ID")
+ ),
+ responses(
+ (status = 204, description = "Repository set as primary"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or repository not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn set_repository_primary(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, repo_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::set_repository_primary(pool, repo_id, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Repository not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!(
+ "Failed to set repository {} as primary for contract {}: {}",
+ repo_id,
+ id,
+ e
+ );
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Task Association
+// =============================================================================
+
+/// Add a task to a contract.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/tasks/{task_id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 204, description = "Task added to contract"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn add_task_to_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, task_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Verify task exists and belongs to owner
+ match repository::get_task_for_owner(pool, task_id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", task_id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Remove a task from a contract.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/contracts/{id}/tasks/{task_id}",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID"),
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 204, description = "Task removed from contract"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract or task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn remove_task_from_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path((id, task_id)): Path<(Uuid, Uuid)>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found in this contract")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!(
+ "Failed to remove task {} from contract {}: {}",
+ task_id,
+ id,
+ e
+ );
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Phase Management
+// =============================================================================
+
+/// Change contract phase.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{id}/phase",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ request_body = ChangePhaseRequest,
+ responses(
+ (status = 200, description = "Phase changed", body = ContractSummary),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn change_phase(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<ChangePhaseRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await {
+ Ok(Some(contract)) => {
+ // Notify supervisor of phase change
+ if let Some(supervisor_task_id) = contract.supervisor_task_id {
+ if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await {
+ let state_clone = state.clone();
+ let contract_id = contract.id;
+ let new_phase = contract.phase.clone();
+ tokio::spawn(async move {
+ state_clone.notify_supervisor_of_phase_change(
+ supervisor.id,
+ supervisor.daemon_id,
+ contract_id,
+ &new_phase,
+ ).await;
+ });
+ }
+ }
+
+ // Get summary with counts
+ match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await
+ {
+ Ok(Some(summary)) => Json(summary).into_response(),
+ _ => Json(ContractSummary {
+ id: contract.id,
+ name: contract.name,
+ description: contract.description,
+ phase: contract.phase,
+ status: contract.status,
+ file_count: 0,
+ task_count: 0,
+ repository_count: 0,
+ version: contract.version,
+ created_at: contract.created_at,
+ })
+ .into_response(),
+ }
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to change phase for contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Events
+// =============================================================================
+
+/// Get contract event history.
+#[utoipa::path(
+ get,
+ path = "/api/v1/contracts/{id}/events",
+ params(
+ ("id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn get_events(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify contract exists and belongs to owner
+ match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get contract {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_contract_events(pool, id).await {
+ Ok(events) => Json(events).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get events for contract {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
index 9634b73..05e871c 100644
--- a/makima/src/server/handlers/files.rs
+++ b/makima/src/server/handlers/files.rs
@@ -8,11 +8,11 @@ use axum::{
};
use uuid::Uuid;
-use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest};
+use crate::db::models::{CreateFileRequest, FileListResponse, UpdateFileRequest};
use crate::db::repository::{self, RepositoryError};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
-use crate::server::state::{FileUpdateNotification, SharedState};
+use crate::server::state::{DaemonCommand, FileUpdateNotification, SharedState};
/// List all files for the authenticated user's owner.
#[utoipa::path(
@@ -42,9 +42,8 @@ pub async fn list_files(
.into_response();
};
- match repository::list_files_for_owner(pool, auth.owner_id).await {
- Ok(files) => {
- let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect();
+ match repository::list_file_summaries_for_owner(pool, auth.owner_id).await {
+ Ok(summaries) => {
let total = summaries.len() as i64;
Json(FileListResponse {
files: summaries,
@@ -114,7 +113,7 @@ pub async fn get_file(
}
}
-/// Create a new file.
+/// Create a new file. Files must belong to a contract.
#[utoipa::path(
post,
path = "/api/v1/files",
@@ -123,6 +122,7 @@ pub async fn get_file(
(status = 201, description = "File created", body = crate::db::models::File),
(status = 400, description = "Invalid request", body = ApiError),
(status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
@@ -145,6 +145,26 @@ pub async fn create_file(
.into_response();
};
+ // Verify the contract exists and belongs to the owner
+ match repository::get_contract_for_owner(pool, req.contract_id, auth.owner_id).await {
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify contract: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ Ok(Some(_)) => {} // Contract exists, proceed
+ }
+
match repository::create_file_for_owner(pool, auth.owner_id, req).await {
Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
Err(e) => {
@@ -310,3 +330,190 @@ pub async fn delete_file(
}
}
}
+
+/// Sync a file from its linked repository file.
+///
+/// This endpoint triggers an async sync operation. The file must have a
+/// repo_file_path set, and its contract must have a linked repository.
+/// A connected daemon will read the file and update the file content.
+#[utoipa::path(
+ post,
+ path = "/api/v1/files/{id}/sync-from-repo",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 202, description = "Sync operation started"),
+ (status = 400, description = "File not linked to repository", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "No daemon available", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Files"
+)]
+pub async fn sync_file_from_repo(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the file and verify it has a repo_file_path
+ let file = match repository::get_file_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(f)) => f,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get file {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if file has a repo path and contract_id
+ let contract_id = match file.contract_id {
+ Some(id) => id,
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_CONTRACT",
+ "File is not associated with a contract",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ let repo_file_path = match file.repo_file_path {
+ Some(ref path) if !path.is_empty() => path.clone(),
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NOT_LINKED",
+ "File is not linked to a repository file",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Get contract repositories
+ let repositories = match repository::list_contract_repositories(pool, contract_id).await {
+ Ok(repos) => repos,
+ Err(e) => {
+ tracing::error!("Failed to get contract repositories: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if contract has repositories
+ if repositories.is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_REPOSITORY",
+ "Contract has no linked repositories",
+ )),
+ )
+ .into_response();
+ }
+
+ // Use the first repository's local path
+ let repo = &repositories[0];
+ let repo_local_path = match &repo.local_path {
+ Some(path) if !path.is_empty() => path.clone(),
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_LOCAL_PATH",
+ "Repository has no local path configured",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Find a connected daemon for this owner
+ let daemon_id = state
+ .daemon_connections
+ .iter()
+ .find(|entry| entry.value().owner_id == auth.owner_id)
+ .map(|entry| entry.value().id);
+
+ let daemon_id = match daemon_id {
+ Some(id) => id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemon connected. Start a daemon to sync files from repository.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Send ReadRepoFile command to daemon
+ // Use the file ID as the request_id so we can match the response
+ let command = DaemonCommand::ReadRepoFile {
+ request_id: id,
+ contract_id,
+ file_path: repo_file_path,
+ repo_path: repo_local_path,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send ReadRepoFile command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ // Update status to indicate sync in progress
+ if let Err(e) = sqlx::query("UPDATE files SET repo_sync_status = 'syncing' WHERE id = $1")
+ .bind(id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to update repo_sync_status: {}", e);
+ }
+
+ // Return 202 Accepted - the sync happens asynchronously
+ (
+ StatusCode::ACCEPTED,
+ Json(serde_json::json!({
+ "message": "Sync operation started",
+ "fileId": id,
+ })),
+ )
+ .into_response()
+}
diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs
index a26c208..524c48a 100644
--- a/makima/src/server/handlers/listen.rs
+++ b/makima/src/server/handlers/listen.rs
@@ -9,13 +9,13 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE};
-use crate::db::models::{CreateFileRequest, TranscriptEntry, UpdateFileRequest};
+use crate::db::models::{TranscriptEntry, UpdateFileRequest};
use crate::db::repository;
use crate::listen::{align_speakers, samples_per_chunk, DialogueSegment, TimestampMode};
use crate::server::messages::{
AudioEncoding, ClientMessage, ServerMessage, StartMessage, TranscriptMessage,
};
-use crate::server::state::SharedState;
+use crate::server::state::{MlModels, SharedState};
/// Chunk size in milliseconds for triggering transcription processing.
const STREAM_CHUNK_MS: u32 = 5_000;
@@ -77,6 +77,23 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
}
});
+ // Lazy-load ML models on first Listen connection
+ let ml_models = match state.get_ml_models().await {
+ Ok(models) => models,
+ Err(e) => {
+ tracing::error!(session_id = %session_id, error = %e, "Failed to load ML models");
+ let _ = response_tx
+ .send(ServerMessage::Error {
+ code: "MODEL_LOAD_ERROR".into(),
+ message: format!("Failed to load ML models: {}", e),
+ })
+ .await;
+ drop(response_tx);
+ let _ = sender_task.await;
+ return;
+ }
+ };
+
// Send ready message
let _ = response_tx
.send(ServerMessage::Ready {
@@ -106,9 +123,13 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
let mut transcript_entries: Vec<TranscriptEntry> = Vec::new();
let mut transcript_counter: u32 = 0;
+ // Auth state (set when Start message includes valid auth_token and contract_id)
+ let mut authenticated_owner_id: Option<Uuid> = None;
+ let mut target_contract_id: Option<Uuid> = None;
+
// Reset Sortformer state for new session
{
- let mut sortformer = state.sortformer.lock().await;
+ let mut sortformer = ml_models.sortformer.lock().await;
sortformer.reset_state();
}
@@ -132,8 +153,51 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
sample_rate = start.sample_rate,
channels = start.channels,
encoding = ?start.encoding,
+ contract_id = ?start.contract_id,
+ has_auth = start.auth_token.is_some(),
"Session started"
);
+
+ // Validate auth and contract if provided
+ if let (Some(token), Some(contract_id_str)) = (&start.auth_token, &start.contract_id) {
+ // Parse contract ID
+ if let Ok(contract_id) = Uuid::parse_str(contract_id_str) {
+ // Validate JWT token
+ if let Some(ref verifier) = state.jwt_verifier {
+ match verifier.verify(token) {
+ Ok(claims) => {
+ authenticated_owner_id = Some(claims.sub);
+ target_contract_id = Some(contract_id);
+ tracing::info!(
+ session_id = %session_id,
+ owner_id = %claims.sub,
+ contract_id = %contract_id,
+ "Authenticated session - transcripts will be saved to contract"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ session_id = %session_id,
+ error = %e,
+ "Invalid auth token - transcripts will not be saved"
+ );
+ }
+ }
+ } else {
+ tracing::debug!(
+ session_id = %session_id,
+ "No JWT verifier configured - transcripts will not be saved"
+ );
+ }
+ } else {
+ tracing::warn!(
+ session_id = %session_id,
+ contract_id = contract_id_str,
+ "Invalid contract ID format"
+ );
+ }
+ }
+
audio_format = Some(start);
audio_buffer.clear();
eou_buffer.clear();
@@ -143,9 +207,12 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
last_processed_len = 0;
audio_offset = 0.0;
finalized_segments.clear();
+ file_id = None;
+ authenticated_owner_id = authenticated_owner_id; // Keep from above
+ target_contract_id = target_contract_id; // Keep from above
// Reset models for new session
- let mut sortformer = state.sortformer.lock().await;
+ let mut sortformer = ml_models.sortformer.lock().await;
sortformer.reset_state();
}
Ok(ClientMessage::Stop(stop)) => {
@@ -165,7 +232,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
);
// Process remaining audio with sliding window
- match process_audio_window(&audio_buffer, audio_offset, &state).await {
+ match process_audio_window(&audio_buffer, audio_offset, ml_models).await {
Ok(segments) => {
tracing::debug!(
session_id = %session_id,
@@ -291,7 +358,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
while eou_buffer.len() >= EOU_CHUNK_SIZE {
let chunk: Vec<f32> = eou_buffer.drain(..EOU_CHUNK_SIZE).collect();
- let mut eou = state.parakeet_eou.lock().await;
+ let mut eou = ml_models.parakeet_eou.lock().await;
if let Ok(text) = eou.transcribe(&chunk, false) {
// Detect utterance boundary (sentence-ending punctuation)
if !text.is_empty() && text != last_eou_text {
@@ -325,7 +392,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
"Processing audio with sliding window"
);
- match process_audio_window(&audio_buffer, audio_offset, &state).await {
+ match process_audio_window(&audio_buffer, audio_offset, ml_models).await {
Ok(segments) => {
tracing::debug!(
session_id = %session_id,
@@ -339,21 +406,29 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
let adjusted_start = seg.start + audio_offset;
let adjusted_end = seg.end + audio_offset;
if adjusted_end > last_sent_end_time {
- // Create file on first transcript if database is available
+ // Create file on first transcript if authenticated with contract
if file_id.is_none() {
- if let Some(ref pool) = state.db_pool {
- match repository::create_file(pool, CreateFileRequest {
+ if let (Some(owner_id), Some(contract_id), Some(pool)) =
+ (authenticated_owner_id, target_contract_id, &state.db_pool)
+ {
+ let create_req = crate::db::models::CreateFileRequest {
+ contract_id,
name: None, // Auto-generated
- description: None,
+ description: Some("Live transcription".to_string()),
transcript: vec![],
location: None,
- }).await {
+ body: vec![],
+ repo_file_path: None,
+ contract_phase: None, // Will be looked up from contract
+ };
+ match repository::create_file_for_owner(pool, owner_id, create_req).await {
Ok(file) => {
file_id = Some(file.id);
tracing::info!(
session_id = %session_id,
file_id = %file.id,
- "Created file for session"
+ contract_id = %contract_id,
+ "Created file for session in contract"
);
}
Err(e) => {
@@ -468,6 +543,7 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
summary: None,
body: None,
version: None, // Internal update, skip version check
+ repo_file_path: None,
}).await {
Ok(_) => {
tracing::info!(
@@ -649,7 +725,7 @@ fn text_similarity(a: &str, b: &str) -> f32 {
async fn process_audio_window(
samples: &[f32],
_audio_offset: f32,
- state: &SharedState,
+ ml_models: &MlModels,
) -> Result<Vec<DialogueSegment>, Box<dyn std::error::Error + Send + Sync>> {
// Apply sliding window - only process the last 30 seconds
let window_start = samples.len().saturating_sub(MAX_WINDOW_SAMPLES);
@@ -663,8 +739,8 @@ async fn process_audio_window(
);
// Acquire model locks and run inference
- let mut parakeet = state.parakeet.lock().await;
- let mut sortformer = state.sortformer.lock().await;
+ let mut parakeet = ml_models.parakeet.lock().await;
+ let mut sortformer = ml_models.sortformer.lock().await;
// Run streaming diarization (maintains speaker cache across calls)
let diarization_segments =
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 760740c..2d90a04 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -214,7 +214,27 @@ pub async fn create_task(
};
match repository::create_task_for_owner(pool, auth.owner_id, req).await {
- Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
+ Ok(task) => {
+ // Notify supervisor of new task creation if task belongs to a contract
+ if let Some(contract_id) = task.contract_id {
+ if !task.is_supervisor {
+ let pool = pool.clone();
+ let state_clone = state.clone();
+ let task_clone = task.clone();
+ tokio::spawn(async move {
+ if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await {
+ state_clone.notify_supervisor_of_task_created(
+ supervisor.id,
+ supervisor.daemon_id,
+ task_clone.id,
+ &task_clone.name,
+ ).await;
+ }
+ });
+ }
+ }
+ (StatusCode::CREATED, Json(task)).into_response()
+ }
Err(e) => {
tracing::error!("Failed to create task: {}", e);
(
@@ -262,6 +282,26 @@ pub async fn update_task(
.into_response();
};
+ // Check if trying to set a supervisor task to a terminal status
+ if let Some(ref new_status) = req.status {
+ let terminal_statuses = ["done", "failed", "merged"];
+ if terminal_statuses.contains(&new_status.as_str()) {
+ // Get the task to check if it's a supervisor
+ if let Ok(Some(task)) = repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ if task.is_supervisor {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "SUPERVISOR_CANNOT_COMPLETE",
+ "Supervisor tasks cannot be marked as done, failed, or merged. They run for the lifetime of the contract.",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ }
+
// Track which fields are being updated for the notification
let mut updated_fields = Vec::new();
if req.name.is_some() {
@@ -288,6 +328,8 @@ pub async fn update_task(
match repository::update_task_for_owner(pool, id, auth.owner_id, req).await {
Ok(Some(task)) => {
+ let updated_fields_clone = updated_fields.clone();
+
// Broadcast task update notification
state.broadcast_task_update(TaskUpdateNotification {
task_id: task.id,
@@ -297,6 +339,28 @@ pub async fn update_task(
updated_fields,
updated_by: "user".to_string(),
});
+
+ // Notify supervisor of status change if task belongs to a contract
+ if let Some(contract_id) = task.contract_id {
+ if !task.is_supervisor && updated_fields_clone.contains(&"status".to_string()) {
+ let pool = pool.clone();
+ let state_clone = state.clone();
+ let task_clone = task.clone();
+ tokio::spawn(async move {
+ if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await {
+ state_clone.notify_supervisor_of_task_update(
+ supervisor.id,
+ supervisor.daemon_id,
+ task_clone.id,
+ &task_clone.name,
+ &task_clone.status,
+ &updated_fields_clone,
+ ).await;
+ }
+ });
+ }
+ }
+
Json(task).into_response()
}
Ok(None) => (
@@ -556,7 +620,8 @@ pub async fn start_task(
task_depth = task.depth,
subtask_count = subtask_count,
is_orchestrator = is_orchestrator,
- "Starting task with orchestrator determination"
+ is_supervisor = task.is_supervisor,
+ "Starting task with orchestrator/supervisor determination"
);
// IMPORTANT: Update database FIRST to assign daemon_id before sending command
@@ -602,8 +667,18 @@ pub async fn start_task(
completion_action: task.completion_action.clone(),
continue_from_task_id: task.continue_from_task_id,
copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ contract_id: task.contract_id,
+ is_supervisor: task.is_supervisor,
};
+ tracing::info!(
+ task_id = %id,
+ is_supervisor = task.is_supervisor,
+ is_orchestrator = is_orchestrator,
+ daemon_id = %target_daemon_id,
+ "Sending SpawnTask command to daemon"
+ );
+
if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
tracing::error!("Failed to send SpawnTask command: {}", e);
// Rollback: clear daemon_id and reset status since command failed
@@ -884,8 +959,11 @@ pub async fn send_message(
}
};
- // Check if task is running
- if task.status != "running" {
+ // Check if task is running (except for AUTH_CODE messages and supervisor tasks)
+ // Supervisor tasks can receive messages even when not running - daemon will respawn Claude
+ let is_auth_code = req.message.starts_with("AUTH_CODE:");
+ let is_supervisor = task.is_supervisor;
+ if task.status != "running" && !is_auth_code && !is_supervisor {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new(
@@ -900,8 +978,27 @@ pub async fn send_message(
}
// Find the daemon running this task
+ // For supervisors, if no daemon is assigned, find any available daemon for this owner
let target_daemon_id = if let Some(daemon_id) = task.daemon_id {
daemon_id
+ } else if is_supervisor {
+ // Supervisor without daemon - find one
+ match state.daemon_connections
+ .iter()
+ .find(|d| d.value().owner_id == auth.owner_id)
+ {
+ Some(entry) => entry.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemon available. Please start a daemon.",
+ )),
+ )
+ .into_response();
+ }
+ }
} else {
return (
StatusCode::SERVICE_UNAVAILABLE,
diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs
index 5d6d2ee..3f650bc 100644
--- a/makima/src/server/handlers/mesh_chat.rs
+++ b/makima/src/server/handlers/mesh_chat.rs
@@ -930,6 +930,46 @@ async fn handle_mesh_request(
merge_mode,
priority,
} => {
+ // Subtasks inherit contract_id from parent task
+ let contract_id = if let Some(parent_id) = parent_task_id {
+ match repository::get_task(pool, parent_id).await {
+ Ok(Some(parent_task)) => {
+ match parent_task.contract_id {
+ Some(cid) => cid,
+ None => {
+ return MeshRequestResult {
+ success: false,
+ message: "Parent task has no contract_id".to_string(),
+ data: None,
+ };
+ }
+ }
+ }
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Parent task {} not found", parent_id),
+ data: None,
+ };
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Failed to look up parent task: {}", e),
+ data: None,
+ };
+ }
+ }
+ } else {
+ // Root tasks created via LLM chat require a contract_id
+ // TODO: Add contract_id to create_task tool definition
+ return MeshRequestResult {
+ success: false,
+ message: "Cannot create root task without contract_id. Use parent_task_id to create subtasks.".to_string(),
+ data: None,
+ };
+ };
+
// Check if repository_url matches a daemon's working directory (for this owner)
let is_daemon_working_dir = repository_url.as_ref().map(|url| {
state.daemon_connections.iter().any(|entry| {
@@ -962,6 +1002,7 @@ async fn handle_mesh_request(
};
let create_req = CreateTaskRequest {
+ contract_id,
name: name.clone(),
description: None,
plan,
@@ -975,6 +1016,8 @@ async fn handle_mesh_request(
completion_action,
continue_from_task_id: None,
copy_files: None,
+ is_supervisor: false,
+ checkpoint_sha: None,
};
match repository::create_task_for_owner(pool, owner_id, create_req).await {
@@ -1074,6 +1117,8 @@ async fn handle_mesh_request(
completion_action: task.completion_action.clone(),
continue_from_task_id: task.continue_from_task_id,
copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ contract_id: task.contract_id,
+ is_supervisor: task.is_supervisor,
};
match state.send_daemon_command(target_daemon_id, command).await {
@@ -1610,6 +1655,9 @@ async fn handle_mesh_request(
crate::db::models::BodyElement::Image { src, alt, caption } => {
json!({ "type": "image", "src": src, "alt": alt, "caption": caption })
}
+ crate::db::models::BodyElement::Markdown { content } => {
+ json!({ "type": "markdown", "content": content })
+ }
}
})
.collect();
@@ -1640,6 +1688,9 @@ async fn handle_mesh_request(
}).collect();
Some(list_text.join("\n"))
}
+ crate::db::models::BodyElement::Markdown { content } => {
+ Some(content.clone())
+ }
_ => None,
}
})
@@ -1976,6 +2027,79 @@ async fn handle_mesh_request(
},
}
}
+
+ // Supervisor-only tools - these should be handled via the supervisor.sh script,
+ // not through the mesh chat. Return an informative error.
+ MeshToolRequest::GetAllContractTasks { contract_id } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "get_all_contract_tasks is a supervisor-only tool. Use supervisor.sh to access this functionality. Contract: {}",
+ contract_id
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::WaitForTaskCompletion { task_id, timeout_seconds } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "wait_for_task_completion is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Timeout: {}s",
+ task_id, timeout_seconds
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::ReadTaskWorktree { task_id, file_path } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "read_task_worktree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Path: {}",
+ task_id, file_path
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::SpawnTask { name, plan, parent_task_id, checkpoint_sha, .. } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "spawn_task is a supervisor-only tool. Only the contract supervisor can spawn new tasks. Task name: {}",
+ name
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::CreateCheckpoint { task_id, message } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "create_checkpoint is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Message: {}",
+ task_id, message
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::ListTaskCheckpoints { task_id } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "list_task_checkpoints is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}",
+ task_id
+ ),
+ data: None,
+ }
+ }
+ MeshToolRequest::GetTaskTree { task_id } => {
+ MeshRequestResult {
+ success: false,
+ message: format!(
+ "get_task_tree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}",
+ task_id
+ ),
+ data: None,
+ }
+ }
}
}
diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs
index 644d0bc..178e5e1 100644
--- a/makima/src/server/handlers/mesh_daemon.rs
+++ b/makima/src/server/handlers/mesh_daemon.rs
@@ -301,6 +301,17 @@ pub enum DaemonMessage {
#[serde(rename = "taskId")]
task_id: Uuid,
},
+ /// Authentication required - OAuth token expired, provides login URL
+ AuthenticationRequired {
+ /// Task ID that triggered the auth error (if any)
+ #[serde(rename = "taskId")]
+ task_id: Option<Uuid>,
+ /// OAuth login URL for remote authentication
+ #[serde(rename = "loginUrl")]
+ login_url: String,
+ /// Hostname of the daemon requiring auth
+ hostname: Option<String>,
+ },
/// Response to RetryCompletionAction command
CompletionActionResult {
#[serde(rename = "taskId")]
@@ -343,6 +354,21 @@ pub enum DaemonMessage {
#[serde(rename = "targetDir")]
target_dir: String,
},
+ /// Response to ReadRepoFile command
+ RepoFileContent {
+ /// Request ID from the original command
+ #[serde(rename = "requestId")]
+ request_id: Uuid,
+ /// Path to the file that was read
+ #[serde(rename = "filePath")]
+ file_path: String,
+ /// File content (None if error occurred)
+ content: Option<String>,
+ /// Whether the operation succeeded
+ success: bool,
+ /// Error message if operation failed
+ error: Option<String>,
+ },
}
/// Validated daemon authentication result.
@@ -509,6 +535,31 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
"Daemon registered"
);
+ // Register daemon in database
+ if let Some(ref pool) = state.db_pool {
+ match repository::register_daemon(
+ pool,
+ owner_id,
+ &connection_id,
+ Some(&hostname),
+ Some(&machine_id),
+ max_concurrent_tasks as i32,
+ ).await {
+ Ok(db_daemon) => {
+ tracing::debug!(
+ daemon_id = %db_daemon.id,
+ "Daemon registered in database"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ error = %e,
+ "Failed to register daemon in database"
+ );
+ }
+ }
+ }
+
// Register daemon in state with owner_id
state.register_daemon(
connection_id.clone(),
@@ -718,6 +769,24 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
],
updated_by: "daemon".into(),
});
+
+ // Notify supervisor if this task belongs to a contract
+ if let Some(contract_id) = updated_task.contract_id {
+ // Don't notify for supervisor tasks (they don't report to themselves)
+ if !updated_task.is_supervisor {
+ if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await {
+ state.notify_supervisor_of_task_completion(
+ supervisor.id,
+ supervisor.daemon_id,
+ updated_task.id,
+ &updated_task.name,
+ &updated_task.status,
+ updated_task.progress_summary.as_deref(),
+ updated_task.error_message.as_deref(),
+ ).await;
+ }
+ }
+ }
}
Ok(None) => {
tracing::warn!(
@@ -763,6 +832,50 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
);
state.revoke_tool_key(task_id);
}
+ Ok(DaemonMessage::AuthenticationRequired { task_id, login_url, hostname }) => {
+ tracing::warn!(
+ task_id = ?task_id,
+ login_url = %login_url,
+ hostname = ?hostname,
+ "Daemon requires authentication - OAuth token expired"
+ );
+
+ // Broadcast as task output with auth_required type so UI can display the login link
+ let content = format!(
+ "🔐 Authentication required on daemon{}. Click to login: {}",
+ hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default(),
+ login_url
+ );
+
+ // Broadcast to task subscribers if we have a task_id
+ if let Some(tid) = task_id {
+ tracing::info!(task_id = %tid, "Broadcasting auth_required to task subscribers");
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id: tid,
+ owner_id: Some(owner_id),
+ message_type: "auth_required".to_string(),
+ content: "Authentication required".to_string(), // Constant for dedup
+ tool_name: None,
+ tool_input: Some(serde_json::json!({
+ "loginUrl": login_url,
+ "hostname": hostname,
+ "taskId": tid.to_string(),
+ })),
+ is_error: Some(true),
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ } else {
+ tracing::warn!("No task_id for auth_required - cannot broadcast to specific task");
+ }
+
+ // Also log the full URL for manual use
+ tracing::info!(
+ login_url = %login_url,
+ "OAuth login URL available - user should open this in browser"
+ );
+ }
Ok(DaemonMessage::DaemonDirectories { working_directory, home_directory, worktrees_directory }) => {
tracing::info!(
daemon_id = %daemon_uuid,
@@ -874,6 +987,92 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
is_partial: false,
});
}
+ Ok(DaemonMessage::RepoFileContent {
+ request_id,
+ file_path,
+ content,
+ success,
+ error,
+ }) => {
+ tracing::info!(
+ request_id = %request_id,
+ file_path = %file_path,
+ success = success,
+ content_len = content.as_ref().map(|c| c.len()),
+ error = ?error,
+ "Repo file content received from daemon"
+ );
+
+ // The request_id is the file_id we want to update
+ if success {
+ if let (Some(pool), Some(content)) = (&state.db_pool, content) {
+ // Convert markdown to body elements
+ let body = crate::llm::markdown_to_body(&content);
+
+ // Update file in database
+ let update_req = crate::db::models::UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: None,
+ summary: None,
+ body: Some(body),
+ version: None,
+ repo_file_path: None,
+ };
+
+ match repository::update_file_for_owner(pool, request_id, owner_id, update_req).await {
+ Ok(Some(_file)) => {
+ tracing::info!(
+ file_id = %request_id,
+ "File synced from repository successfully"
+ );
+
+ // Update repo_sync_status to 'synced' and set repo_synced_at
+ if let Err(e) = sqlx::query(
+ "UPDATE files SET repo_sync_status = 'synced', repo_synced_at = NOW() WHERE id = $1"
+ )
+ .bind(request_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!(
+ file_id = %request_id,
+ error = %e,
+ "Failed to update repo sync status"
+ );
+ }
+
+ // Broadcast file update notification
+ state.broadcast_file_update(crate::server::state::FileUpdateNotification {
+ file_id: request_id,
+ version: 0, // Will be updated by next fetch
+ updated_fields: vec!["body".to_string(), "repo_sync_status".to_string()],
+ updated_by: "daemon".to_string(),
+ });
+ }
+ Ok(None) => {
+ tracing::warn!(
+ file_id = %request_id,
+ "File not found when syncing from repository"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ file_id = %request_id,
+ error = %e,
+ "Failed to update file from repository content"
+ );
+ }
+ }
+ }
+ } else {
+ tracing::warn!(
+ file_id = %request_id,
+ error = ?error,
+ "Daemon failed to read repo file"
+ );
+ }
+ }
Err(e) => {
tracing::warn!("Failed to parse daemon message: {}", e);
}
@@ -913,10 +1112,20 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
// Cleanup on disconnect
state.unregister_daemon(&connection_id);
- // Clear daemon_id from any tasks that were running on this daemon
+ // Delete daemon from database and clear tasks
if let Some(ref pool) = state.db_pool {
let pool = pool.clone();
+ let conn_id = connection_id.clone();
tokio::spawn(async move {
+ // Delete daemon from database
+ if let Err(e) = repository::delete_daemon_by_connection(&pool, &conn_id).await {
+ tracing::error!(
+ connection_id = %conn_id,
+ error = %e,
+ "Failed to delete daemon from database"
+ );
+ }
+
// Find tasks assigned to this daemon that are still active
if let Err(e) = clear_daemon_from_tasks(&pool, daemon_uuid).await {
tracing::error!(
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
new file mode 100644
index 0000000..ac59130
--- /dev/null
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -0,0 +1,1153 @@
+//! HTTP handlers for supervisor-specific mesh operations.
+//!
+//! These endpoints are used by supervisor tasks (via supervisor.sh) to orchestrate
+//! contract work: spawning tasks, waiting for completion, reading worktree files, etc.
+
+use axum::{
+ extract::{Path, State},
+ http::{HeaderMap, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::models::{CreateTaskRequest, Task, TaskSummary};
+use crate::db::repository;
+use crate::server::handlers::mesh::{extract_auth, AuthSource};
+use crate::server::messages::ApiError;
+use crate::server::state::{DaemonCommand, SharedState};
+
+// =============================================================================
+// Request/Response Types
+// =============================================================================
+
+/// Request to spawn a new task from supervisor.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SpawnTaskRequest {
+ pub name: String,
+ pub plan: String,
+ pub contract_id: Uuid,
+ pub parent_task_id: Option<Uuid>,
+ pub checkpoint_sha: Option<String>,
+ /// Repository URL for the task (supervisor should provide this)
+ pub repository_url: Option<String>,
+}
+
+/// Request to wait for task completion.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct WaitForTaskRequest {
+ #[serde(default = "default_timeout")]
+ pub timeout_seconds: i32,
+}
+
+fn default_timeout() -> i32 {
+ 300
+}
+
+/// Request to read a file from task worktree.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ReadWorktreeFileRequest {
+ pub file_path: String,
+}
+
+/// Request to create a checkpoint.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateCheckpointRequest {
+ pub message: String,
+}
+
+/// Response for task tree.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskTreeResponse {
+ pub tasks: Vec<TaskSummary>,
+ pub supervisor_task_id: Option<Uuid>,
+ pub total_count: usize,
+}
+
+/// Response for wait operation.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct WaitResponse {
+ pub task_id: Uuid,
+ pub status: String,
+ pub completed: bool,
+ pub output_summary: Option<String>,
+}
+
+/// Response for read file operation.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ReadFileResponse {
+ pub task_id: Uuid,
+ pub file_path: String,
+ pub content: String,
+ pub exists: bool,
+}
+
+/// Response for checkpoint operations.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckpointResponse {
+ pub task_id: Uuid,
+ pub checkpoint_number: i32,
+ pub commit_sha: String,
+ pub message: String,
+}
+
+/// Task checkpoint info.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskCheckpoint {
+ pub id: Uuid,
+ pub task_id: Uuid,
+ pub checkpoint_number: i32,
+ pub commit_sha: String,
+ pub branch_name: String,
+ pub message: String,
+ pub files_changed: Option<serde_json::Value>,
+ pub lines_added: i32,
+ pub lines_removed: i32,
+ pub created_at: chrono::DateTime<chrono::Utc>,
+}
+
+/// Response for list checkpoints.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckpointListResponse {
+ pub task_id: Uuid,
+ pub checkpoints: Vec<TaskCheckpoint>,
+}
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+/// Verify the request comes from a supervisor task and extract ownership info.
+async fn verify_supervisor_auth(
+ state: &SharedState,
+ headers: &HeaderMap,
+ contract_id: Option<Uuid>,
+) -> Result<(Uuid, Uuid), (StatusCode, Json<ApiError>)> {
+ let auth = extract_auth(state, headers);
+
+ let task_id = match auth {
+ AuthSource::ToolKey(task_id) => task_id,
+ _ => {
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ Json(ApiError::new("UNAUTHORIZED", "Supervisor endpoints require tool key auth")),
+ ));
+ }
+ };
+
+ // Get the task to verify it's a supervisor and get owner_id
+ let pool = state.db_pool.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ })?;
+
+ let task = repository::get_task(pool, task_id)
+ .await
+ .map_err(|e| {
+ tracing::error!(error = %e, "Failed to get supervisor task");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to verify supervisor")),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ })?;
+
+ // Verify task is a supervisor
+ if !task.is_supervisor {
+ return Err((
+ StatusCode::FORBIDDEN,
+ Json(ApiError::new("NOT_SUPERVISOR", "Only supervisor tasks can use these endpoints")),
+ ));
+ }
+
+ // If contract_id provided, verify the supervisor belongs to that contract
+ if let Some(cid) = contract_id {
+ if task.contract_id != Some(cid) {
+ return Err((
+ StatusCode::FORBIDDEN,
+ Json(ApiError::new("CONTRACT_MISMATCH", "Supervisor does not belong to this contract")),
+ ));
+ }
+ }
+
+ Ok((task_id, task.owner_id))
+}
+
+// =============================================================================
+// Contract Task Handlers
+// =============================================================================
+
+/// List all tasks in a contract's tree.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/supervisor/contracts/{contract_id}/tasks",
+ params(
+ ("contract_id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "List of tasks in contract", body = TaskTreeResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn list_contract_tasks(
+ State(state): State<SharedState>,
+ Path(contract_id): Path<Uuid>,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, Some(contract_id)).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get all tasks for this contract
+ match repository::list_tasks_by_contract(pool, contract_id, owner_id).await {
+ Ok(tasks) => {
+ let supervisor_task_id = tasks.iter().find(|t| t.is_supervisor).map(|t| t.id);
+ let summaries: Vec<TaskSummary> = tasks.into_iter().map(TaskSummary::from).collect();
+ let total_count = summaries.len();
+
+ (
+ StatusCode::OK,
+ Json(TaskTreeResponse {
+ tasks: summaries,
+ supervisor_task_id,
+ total_count,
+ }),
+ ).into_response()
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to list contract tasks");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to list tasks")),
+ ).into_response()
+ }
+ }
+}
+
+/// Get full task tree structure for a contract.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/supervisor/contracts/{contract_id}/tree",
+ params(
+ ("contract_id" = Uuid, Path, description = "Contract ID")
+ ),
+ responses(
+ (status = 200, description = "Task tree structure", body = TaskTreeResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn get_contract_tree(
+ State(state): State<SharedState>,
+ Path(contract_id): Path<Uuid>,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ // Same as list_contract_tasks for now - can add tree structure later
+ list_contract_tasks(State(state), Path(contract_id), headers).await
+}
+
+// =============================================================================
+// Task Spawn Handler
+// =============================================================================
+
+/// Spawn a new task (supervisor only).
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/tasks",
+ request_body = SpawnTaskRequest,
+ responses(
+ (status = 201, description = "Task created", body = Task),
+ (status = 400, description = "Invalid request"),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn spawn_task(
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+ Json(request): Json<SpawnTaskRequest>,
+) -> impl IntoResponse {
+ let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, Some(request.contract_id)).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Verify contract exists
+ let _contract = match repository::get_contract_for_owner(pool, request.contract_id, owner_id).await {
+ Ok(Some(c)) => c,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get contract");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get contract")),
+ ).into_response();
+ }
+ };
+
+ // Get repository URL from the contract's primary repository
+ let repo_url = match repository::list_contract_repositories(pool, request.contract_id).await {
+ Ok(repos) => {
+ // Prefer primary repo, fallback to first repo
+ repos.iter()
+ .find(|r| r.is_primary)
+ .or(repos.first())
+ .and_then(|r| r.repository_url.clone())
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "Failed to get contract repositories, continuing without repo URL");
+ None
+ }
+ };
+
+ // Supervisor can override with explicit repository_url
+ let repo_url = request.repository_url.clone().or(repo_url);
+
+ // Create task request
+ let create_req = CreateTaskRequest {
+ name: request.name.clone(),
+ description: None,
+ plan: request.plan.clone(),
+ repository_url: repo_url.clone(),
+ contract_id: request.contract_id,
+ parent_task_id: request.parent_task_id,
+ is_supervisor: false,
+ checkpoint_sha: request.checkpoint_sha.clone(),
+ merge_mode: Some("manual".to_string()),
+ priority: 0,
+ base_branch: None,
+ target_branch: None,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ };
+
+ // Create task in DB
+ let task = match repository::create_task_for_owner(pool, owner_id, create_req).await {
+ Ok(t) => t,
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to create task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to create task")),
+ ).into_response();
+ }
+ };
+
+ tracing::info!(
+ supervisor_id = %supervisor_id,
+ task_id = %task.id,
+ task_name = %task.name,
+ "Supervisor spawned new task"
+ );
+
+ // Start task on a daemon
+ // Find a daemon that belongs to this owner
+ for entry in state.daemon_connections.iter() {
+ let daemon = entry.value();
+ if daemon.owner_id == owner_id {
+ // Send spawn command to first available daemon
+ let cmd = DaemonCommand::SpawnTask {
+ task_id: task.id,
+ task_name: task.name.clone(),
+ plan: task.plan.clone(),
+ repo_url: repo_url.clone(),
+ base_branch: task.base_branch.clone(),
+ target_branch: task.target_branch.clone(),
+ parent_task_id: task.parent_task_id,
+ depth: task.depth,
+ is_orchestrator: false,
+ target_repo_path: task.target_repo_path.clone(),
+ completion_action: task.completion_action.clone(),
+ continue_from_task_id: task.continue_from_task_id,
+ copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ contract_id: task.contract_id,
+ is_supervisor: false,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon.id, cmd).await {
+ tracing::warn!(error = %e, daemon_id = %daemon.id, "Failed to send spawn command");
+ } else {
+ tracing::info!(task_id = %task.id, daemon_id = %daemon.id, repo_url = ?repo_url, "Task spawn command sent");
+ }
+ break;
+ }
+ }
+
+ (StatusCode::CREATED, Json(task)).into_response()
+}
+
+// =============================================================================
+// Wait for Task Handler
+// =============================================================================
+
+/// Wait for a task to complete.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/tasks/{task_id}/wait",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID to wait for")
+ ),
+ request_body = WaitForTaskRequest,
+ responses(
+ (status = 200, description = "Task completed or timed out", body = WaitResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn wait_for_task(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+ Json(request): Json<WaitForTaskRequest>,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Verify task belongs to same owner
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // Check if already done
+ if task.status == "done" || task.status == "failed" || task.status == "merged" {
+ return (
+ StatusCode::OK,
+ Json(WaitResponse {
+ task_id,
+ status: task.status,
+ completed: true,
+ output_summary: None,
+ }),
+ ).into_response();
+ }
+
+ // Subscribe to task completions
+ let mut rx = state.task_completions.subscribe();
+ let timeout = tokio::time::Duration::from_secs(request.timeout_seconds as u64);
+
+ // Wait for completion or timeout
+ let result = tokio::time::timeout(timeout, async {
+ loop {
+ match rx.recv().await {
+ Ok(notification) => {
+ if notification.task_id == task_id {
+ return Some(notification);
+ }
+ }
+ Err(_) => {
+ // Channel closed or lagged - check DB directly
+ if let Ok(Some(t)) = repository::get_task(pool, task_id).await {
+ if t.status == "done" || t.status == "failed" || t.status == "merged" {
+ return Some(crate::server::state::TaskCompletionNotification {
+ task_id: t.id,
+ owner_id: Some(t.owner_id),
+ contract_id: t.contract_id,
+ parent_task_id: t.parent_task_id,
+ status: t.status,
+ output_summary: None,
+ worktree_path: None,
+ error_message: t.error_message,
+ });
+ }
+ }
+ tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
+ }
+ }
+ }
+ }).await;
+
+ match result {
+ Ok(Some(notification)) => {
+ (
+ StatusCode::OK,
+ Json(WaitResponse {
+ task_id,
+ status: notification.status,
+ completed: true,
+ output_summary: notification.output_summary,
+ }),
+ ).into_response()
+ }
+ Ok(None) | Err(_) => {
+ // Timeout - check final status
+ let final_status = repository::get_task(pool, task_id)
+ .await
+ .ok()
+ .flatten()
+ .map(|t| t.status)
+ .unwrap_or_else(|| "unknown".to_string());
+
+ (
+ StatusCode::OK,
+ Json(WaitResponse {
+ task_id,
+ status: final_status.clone(),
+ completed: final_status == "done" || final_status == "failed" || final_status == "merged",
+ output_summary: None,
+ }),
+ ).into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Read Worktree File Handler
+// =============================================================================
+
+/// Read a file from a task's worktree.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/tasks/{task_id}/read-file",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = ReadWorktreeFileRequest,
+ responses(
+ (status = 200, description = "File content", body = ReadFileResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn read_worktree_file(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+ Json(request): Json<ReadWorktreeFileRequest>,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get task to verify ownership
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // TODO: Implement file reading via worktree path
+ // For now, return not implemented - supervisor should use local file access via worktree
+ let _ = (task, request);
+
+ (
+ StatusCode::NOT_IMPLEMENTED,
+ Json(ApiError::new(
+ "NOT_IMPLEMENTED",
+ "Worktree file reading via API not yet implemented. Use local filesystem access via worktree path.",
+ )),
+ ).into_response()
+}
+
+// =============================================================================
+// Checkpoint Handlers
+// =============================================================================
+
+/// Create a git checkpoint for a task.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{task_id}/checkpoint",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = CreateCheckpointRequest,
+ responses(
+ (status = 201, description = "Checkpoint created", body = CheckpointResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn create_checkpoint(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+ Json(request): Json<CreateCheckpointRequest>,
+) -> impl IntoResponse {
+ let auth = extract_auth(&state, &headers);
+
+ let task_id_from_auth = match auth {
+ AuthSource::ToolKey(tid) => tid,
+ _ => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(ApiError::new("UNAUTHORIZED", "Tool key required")),
+ ).into_response();
+ }
+ };
+
+ // Can only create checkpoint for own task
+ if task_id_from_auth != task_id {
+ return (
+ StatusCode::FORBIDDEN,
+ Json(ApiError::new("FORBIDDEN", "Can only create checkpoint for own task")),
+ ).into_response();
+ }
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get task
+ let task = match repository::get_task(pool, task_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // TODO: Implement checkpoint creation via daemon command
+ // For now, checkpoints should be created by the task itself via git commands
+ let _ = (task, request);
+
+ (
+ StatusCode::NOT_IMPLEMENTED,
+ Json(ApiError::new(
+ "NOT_IMPLEMENTED",
+ "Checkpoint creation via API not yet implemented. Use git commands directly in the task.",
+ )),
+ ).into_response()
+}
+
+/// List checkpoints for a task.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{task_id}/checkpoints",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "List of checkpoints", body = CheckpointListResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn list_checkpoints(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ let auth = extract_auth(&state, &headers);
+
+ let _task_id_from_auth = match auth {
+ AuthSource::ToolKey(tid) => tid,
+ _ => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(ApiError::new("UNAUTHORIZED", "Tool key required")),
+ ).into_response();
+ }
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get checkpoints from DB
+ match repository::list_task_checkpoints(pool, task_id).await {
+ Ok(checkpoints) => {
+ let checkpoint_list: Vec<TaskCheckpoint> = checkpoints
+ .into_iter()
+ .map(|c| TaskCheckpoint {
+ id: c.id,
+ task_id: c.task_id,
+ checkpoint_number: c.checkpoint_number,
+ commit_sha: c.commit_sha,
+ branch_name: c.branch_name,
+ message: c.message,
+ files_changed: c.files_changed,
+ lines_added: c.lines_added.unwrap_or(0),
+ lines_removed: c.lines_removed.unwrap_or(0),
+ created_at: c.created_at,
+ })
+ .collect();
+
+ (
+ StatusCode::OK,
+ Json(CheckpointListResponse {
+ task_id,
+ checkpoints: checkpoint_list,
+ }),
+ ).into_response()
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to list checkpoints");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to list checkpoints")),
+ ).into_response()
+ }
+ }
+}
+
+// =============================================================================
+// Git Operations - Request/Response Types
+// =============================================================================
+
+/// Request to create a new branch.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBranchRequest {
+ pub branch_name: String,
+ pub from_ref: Option<String>,
+}
+
+/// Response for branch creation.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateBranchResponse {
+ pub success: bool,
+ pub branch_name: String,
+ pub message: String,
+}
+
+/// Request to merge task changes.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeTaskRequest {
+ pub target_branch: Option<String>,
+ #[serde(default)]
+ pub squash: bool,
+}
+
+/// Response for merge operation.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeTaskResponse {
+ pub task_id: Uuid,
+ pub success: bool,
+ pub message: String,
+ pub commit_sha: Option<String>,
+ pub conflicts: Option<Vec<String>>,
+}
+
+/// Request to create a pull request.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePRRequest {
+ pub task_id: Uuid,
+ pub title: String,
+ pub body: Option<String>,
+ #[serde(default = "default_base_branch")]
+ pub base_branch: String,
+}
+
+fn default_base_branch() -> String {
+ "main".to_string()
+}
+
+/// Response for PR creation.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePRResponse {
+ pub task_id: Uuid,
+ pub success: bool,
+ pub message: String,
+ pub pr_url: Option<String>,
+ pub pr_number: Option<i32>,
+}
+
+/// Response for task diff.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskDiffResponse {
+ pub task_id: Uuid,
+ pub success: bool,
+ pub diff: Option<String>,
+ pub error: Option<String>,
+}
+
+// =============================================================================
+// Git Operations - Handlers
+// =============================================================================
+
+/// Create a new branch from supervisor's worktree.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/branches",
+ request_body = CreateBranchRequest,
+ responses(
+ (status = 201, description = "Branch created", body = CreateBranchResponse),
+ (status = 400, description = "Invalid request"),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn create_branch(
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+ Json(request): Json<CreateBranchRequest>,
+) -> impl IntoResponse {
+ let (supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ // Find daemon running supervisor
+ let daemon_id = {
+ let pool = state.db_pool.as_ref().unwrap();
+ match repository::get_task(pool, supervisor_id).await {
+ Ok(Some(task)) => task.daemon_id,
+ _ => None,
+ }
+ };
+
+ let Some(daemon_id) = daemon_id else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "Supervisor has no assigned daemon")),
+ ).into_response();
+ };
+
+ // Send CreateBranch command to daemon
+ let cmd = DaemonCommand::CreateBranch {
+ task_id: supervisor_id,
+ branch_name: request.branch_name.clone(),
+ from_ref: request.from_ref,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, cmd).await {
+ tracing::error!(error = %e, "Failed to send CreateBranch command");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")),
+ ).into_response();
+ }
+
+ // Note: Real implementation would wait for daemon response
+ // For now, return success immediately - daemon will send response via WebSocket
+ (
+ StatusCode::CREATED,
+ Json(CreateBranchResponse {
+ success: true,
+ branch_name: request.branch_name,
+ message: "Branch creation command sent".to_string(),
+ }),
+ ).into_response()
+}
+
+/// Merge a task's changes to a target branch.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/tasks/{task_id}/merge",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID to merge")
+ ),
+ request_body = MergeTaskRequest,
+ responses(
+ (status = 200, description = "Merge initiated", body = MergeTaskResponse),
+ (status = 400, description = "Invalid request"),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn merge_task(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+ Json(request): Json<MergeTaskRequest>,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get the target task
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // Get daemon running the task
+ let Some(daemon_id) = task.daemon_id else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")),
+ ).into_response();
+ };
+
+ // Send MergeTaskToTarget command to daemon
+ let cmd = DaemonCommand::MergeTaskToTarget {
+ task_id,
+ target_branch: request.target_branch,
+ squash: request.squash,
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, cmd).await {
+ tracing::error!(error = %e, "Failed to send MergeTaskToTarget command");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")),
+ ).into_response();
+ }
+
+ (
+ StatusCode::OK,
+ Json(MergeTaskResponse {
+ task_id,
+ success: true,
+ message: "Merge command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ ).into_response()
+}
+
+/// Create a pull request for a task's changes.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/supervisor/pr",
+ request_body = CreatePRRequest,
+ responses(
+ (status = 201, description = "PR created", body = CreatePRResponse),
+ (status = 400, description = "Invalid request"),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn create_pr(
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+ Json(request): Json<CreatePRRequest>,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get the target task
+ let task = match repository::get_task_for_owner(pool, request.task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // Get daemon running the task
+ let Some(daemon_id) = task.daemon_id else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")),
+ ).into_response();
+ };
+
+ // Send CreatePR command to daemon
+ let cmd = DaemonCommand::CreatePR {
+ task_id: request.task_id,
+ title: request.title.clone(),
+ body: request.body.clone(),
+ base_branch: request.base_branch.clone(),
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, cmd).await {
+ tracing::error!(error = %e, "Failed to send CreatePR command");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")),
+ ).into_response();
+ }
+
+ (
+ StatusCode::CREATED,
+ Json(CreatePRResponse {
+ task_id: request.task_id,
+ success: true,
+ message: "PR creation command sent".to_string(),
+ pr_url: None,
+ pr_number: None,
+ }),
+ ).into_response()
+}
+
+/// Get the diff for a task's changes.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/supervisor/tasks/{task_id}/diff",
+ params(
+ ("task_id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Task diff", body = TaskDiffResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 403, description = "Forbidden - not a supervisor"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error"),
+ ),
+ tag = "Mesh Supervisor"
+)]
+pub async fn get_task_diff(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ headers: HeaderMap,
+) -> impl IntoResponse {
+ let (_supervisor_id, owner_id) = match verify_supervisor_auth(&state, &headers, None).await {
+ Ok(ids) => ids,
+ Err(e) => return e.into_response(),
+ };
+
+ let pool = state.db_pool.as_ref().unwrap();
+
+ // Get the target task
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ ).into_response();
+ }
+ Err(e) => {
+ tracing::error!(error = %e, "Failed to get task");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", "Failed to get task")),
+ ).into_response();
+ }
+ };
+
+ // Get daemon running the task
+ let Some(daemon_id) = task.daemon_id else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "Task has no assigned daemon")),
+ ).into_response();
+ };
+
+ // Send GetTaskDiff command to daemon
+ let cmd = DaemonCommand::GetTaskDiff { task_id };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, cmd).await {
+ tracing::error!(error = %e, "Failed to send GetTaskDiff command");
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("COMMAND_FAILED", "Failed to send command to daemon")),
+ ).into_response();
+ }
+
+ (
+ StatusCode::OK,
+ Json(TaskDiffResponse {
+ task_id,
+ success: true,
+ diff: None,
+ error: Some("Diff command sent - response will be streamed".to_string()),
+ }),
+ ).into_response()
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 8681104..8c2cb0c 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -2,6 +2,9 @@
pub mod api_keys;
pub mod chat;
+pub mod contract_chat;
+pub mod contract_daemon;
+pub mod contracts;
pub mod file_ws;
pub mod files;
pub mod listen;
@@ -9,6 +12,8 @@ pub mod mesh;
pub mod mesh_chat;
pub mod mesh_daemon;
pub mod mesh_merge;
+pub mod mesh_supervisor;
pub mod mesh_ws;
+pub mod templates;
pub mod users;
pub mod versions;
diff --git a/makima/src/server/handlers/templates.rs b/makima/src/server/handlers/templates.rs
new file mode 100644
index 0000000..868d5b4
--- /dev/null
+++ b/makima/src/server/handlers/templates.rs
@@ -0,0 +1,107 @@
+//! Templates API handler.
+
+use axum::{extract::Query, http::StatusCode, response::IntoResponse, Json};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::llm::templates;
+
+/// Query parameters for listing templates
+#[derive(Debug, Deserialize, ToSchema)]
+pub struct ListTemplatesQuery {
+ /// Filter by contract phase (research, specify, plan, execute, review)
+ pub phase: Option<String>,
+}
+
+/// Template summary for API response
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TemplateSummary {
+ /// Template identifier
+ pub id: String,
+ /// Display name
+ pub name: String,
+ /// Contract phase this template is designed for
+ pub phase: String,
+ /// Brief description
+ pub description: String,
+ /// Number of body elements in the template
+ pub element_count: usize,
+}
+
+/// Response for listing templates
+#[derive(Debug, Serialize, ToSchema)]
+pub struct ListTemplatesResponse {
+ pub templates: Vec<TemplateSummary>,
+}
+
+/// List available file templates
+#[utoipa::path(
+ get,
+ path = "/api/v1/templates",
+ params(
+ ("phase" = Option<String>, Query, description = "Filter by contract phase")
+ ),
+ responses(
+ (status = 200, description = "Templates retrieved successfully", body = ListTemplatesResponse)
+ ),
+ tag = "templates"
+)]
+pub async fn list_templates(
+ Query(query): Query<ListTemplatesQuery>,
+) -> impl IntoResponse {
+ let template_list = match query.phase.as_deref() {
+ Some(phase) => templates::templates_for_phase(phase),
+ None => templates::all_templates(),
+ };
+
+ let summaries: Vec<TemplateSummary> = template_list
+ .iter()
+ .map(|t| TemplateSummary {
+ id: t.id.clone(),
+ name: t.name.clone(),
+ phase: t.phase.clone(),
+ description: t.description.clone(),
+ element_count: t.suggested_body.len(),
+ })
+ .collect();
+
+ (
+ StatusCode::OK,
+ Json(ListTemplatesResponse {
+ templates: summaries,
+ }),
+ )
+ .into_response()
+}
+
+/// Get a specific template by ID
+#[utoipa::path(
+ get,
+ path = "/api/v1/templates/{id}",
+ params(
+ ("id" = String, Path, description = "Template ID")
+ ),
+ responses(
+ (status = 200, description = "Template retrieved successfully", body = templates::FileTemplate),
+ (status = 404, description = "Template not found")
+ ),
+ tag = "templates"
+)]
+pub async fn get_template(
+ axum::extract::Path(id): axum::extract::Path<String>,
+) -> impl IntoResponse {
+ let all = templates::all_templates();
+ let template = all.into_iter().find(|t| t.id == id);
+
+ match template {
+ Some(t) => (StatusCode::OK, Json(serde_json::json!(t))).into_response(),
+ None => (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({
+ "error": format!("Template '{}' not found", id)
+ })),
+ )
+ .into_response(),
+ }
+}
diff --git a/makima/src/server/messages.rs b/makima/src/server/messages.rs
index 0c92447..401afb0 100644
--- a/makima/src/server/messages.rs
+++ b/makima/src/server/messages.rs
@@ -25,6 +25,12 @@ pub struct StartMessage {
pub channels: u16,
/// Audio encoding format
pub encoding: AudioEncoding,
+ /// Optional contract ID to save transcript to (requires auth_token)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub contract_id: Option<String>,
+ /// Optional auth token (JWT) for authenticated sessions
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_token: Option<String>,
}
/// Stop message to terminate the session.
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index a096a5c..568b287 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{api_keys, chat, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_ws, users, versions};
+use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contracts, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, templates, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -53,6 +53,7 @@ pub fn make_router(state: SharedState) -> Router {
.delete(files::delete_file),
)
.route("/files/{id}/chat", post(chat::chat_handler))
+ .route("/files/{id}/sync-from-repo", post(files::sync_file_from_repo))
// Version history endpoints
.route("/files/{id}/versions", get(versions::list_versions))
.route("/files/{id}/versions/{version}", get(versions::get_version))
@@ -95,6 +96,20 @@ pub fn make_router(state: SharedState) -> Router {
.route("/mesh/tasks/{id}/merge/abort", post(mesh_merge::merge_abort))
.route("/mesh/tasks/{id}/merge/skip", post(mesh_merge::merge_skip))
.route("/mesh/tasks/{id}/merge/check", get(mesh_merge::merge_check))
+ // Checkpoint endpoints
+ .route("/mesh/tasks/{id}/checkpoint", post(mesh_supervisor::create_checkpoint))
+ .route("/mesh/tasks/{id}/checkpoints", get(mesh_supervisor::list_checkpoints))
+ // Supervisor endpoints (for supervisor.sh)
+ .route("/mesh/supervisor/contracts/{contract_id}/tasks", get(mesh_supervisor::list_contract_tasks))
+ .route("/mesh/supervisor/contracts/{contract_id}/tree", get(mesh_supervisor::get_contract_tree))
+ .route("/mesh/supervisor/tasks", post(mesh_supervisor::spawn_task))
+ .route("/mesh/supervisor/tasks/{task_id}/wait", post(mesh_supervisor::wait_for_task))
+ .route("/mesh/supervisor/tasks/{task_id}/read-file", post(mesh_supervisor::read_worktree_file))
+ // Supervisor git operations
+ .route("/mesh/supervisor/branches", post(mesh_supervisor::create_branch))
+ .route("/mesh/supervisor/tasks/{task_id}/merge", post(mesh_supervisor::merge_task))
+ .route("/mesh/supervisor/tasks/{task_id}/diff", get(mesh_supervisor::get_task_diff))
+ .route("/mesh/supervisor/pr", post(mesh_supervisor::create_pr))
// Mesh WebSocket endpoints
.route("/mesh/tasks/subscribe", get(mesh_ws::task_subscription_handler))
.route("/mesh/daemons/connect", get(mesh_daemon::daemon_handler))
@@ -113,6 +128,59 @@ pub fn make_router(state: SharedState) -> Router {
)
.route("/users/me/password", axum::routing::put(users::change_password_handler))
.route("/users/me/email", axum::routing::put(users::change_email_handler))
+ // Contract endpoints
+ .route(
+ "/contracts",
+ get(contracts::list_contracts).post(contracts::create_contract),
+ )
+ .route(
+ "/contracts/{id}",
+ get(contracts::get_contract)
+ .put(contracts::update_contract)
+ .delete(contracts::delete_contract),
+ )
+ .route("/contracts/{id}/phase", post(contracts::change_phase))
+ .route("/contracts/{id}/events", get(contracts::get_events))
+ .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler))
+ .route(
+ "/contracts/{id}/chat/history",
+ get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history),
+ )
+ // Contract daemon endpoints (for tasks to interact with contracts)
+ .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status))
+ .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist))
+ .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals))
+ .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report))
+ .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action))
+ .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action))
+ .route(
+ "/contracts/{id}/daemon/files",
+ get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file),
+ )
+ .route(
+ "/contracts/{id}/daemon/files/{file_id}",
+ get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file),
+ )
+ // Contract repository endpoints
+ .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository))
+ .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository))
+ .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository))
+ .route(
+ "/contracts/{id}/repositories/{repo_id}",
+ axum::routing::delete(contracts::delete_repository),
+ )
+ .route(
+ "/contracts/{id}/repositories/{repo_id}/primary",
+ axum::routing::put(contracts::set_repository_primary),
+ )
+ // Contract task association endpoints
+ .route(
+ "/contracts/{id}/tasks/{task_id}",
+ post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract),
+ )
+ // Template endpoints
+ .route("/templates", get(templates::list_templates))
+ .route("/templates/{id}", get(templates::get_template))
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")
@@ -131,12 +199,60 @@ pub fn make_router(state: SharedState) -> Router {
.layer(TraceLayer::new_for_http())
}
+/// Stale daemon cleanup interval in seconds
+const DAEMON_CLEANUP_INTERVAL_SECS: u64 = 60;
+/// Daemon heartbeat timeout in seconds (delete daemons older than this)
+const DAEMON_HEARTBEAT_TIMEOUT_SECS: i64 = 120;
+
/// Run the HTTP server with graceful shutdown support.
///
/// # Arguments
/// * `state` - Shared application state containing ML models
/// * `addr` - Address to bind to (e.g., "0.0.0.0:8080")
pub async fn run_server(state: SharedState, addr: &str) -> anyhow::Result<()> {
+ // Start background daemon cleanup task if database is available
+ if let Some(pool) = state.db_pool.clone() {
+ // Initial cleanup of any stale daemons from previous server run
+ match crate::db::repository::delete_stale_daemons(&pool, 0).await {
+ Ok(deleted) if deleted > 0 => {
+ tracing::info!(
+ deleted = deleted,
+ "Cleaned up stale daemons from previous server run"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "Failed to clean up stale daemons on startup");
+ }
+ _ => {}
+ }
+
+ // Spawn periodic cleanup task
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(
+ std::time::Duration::from_secs(DAEMON_CLEANUP_INTERVAL_SECS)
+ );
+ loop {
+ interval.tick().await;
+ match crate::db::repository::delete_stale_daemons(
+ &pool,
+ DAEMON_HEARTBEAT_TIMEOUT_SECS,
+ ).await {
+ Ok(deleted) if deleted > 0 => {
+ tracing::info!(
+ deleted = deleted,
+ timeout_secs = DAEMON_HEARTBEAT_TIMEOUT_SECS,
+ "Deleted stale daemons"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(error = %e, "Failed to delete stale daemons");
+ }
+ _ => {}
+ }
+ }
+ });
+ }
+
let app = make_router(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 425c466..c4f0f19 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -3,19 +3,22 @@
use utoipa::OpenApi;
use crate::db::models::{
- BranchInfo, BranchListResponse, CreateFileRequest, CreateTaskRequest, Daemon,
- DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse,
+ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse,
+ ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord,
+ ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
+ CreateContractRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest,
+ Daemon, DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse,
FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest,
MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse,
- MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest,
- Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
- UpdateFileRequest, UpdateTaskRequest,
+ MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest, Task,
+ TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
+ UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest,
};
use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-use crate::server::handlers::{api_keys, files, listen, mesh, mesh_chat, mesh_merge, users};
+use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -33,6 +36,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
files::create_file,
files::update_file,
files::delete_file,
+ files::sync_file_from_repo,
// Mesh endpoints
mesh::list_tasks,
mesh::get_task,
@@ -71,6 +75,25 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::change_password_handler,
users::change_email_handler,
users::delete_account_handler,
+ // Contract endpoints
+ contracts::list_contracts,
+ contracts::get_contract,
+ contracts::create_contract,
+ contracts::update_contract,
+ contracts::delete_contract,
+ contracts::change_phase,
+ contracts::get_events,
+ contracts::add_remote_repository,
+ contracts::add_local_repository,
+ contracts::create_managed_repository,
+ contracts::delete_repository,
+ contracts::set_repository_primary,
+ contracts::add_task_to_contract,
+ contracts::remove_task_from_contract,
+ // Contract chat endpoints
+ contract_chat::contract_chat_handler,
+ contract_chat::get_contract_chat_history,
+ contract_chat::clear_contract_chat_history,
),
components(
schemas(
@@ -102,6 +125,9 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
MeshChatConversation,
MeshChatMessageRecord,
MeshChatHistoryResponse,
+ // Contract chat schemas
+ ContractChatMessageRecord,
+ ContractChatHistoryResponse,
// Merge schemas
BranchInfo,
BranchListResponse,
@@ -127,12 +153,26 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::ChangeEmailResponse,
users::DeleteAccountRequest,
users::DeleteAccountResponse,
+ // Contract schemas
+ Contract,
+ ContractSummary,
+ ContractListResponse,
+ ContractWithRelations,
+ ContractRepository,
+ ContractEvent,
+ CreateContractRequest,
+ UpdateContractRequest,
+ AddRemoteRepositoryRequest,
+ AddLocalRepositoryRequest,
+ CreateManagedRepositoryRequest,
+ ChangePhaseRequest,
)
),
tags(
(name = "Listen", description = "Speech-to-text streaming endpoints"),
(name = "Files", description = "Transcript file management"),
(name = "Mesh", description = "Task orchestration for Claude Code instances"),
+ (name = "Contracts", description = "Contract management with workflow phases"),
(name = "API Keys", description = "API key management for programmatic access"),
(name = "Users", description = "User account management"),
)
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index e89197a..1c28544 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -3,7 +3,7 @@
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;
-use tokio::sync::{broadcast, mpsc, Mutex};
+use tokio::sync::{broadcast, mpsc, Mutex, OnceCell};
use uuid::Uuid;
use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer};
@@ -75,6 +75,34 @@ pub struct TaskOutputNotification {
pub is_partial: bool,
}
+/// Notification for task completion events (for supervisor tasks to monitor).
+#[derive(Debug, Clone, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskCompletionNotification {
+ /// ID of the completed task
+ pub task_id: Uuid,
+ /// Owner ID for data isolation
+ #[serde(skip)]
+ pub owner_id: Option<Uuid>,
+ /// Contract ID if task belongs to a contract
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub contract_id: Option<Uuid>,
+ /// Parent task ID (to notify parent/supervisor)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub parent_task_id: Option<Uuid>,
+ /// Final status: "done", "failed", etc.
+ pub status: String,
+ /// Summary of task output/results
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub output_summary: Option<String>,
+ /// Path to the task's worktree (for reading files)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub worktree_path: Option<String>,
+ /// Error message if task failed
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error_message: Option<String>,
+}
+
/// Command sent from server to daemon.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -119,6 +147,12 @@ pub enum DaemonCommand {
/// Files to copy from parent task's worktree
#[serde(rename = "copyFiles")]
copy_files: Option<Vec<String>>,
+ /// Contract ID if this task is associated with a contract
+ #[serde(rename = "contractId")]
+ contract_id: Option<Uuid>,
+ /// Whether this task is a supervisor (long-running contract orchestrator)
+ #[serde(rename = "isSupervisor")]
+ is_supervisor: bool,
},
/// Pause a running task
PauseTask {
@@ -251,6 +285,69 @@ pub enum DaemonCommand {
target_dir: String,
},
+ // =========================================================================
+ // Contract File Commands
+ // =========================================================================
+
+ /// Read a file from a repository linked to a contract
+ ReadRepoFile {
+ /// Request ID for correlating response
+ #[serde(rename = "requestId")]
+ request_id: Uuid,
+ /// Contract ID (used for logging/context)
+ #[serde(rename = "contractId")]
+ contract_id: Uuid,
+ /// Path to the file within the repository
+ #[serde(rename = "filePath")]
+ file_path: String,
+ /// Full repository path on daemon's filesystem
+ #[serde(rename = "repoPath")]
+ repo_path: String,
+ },
+
+ // =========================================================================
+ // Supervisor Git Commands
+ // =========================================================================
+
+ /// Create a new branch in a task's worktree
+ CreateBranch {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "branchName")]
+ branch_name: String,
+ /// Optional reference to create branch from (task_id or SHA)
+ #[serde(rename = "fromRef")]
+ from_ref: Option<String>,
+ },
+
+ /// Merge a task's changes to a target branch
+ MergeTaskToTarget {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Target branch to merge into (default: task's base branch)
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ /// Whether to squash commits
+ squash: bool,
+ },
+
+ /// Create a pull request for a task's changes
+ CreatePR {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ title: String,
+ body: Option<String>,
+ /// Base branch for the PR (default: main)
+ #[serde(rename = "baseBranch")]
+ base_branch: String,
+ },
+
+ /// Get the diff for a task's changes
+ GetTaskDiff {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
/// Error response
Error { code: String, message: String },
}
@@ -278,16 +375,29 @@ pub struct DaemonConnectionInfo {
pub worktrees_directory: Option<String>,
}
-/// Shared application state containing ML models and database pool.
-///
-/// Models are wrapped in `Mutex` for thread-safe mutable access during inference.
-pub struct AppState {
- /// Speech-to-text model (Parakeet TDT)
+/// Configuration paths for ML models (used for lazy loading).
+#[derive(Clone)]
+pub struct ModelConfig {
+ pub parakeet_model_dir: String,
+ pub parakeet_eou_dir: String,
+ pub sortformer_model_path: String,
+}
+
+/// Lazily-loaded ML models.
+pub struct MlModels {
pub parakeet: Mutex<ParakeetTDT>,
- /// End-of-Utterance detection model for streaming
pub parakeet_eou: Mutex<ParakeetEOU>,
- /// Speaker diarization model (Sortformer)
pub sortformer: Mutex<Sortformer>,
+}
+
+/// Shared application state containing ML models and database pool.
+///
+/// Models are lazily loaded on first use to speed up server startup.
+pub struct AppState {
+ /// ML model configuration (paths for lazy loading)
+ pub model_config: Option<ModelConfig>,
+ /// Lazily-loaded ML models (initialized on first Listen connection)
+ pub ml_models: OnceCell<MlModels>,
/// Optional database connection pool
pub db_pool: Option<PgPool>,
/// Broadcast channel for file update notifications
@@ -296,6 +406,8 @@ pub struct AppState {
pub task_updates: broadcast::Sender<TaskUpdateNotification>,
/// Broadcast channel for task output streaming
pub task_output: broadcast::Sender<TaskOutputNotification>,
+ /// Broadcast channel for task completion notifications (for supervisors)
+ pub task_completions: broadcast::Sender<TaskCompletionNotification>,
/// Active daemon connections (keyed by connection_id)
pub daemon_connections: DashMap<String, DaemonConnectionInfo>,
/// Tool keys for orchestrator API access (key -> task_id)
@@ -305,7 +417,9 @@ pub struct AppState {
}
impl AppState {
- /// Load all ML models from the specified directories.
+ /// Create AppState with ML model configuration for lazy loading.
+ ///
+ /// Models are NOT loaded at startup - they will be loaded on first Listen connection.
///
/// # Arguments
/// * `parakeet_model_dir` - Path to the Parakeet TDT model directory
@@ -315,19 +429,12 @@ impl AppState {
parakeet_model_dir: &str,
parakeet_eou_dir: &str,
sortformer_model_path: &str,
- ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
- let parakeet = ParakeetTDT::from_pretrained(parakeet_model_dir, None)?;
- let parakeet_eou = ParakeetEOU::from_pretrained(parakeet_eou_dir, None)?;
- let sortformer = Sortformer::with_config(
- sortformer_model_path,
- None,
- DiarizationConfig::callhome(),
- )?;
-
+ ) -> Self {
// Create broadcast channels with buffer for 256 messages
let (file_updates, _) = broadcast::channel(256);
let (task_updates, _) = broadcast::channel(256);
let (task_output, _) = broadcast::channel(1024); // Larger buffer for output streaming
+ let (task_completions, _) = broadcast::channel(256); // For supervisor task monitoring
// Initialize JWT verifier from environment (optional)
// Requires SUPABASE_URL and either SUPABASE_JWT_PUBLIC_KEY (RS256) or SUPABASE_JWT_SECRET (HS256)
@@ -357,18 +464,61 @@ impl AppState {
}
};
- Ok(Self {
- parakeet: Mutex::new(parakeet),
- parakeet_eou: Mutex::new(parakeet_eou),
- sortformer: Mutex::new(sortformer),
+ Self {
+ model_config: Some(ModelConfig {
+ parakeet_model_dir: parakeet_model_dir.to_string(),
+ parakeet_eou_dir: parakeet_eou_dir.to_string(),
+ sortformer_model_path: sortformer_model_path.to_string(),
+ }),
+ ml_models: OnceCell::new(),
db_pool: None,
file_updates,
task_updates,
task_output,
+ task_completions,
daemon_connections: DashMap::new(),
tool_keys: DashMap::new(),
jwt_verifier,
- })
+ }
+ }
+
+ /// Get or initialize ML models (lazy loading).
+ ///
+ /// Models are loaded on first call and cached for subsequent calls.
+ /// Returns None if model config is not set.
+ pub async fn get_ml_models(&self) -> Result<&MlModels, Box<dyn std::error::Error + Send + Sync>> {
+ let config = self.model_config.as_ref()
+ .ok_or_else(|| "ML model configuration not set")?;
+
+ self.ml_models.get_or_try_init(|| async {
+ tracing::info!(
+ parakeet = %config.parakeet_model_dir,
+ eou = %config.parakeet_eou_dir,
+ sortformer = %config.sortformer_model_path,
+ "Lazy-loading ML models on first Listen connection..."
+ );
+
+ let parakeet = ParakeetTDT::from_pretrained(&config.parakeet_model_dir, None)?;
+ let parakeet_eou = ParakeetEOU::from_pretrained(&config.parakeet_eou_dir, None)?;
+ let sortformer = Sortformer::with_config(
+ &config.sortformer_model_path,
+ None,
+ DiarizationConfig::callhome(),
+ )?;
+
+ tracing::info!("ML models loaded successfully");
+
+ Ok(MlModels {
+ parakeet: Mutex::new(parakeet),
+ parakeet_eou: Mutex::new(parakeet_eou),
+ sortformer: Mutex::new(sortformer),
+ })
+ }).await
+ }
+
+ /// Check if ML models are loaded.
+ pub fn are_models_loaded(&self) -> bool {
+ self.ml_models.initialized()
}
/// Set the database pool.
@@ -399,6 +549,13 @@ impl AppState {
let _ = self.task_output.send(notification);
}
+ /// Broadcast a task completion notification to all subscribers.
+ ///
+ /// Used to notify supervisor tasks when their child tasks complete.
+ pub fn broadcast_task_completion(&self, notification: TaskCompletionNotification) {
+ let _ = self.task_completions.send(notification);
+ }
+
/// Register a new daemon connection.
///
/// Returns the connection_id for later reference.
@@ -544,6 +701,167 @@ impl AppState {
self.tool_keys.retain(|_, v| *v != task_id);
tracing::info!(task_id = %task_id, "Revoked tool key");
}
+
+ // =========================================================================
+ // Supervisor Notifications
+ // =========================================================================
+
+ /// Notify a contract's supervisor task about an event.
+ ///
+ /// This sends a message to the supervisor's stdin so it can react to changes
+ /// in tasks or contract state.
+ pub async fn notify_supervisor(
+ &self,
+ supervisor_task_id: Uuid,
+ supervisor_daemon_id: Option<Uuid>,
+ message: &str,
+ ) -> Result<(), String> {
+ // Only send if we have a daemon ID
+ let daemon_id = match supervisor_daemon_id {
+ Some(id) => id,
+ None => {
+ tracing::debug!(
+ supervisor_task_id = %supervisor_task_id,
+ "Supervisor has no daemon assigned, skipping notification"
+ );
+ return Ok(());
+ }
+ };
+
+ let command = DaemonCommand::SendMessage {
+ task_id: supervisor_task_id,
+ message: message.to_string(),
+ };
+
+ self.send_daemon_command(daemon_id, command).await
+ }
+
+ /// Format and send a task completion notification to a supervisor.
+ pub async fn notify_supervisor_of_task_completion(
+ &self,
+ supervisor_task_id: Uuid,
+ supervisor_daemon_id: Option<Uuid>,
+ completed_task_id: Uuid,
+ completed_task_name: &str,
+ status: &str,
+ progress_summary: Option<&str>,
+ error_message: Option<&str>,
+ ) {
+ let mut message = format!(
+ "TASK_COMPLETED task_id={} name=\"{}\" status={}",
+ completed_task_id, completed_task_name, status
+ );
+
+ if let Some(summary) = progress_summary {
+ // Escape newlines in summary
+ let escaped = summary.replace('\n', "\\n");
+ message.push_str(&format!(" summary=\"{}\"", escaped));
+ }
+
+ if let Some(err) = error_message {
+ let escaped = err.replace('\n', "\\n");
+ message.push_str(&format!(" error=\"{}\"", escaped));
+ }
+
+ if let Err(e) = self.notify_supervisor(
+ supervisor_task_id,
+ supervisor_daemon_id,
+ &message,
+ ).await {
+ tracing::warn!(
+ supervisor_task_id = %supervisor_task_id,
+ completed_task_id = %completed_task_id,
+ "Failed to notify supervisor of task completion: {}",
+ e
+ );
+ }
+ }
+
+ /// Format and send a task status change notification to a supervisor.
+ pub async fn notify_supervisor_of_task_update(
+ &self,
+ supervisor_task_id: Uuid,
+ supervisor_daemon_id: Option<Uuid>,
+ updated_task_id: Uuid,
+ updated_task_name: &str,
+ new_status: &str,
+ updated_fields: &[String],
+ ) {
+ let message = format!(
+ "TASK_UPDATED task_id={} name=\"{}\" status={} fields={}",
+ updated_task_id,
+ updated_task_name,
+ new_status,
+ updated_fields.join(",")
+ );
+
+ if let Err(e) = self.notify_supervisor(
+ supervisor_task_id,
+ supervisor_daemon_id,
+ &message,
+ ).await {
+ tracing::warn!(
+ supervisor_task_id = %supervisor_task_id,
+ updated_task_id = %updated_task_id,
+ "Failed to notify supervisor of task update: {}",
+ e
+ );
+ }
+ }
+
+ /// Format and send a contract phase change notification to a supervisor.
+ pub async fn notify_supervisor_of_phase_change(
+ &self,
+ supervisor_task_id: Uuid,
+ supervisor_daemon_id: Option<Uuid>,
+ contract_id: Uuid,
+ new_phase: &str,
+ ) {
+ let message = format!(
+ "PHASE_CHANGED contract_id={} phase={}",
+ contract_id, new_phase
+ );
+
+ if let Err(e) = self.notify_supervisor(
+ supervisor_task_id,
+ supervisor_daemon_id,
+ &message,
+ ).await {
+ tracing::warn!(
+ supervisor_task_id = %supervisor_task_id,
+ contract_id = %contract_id,
+ "Failed to notify supervisor of phase change: {}",
+ e
+ );
+ }
+ }
+
+ /// Format and send a new task created notification to a supervisor.
+ pub async fn notify_supervisor_of_task_created(
+ &self,
+ supervisor_task_id: Uuid,
+ supervisor_daemon_id: Option<Uuid>,
+ new_task_id: Uuid,
+ new_task_name: &str,
+ ) {
+ let message = format!(
+ "TASK_CREATED task_id={} name=\"{}\"",
+ new_task_id, new_task_name
+ );
+
+ if let Err(e) = self.notify_supervisor(
+ supervisor_task_id,
+ supervisor_daemon_id,
+ &message,
+ ).await {
+ tracing::warn!(
+ supervisor_task_id = %supervisor_task_id,
+ new_task_id = %new_task_id,
+ "Failed to notify supervisor of task creation: {}",
+ e
+ );
+ }
+ }
}
/// Type alias for the shared application state.