diff options
| author | soryu <soryu@soryu.co> | 2026-01-15 00:23:44 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:23:47 +0000 |
| commit | eff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e (patch) | |
| tree | 90d87d6daf9dd78c31e4b816bb1d282db73821dd | |
| parent | 87044a747b47bd83249d61a45842c7f7b2eae56d (diff) | |
| download | soryu-eff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e.tar.gz soryu-eff0d844ca6e35bfbc2d5fdaa2d2f92177611f2e.zip | |
Contract type system
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 23 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 72 | ||||
| -rw-r--r-- | makima/migrations/20250115000000_add_contract_type.sql | 13 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 78 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 39 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 4 |
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, |
