summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-15 00:23:44 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:23:47 +0000
commiteff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e (patch)
tree90d87d6daf9dd78c31e4b816bb1d282db73821dd
parent87044a747b47bd83249d61a45842c7f7b2eae56d (diff)
downloadsoryu-eff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e.tar.gz
soryu-eff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e.zip
Contract type system
-rw-r--r--makima/frontend/src/lib/api.ts23
-rw-r--r--makima/frontend/src/routes/contracts.tsx72
-rw-r--r--makima/migrations/20250115000000_add_contract_type.sql13
-rw-r--r--makima/src/db/models.rs78
-rw-r--r--makima/src/db/repository.rs39
-rw-r--r--makima/src/server/handlers/contracts.rs4
6 files changed, 207 insertions, 22 deletions
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index d77c85c..d7ac8b6 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1342,11 +1342,26 @@ export async function deleteAccount(
// Contract Types for Workflow Management
// =============================================================================
+/** Contract type determines the workflow and required documents */
+export type ContractType = "simple" | "specification";
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";
+/** Get valid phases for a contract type */
+export function getValidPhases(contractType: ContractType): ContractPhase[] {
+ if (contractType === "simple") {
+ return ["plan", "execute"];
+ }
+ return ["research", "specify", "plan", "execute", "review"];
+}
+
+/** Get default initial phase for a contract type */
+export function getDefaultPhase(contractType: ContractType): ContractPhase {
+ return contractType === "simple" ? "plan" : "research";
+}
+
export interface ContractRepository {
id: string;
contractId: string;
@@ -1364,6 +1379,8 @@ export interface ContractSummary {
id: string;
name: string;
description: string | null;
+ /** Contract type: "simple" or "specification" */
+ contractType: ContractType;
phase: ContractPhase;
status: ContractStatus;
fileCount: number;
@@ -1378,6 +1395,8 @@ export interface Contract {
ownerId: string;
name: string;
description: string | null;
+ /** Contract type: "simple" or "specification" */
+ contractType: ContractType;
phase: ContractPhase;
status: ContractStatus;
/** Supervisor task ID for contract orchestration */
@@ -1411,7 +1430,9 @@ export interface ContractListResponse {
export interface CreateContractRequest {
name: string;
description?: string;
- /** Initial phase to start in (defaults to "research") */
+ /** Contract type: "simple" (default) or "specification" */
+ contractType?: ContractType;
+ /** Initial phase to start in (defaults based on contract type) */
initialPhase?: ContractPhase;
}
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
index 8c90804..f09ec5b 100644
--- a/makima/frontend/src/routes/contracts.tsx
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -11,10 +11,12 @@ import type {
ContractWithRelations,
ContractPhase,
ContractStatus,
+ ContractType,
CreateContractRequest,
RepositorySourceType,
DaemonDirectory,
} from "../lib/api";
+import { getValidPhases, getDefaultPhase } from "../lib/api";
export default function ContractsPage() {
const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth();
@@ -71,7 +73,8 @@ function ContractsPageContent() {
const [isCreating, setIsCreating] = useState(false);
const [newContractName, setNewContractName] = useState("");
const [newContractDescription, setNewContractDescription] = useState("");
- const [initialPhase, setInitialPhase] = useState<ContractPhase>("research");
+ const [contractType, setContractType] = useState<ContractType>("simple");
+ const [initialPhase, setInitialPhase] = useState<ContractPhase>("plan");
const [repoType, setRepoType] = useState<RepositorySourceType>("remote");
const [repoName, setRepoName] = useState("");
const [repoUrl, setRepoUrl] = useState("");
@@ -136,7 +139,8 @@ function ContractsPageContent() {
const data: CreateContractRequest = {
name: newContractName.trim(),
description: newContractDescription.trim() || undefined,
- initialPhase: initialPhase !== "research" ? initialPhase : undefined,
+ contractType: contractType,
+ initialPhase: initialPhase !== getDefaultPhase(contractType) ? initialPhase : undefined,
};
try {
@@ -171,7 +175,8 @@ function ContractsPageContent() {
setIsCreating(false);
setNewContractName("");
setNewContractDescription("");
- setInitialPhase("research");
+ setContractType("simple");
+ setInitialPhase("plan");
setRepoType("remote");
setRepoName("");
setRepoUrl("");
@@ -184,6 +189,8 @@ function ContractsPageContent() {
}, [
newContractName,
newContractDescription,
+ contractType,
+ initialPhase,
repoType,
repoName,
repoUrl,
@@ -200,7 +207,8 @@ function ContractsPageContent() {
setIsCreating(false);
setNewContractName("");
setNewContractDescription("");
- setInitialPhase("research");
+ setContractType("simple");
+ setInitialPhase("plan");
setRepoType("remote");
setRepoName("");
setRepoUrl("");
@@ -424,6 +432,48 @@ function ContractsPageContent() {
/>
</div>
+ {/* Contract Type */}
+ <div>
+ <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
+ Contract Type
+ </label>
+ <div className="flex gap-2">
+ <button
+ type="button"
+ onClick={() => {
+ setContractType("simple");
+ setInitialPhase("plan");
+ }}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ contractType === "simple"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Simple
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ setContractType("specification");
+ setInitialPhase("research");
+ }}
+ className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${
+ contractType === "specification"
+ ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]"
+ : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]"
+ }`}
+ >
+ Specification
+ </button>
+ </div>
+ <p className="mt-1 font-mono text-xs text-[#8b949e]">
+ {contractType === "simple"
+ ? "Plan → Execute: Simple workflow with a plan document"
+ : "Research → Specify → Plan → Execute → Review: Full specification-driven development with TDD"}
+ </p>
+ </div>
+
{/* Starting Phase */}
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
@@ -434,14 +484,16 @@ function ContractsPageContent() {
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>
+ {getValidPhases(contractType).map((phase) => (
+ <option key={phase} value={phase}>
+ {phase.charAt(0).toUpperCase() + phase.slice(1)}
+ </option>
+ ))}
</select>
<p className="mt-1 font-mono text-xs text-[#8b949e]">
- Skip earlier phases if you already have requirements defined
+ {contractType === "simple"
+ ? "Start in Plan to define what to build, or Execute if already planned"
+ : "Skip earlier phases if you already have requirements defined"}
</p>
</div>
diff --git a/makima/migrations/20250115000000_add_contract_type.sql b/makima/migrations/20250115000000_add_contract_type.sql
new file mode 100644
index 0000000..02d7bfd
--- /dev/null
+++ b/makima/migrations/20250115000000_add_contract_type.sql
@@ -0,0 +1,13 @@
+-- Add contract_type column to contracts table
+-- Types: 'simple' (Plan -> Execute) or 'specification' (Research -> Specify -> Plan -> Execute -> Review)
+
+ALTER TABLE contracts ADD COLUMN IF NOT EXISTS contract_type VARCHAR(32) NOT NULL DEFAULT 'simple';
+
+-- Update existing contracts to 'simple' type (they can be manually changed if needed)
+UPDATE contracts SET contract_type = 'simple' WHERE contract_type IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_contracts_contract_type ON contracts(contract_type);
+
+-- Add supervisor_task_id to contracts table if not exists
+ALTER TABLE contracts ADD COLUMN IF NOT EXISTS supervisor_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS idx_contracts_supervisor_task_id ON contracts(supervisor_task_id);
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index e16c43f..ca12eb2 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -991,6 +991,50 @@ pub struct MergeCompleteCheckResponse {
// Contract Types
// =============================================================================
+/// Contract type determines the workflow and required documents
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum ContractType {
+ /// Simple Plan -> Execute workflow (default)
+ /// - Plan phase: requires a "Plan" document
+ /// - Execute phase: no documents, fulfills the plan
+ Simple,
+ /// Specification-based development with TDD
+ /// - Research: gather requirements and context
+ /// - Specify: write specifications and test cases
+ /// - Plan: create implementation plan
+ /// - Execute: implement according to specs
+ /// - Review: verify against specifications
+ Specification,
+}
+
+impl Default for ContractType {
+ fn default() -> Self {
+ ContractType::Simple
+ }
+}
+
+impl std::fmt::Display for ContractType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ContractType::Simple => write!(f, "simple"),
+ ContractType::Specification => write!(f, "specification"),
+ }
+ }
+}
+
+impl std::str::FromStr for ContractType {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "simple" => Ok(ContractType::Simple),
+ "specification" => Ok(ContractType::Specification),
+ _ => Err(format!("Unknown contract type: {}", s)),
+ }
+ }
+}
+
/// Contract phase for workflow progression
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
@@ -1143,6 +1187,8 @@ pub struct Contract {
pub owner_id: Uuid,
pub name: String,
pub description: Option<String>,
+ /// Contract type: "simple" or "specification"
+ pub contract_type: String,
pub phase: String,
pub status: String,
/// The long-running supervisor task that orchestrates this contract
@@ -1154,6 +1200,11 @@ pub struct Contract {
}
impl Contract {
+ /// Parse contract_type string to ContractType enum
+ pub fn contract_type_enum(&self) -> Result<ContractType, String> {
+ self.contract_type.parse()
+ }
+
/// Parse phase string to ContractPhase enum
pub fn phase_enum(&self) -> Result<ContractPhase, String> {
self.phase.parse()
@@ -1163,6 +1214,21 @@ impl Contract {
pub fn status_enum(&self) -> Result<ContractStatus, String> {
self.status.parse()
}
+
+ /// Get valid phases for this contract type
+ pub fn valid_phases(&self) -> Vec<ContractPhase> {
+ match self.contract_type.as_str() {
+ "simple" => vec![ContractPhase::Plan, ContractPhase::Execute],
+ "specification" => vec![
+ ContractPhase::Research,
+ ContractPhase::Specify,
+ ContractPhase::Plan,
+ ContractPhase::Execute,
+ ContractPhase::Review,
+ ],
+ _ => vec![ContractPhase::Plan, ContractPhase::Execute], // Default to simple
+ }
+ }
}
/// Contract repository record from the database
@@ -1200,6 +1266,8 @@ pub struct ContractSummary {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
+ /// Contract type: "simple" or "specification"
+ pub contract_type: String,
pub phase: String,
pub status: String,
pub file_count: i64,
@@ -1236,8 +1304,14 @@ pub struct CreateContractRequest {
pub name: String,
/// Optional description
pub description: Option<String>,
- /// Initial phase to start in (defaults to "research")
- /// Valid values: "research", "specify", "plan", "execute", "review"
+ /// Contract type: "simple" (default) or "specification"
+ /// - simple: Plan -> Execute workflow
+ /// - specification: Research -> Specify -> Plan -> Execute -> Review
+ #[serde(default)]
+ pub contract_type: Option<String>,
+ /// Initial phase to start in (defaults based on contract_type)
+ /// - simple: defaults to "plan"
+ /// - specification: defaults to "research"
#[serde(default)]
pub initial_phase: Option<String>,
}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 3b911c2..7933f1e 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -2019,29 +2019,50 @@ pub async fn create_contract_for_owner(
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");
+ // Default contract type is "simple"
+ let contract_type = req.contract_type.as_deref().unwrap_or("simple");
- // Validate the phase
- let valid_phases = ["research", "specify", "plan", "execute", "review"];
+ // Validate contract type
+ let valid_types = ["simple", "specification"];
+ if !valid_types.contains(&contract_type) {
+ return Err(sqlx::Error::Protocol(format!(
+ "Invalid contract_type '{}'. Must be one of: {}",
+ contract_type,
+ valid_types.join(", ")
+ )));
+ }
+
+ // Determine valid phases based on contract type
+ let (valid_phases, default_phase): (&[&str], &str) = match contract_type {
+ "simple" => (&["plan", "execute"], "plan"),
+ "specification" => (&["research", "specify", "plan", "execute", "review"], "research"),
+ _ => (&["plan", "execute"], "plan"),
+ };
+
+ // Use provided initial_phase or default based on contract type
+ let phase = req.initial_phase.as_deref().unwrap_or(default_phase);
+
+ // Validate the phase is valid for this contract type
if !valid_phases.contains(&phase) {
return Err(sqlx::Error::Protocol(format!(
- "Invalid initial_phase '{}'. Must be one of: {}",
+ "Invalid initial_phase '{}' for contract type '{}'. Must be one of: {}",
phase,
+ contract_type,
valid_phases.join(", ")
)));
}
sqlx::query_as::<_, Contract>(
r#"
- INSERT INTO contracts (owner_id, name, description, phase)
- VALUES ($1, $2, $3, $4)
+ INSERT INTO contracts (owner_id, name, description, contract_type, phase)
+ VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
)
.bind(owner_id)
.bind(&req.name)
.bind(&req.description)
+ .bind(contract_type)
.bind(phase)
.fetch_one(pool)
.await
@@ -2074,7 +2095,7 @@ pub async fn list_contracts_for_owner(
sqlx::query_as::<_, ContractSummary>(
r#"
SELECT
- c.id, c.name, c.description, c.phase, c.status,
+ c.id, c.name, c.description, c.contract_type, 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,
@@ -2098,7 +2119,7 @@ pub async fn get_contract_summary_for_owner(
sqlx::query_as::<_, ContractSummary>(
r#"
SELECT
- c.id, c.name, c.description, c.phase, c.status,
+ c.id, c.name, c.description, c.contract_type, 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,
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
index 3d726df..a3aa00a 100644
--- a/makima/src/server/handlers/contracts.rs
+++ b/makima/src/server/handlers/contracts.rs
@@ -342,6 +342,7 @@ pub async fn create_contract(
id: contract.id,
name: contract.name,
description: contract.description,
+ contract_type: contract.contract_type,
phase: contract.phase,
status: contract.status,
file_count: 0,
@@ -361,6 +362,7 @@ pub async fn create_contract(
id: contract.id,
name: contract.name,
description: contract.description,
+ contract_type: contract.contract_type,
phase: contract.phase,
status: contract.status,
file_count: 0,
@@ -464,6 +466,7 @@ pub async fn update_contract(
id: contract.id,
name: contract.name,
description: contract.description,
+ contract_type: contract.contract_type,
phase: contract.phase,
status: contract.status,
file_count: 0,
@@ -1186,6 +1189,7 @@ pub async fn change_phase(
id: contract.id,
name: contract.name,
description: contract.description,
+ contract_type: contract.contract_type,
phase: contract.phase,
status: contract.status,
file_count: 0,