summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-10 14:50:07 +0000
committersoryu <soryu@soryu.co>2026-02-10 14:50:07 +0000
commit15b6e5fba161a194fe5427d7d29b0c4286423260 (patch)
treefdd7bde229150cbb56d37714c23c2dc9db902f28
parent526edf672aae73c3670ab6141253bf92f1fbfe8c (diff)
downloadsoryu-15b6e5fba161a194fe5427d7d29b0c4286423260.tar.gz
soryu-15b6e5fba161a194fe5427d7d29b0c4286423260.zip
Add auto-PR creation for remote repos in directives
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx34
-rw-r--r--makima/frontend/src/lib/api.ts5
-rw-r--r--makima/migrations/20260210000001_add_directive_pr_fields.sql3
-rw-r--r--makima/src/bin/makima.rs7
-rw-r--r--makima/src/daemon/api/directive.rs20
-rw-r--r--makima/src/daemon/cli/directive.rs15
-rw-r--r--makima/src/daemon/cli/mod.rs3
-rw-r--r--makima/src/daemon/task/manager.rs45
-rw-r--r--makima/src/daemon/worktree/manager.rs92
-rw-r--r--makima/src/db/models.rs7
-rw-r--r--makima/src/db/repository.rs123
-rw-r--r--makima/src/orchestration/directive.rs254
12 files changed, 590 insertions, 18 deletions
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
index 1340482..616c5d2 100644
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -114,6 +114,40 @@ export function DirectiveDetail({
</div>
)}
+ {/* PR link */}
+ {directive.prUrl && (
+ <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#0a1a10] border border-emerald-900 rounded">
+ <span className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
+ <span className="text-[10px] font-mono text-emerald-400">
+ PR created
+ </span>
+ <a
+ href={directive.prUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 underline ml-auto truncate max-w-[200px]"
+ >
+ {directive.prUrl}
+ </a>
+ </div>
+ )}
+
+ {/* Completion task indicator */}
+ {directive.completionTaskId && !directive.prUrl && (
+ <div className="flex items-center gap-2 mb-2 px-2 py-1.5 bg-[#1a1a10] border border-yellow-900 rounded">
+ <span className="inline-block w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
+ <span className="text-[10px] font-mono text-yellow-400">
+ Creating PR...
+ </span>
+ <a
+ href={`/mesh/${directive.completionTaskId}`}
+ className="text-[9px] font-mono text-[#556677] hover:text-yellow-400 underline ml-auto"
+ >
+ View task
+ </a>
+ </div>
+ )}
+
{/* Controls */}
<div className="flex flex-wrap gap-2">
{(directive.status === "draft" || directive.status === "paused") && (
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index b1422df..40e160e 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3020,6 +3020,9 @@ export interface Directive {
localPath: string | null;
baseBranch: string | null;
orchestratorTaskId: string | null;
+ prUrl: string | null;
+ prBranch: string | null;
+ completionTaskId: string | null;
goalUpdatedAt: string;
startedAt: string | null;
version: number;
@@ -3055,6 +3058,8 @@ export interface DirectiveSummary {
status: DirectiveStatus;
repositoryUrl: string | null;
orchestratorTaskId: string | null;
+ prUrl: string | null;
+ completionTaskId: string | null;
version: number;
createdAt: string;
updatedAt: string;
diff --git a/makima/migrations/20260210000001_add_directive_pr_fields.sql b/makima/migrations/20260210000001_add_directive_pr_fields.sql
new file mode 100644
index 0000000..e72bb39
--- /dev/null
+++ b/makima/migrations/20260210000001_add_directive_pr_fields.sql
@@ -0,0 +1,3 @@
+ALTER TABLE directives ADD COLUMN pr_url TEXT;
+ALTER TABLE directives ADD COLUMN pr_branch VARCHAR(255);
+ALTER TABLE directives ADD COLUMN completion_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL;
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index a0889d0..c2c9beb 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -818,6 +818,13 @@ async fn run_directive(
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
+ DirectiveCommand::Update(args) => {
+ let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
+ let result = client
+ .directive_update(args.common.directive_id, args.pr_url, args.pr_branch)
+ .await?;
+ println!("{}", serde_json::to_string(&result.0)?);
+ }
}
Ok(())
diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs
index cd21692..5886766 100644
--- a/makima/src/daemon/api/directive.rs
+++ b/makima/src/daemon/api/directive.rs
@@ -134,4 +134,24 @@ impl ApiClient {
let req = UpdateGoalRequest { goal: goal.to_string() };
self.put(&format!("/api/v1/directives/{}/goal", directive_id), &req).await
}
+
+ /// Update directive metadata (PR URL, PR branch, etc.)
+ pub async fn directive_update(
+ &self,
+ directive_id: Uuid,
+ pr_url: Option<String>,
+ pr_branch: Option<String>,
+ ) -> Result<JsonValue, ApiError> {
+ let req = UpdateDirectiveMetadataRequest { pr_url, pr_branch };
+ self.put(&format!("/api/v1/directives/{}", directive_id), &req).await
+ }
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateDirectiveMetadataRequest {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pr_url: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pr_branch: Option<String>,
}
diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs
index cd94a56..2e6ac1d 100644
--- a/makima/src/daemon/cli/directive.rs
+++ b/makima/src/daemon/cli/directive.rs
@@ -110,3 +110,18 @@ pub struct BatchAddStepsArgs {
#[arg(long)]
pub json: String,
}
+
+/// Arguments for update command.
+#[derive(Args, Debug)]
+pub struct UpdateArgs {
+ #[command(flatten)]
+ pub common: DirectiveArgs,
+
+ /// PR URL to store on the directive
+ #[arg(long)]
+ pub pr_url: Option<String>,
+
+ /// PR branch name to store on the directive
+ #[arg(long)]
+ pub pr_branch: Option<String>,
+}
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index 98923d9..bcaaa70 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -246,6 +246,9 @@ pub enum DirectiveCommand {
/// Batch add multiple steps from JSON
BatchAddSteps(directive::BatchAddStepsArgs),
+
+ /// Update directive metadata (PR URL, etc.)
+ Update(directive::UpdateArgs),
}
impl Cli {
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index a24f527..22b41d9 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -5587,8 +5587,8 @@ impl TaskManagerInner {
let target_repo = match target_repo_path {
Some(path) => Some(crate::daemon::worktree::expand_tilde(path)),
None => {
- if action == "pr" {
- // For PR action, check if worktree has an origin remote we can use directly
+ if action == "pr" || action == "branch" {
+ // For PR/branch action without target_repo, use origin directly
None
} else {
tracing::warn!(task_id = %task_id, "No target_repo_path configured, skipping completion action");
@@ -5633,19 +5633,36 @@ impl TaskManagerInner {
match action {
"branch" => {
- let target_repo = target_repo.ok_or_else(|| "No target_repo_path configured for branch action".to_string())?;
- // Just push the branch to target repo
- self.worktree_manager
- .push_to_target_repo(worktree_path, &target_repo, &branch_name, task_name)
- .await
- .map_err(|e| e.to_string())?;
+ match target_repo {
+ Some(target_repo) => {
+ // Push branch to local target repo
+ self.worktree_manager
+ .push_to_target_repo(worktree_path, &target_repo, &branch_name, task_name)
+ .await
+ .map_err(|e| e.to_string())?;
- let msg = DaemonMessage::task_output(
- task_id,
- format!("Branch '{}' pushed to {}\n", branch_name, target_repo.display()),
- false,
- );
- let _ = self.ws_tx.send(msg).await;
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Branch '{}' pushed to {}\n", branch_name, target_repo.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+ None => {
+ // Push branch to origin (GitHub)
+ self.worktree_manager
+ .push_branch_to_origin(worktree_path, &branch_name, task_name)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Branch '{}' pushed to origin\n", branch_name),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
Ok(None)
}
"merge" => {
diff --git a/makima/src/daemon/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs
index 310627c..20c93b1 100644
--- a/makima/src/daemon/worktree/manager.rs
+++ b/makima/src/daemon/worktree/manager.rs
@@ -1417,6 +1417,98 @@ impl WorktreeManager {
Ok(())
}
+ /// Push a worktree branch to origin (the upstream GitHub remote).
+ /// Simpler than push_to_target_repo — just pushes to origin.
+ pub async fn push_branch_to_origin(
+ &self,
+ worktree_path: &Path,
+ branch_name: &str,
+ task_name: &str,
+ ) -> Result<(), WorktreeError> {
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ branch = %branch_name,
+ "Pushing branch to origin"
+ );
+
+ // Stage all changes
+ let output = Command::new("git")
+ .args(["add", "-A"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to stage changes: {}",
+ stderr
+ )));
+ }
+
+ // Check if there are staged changes to commit
+ let output = Command::new("git")
+ .args(["diff", "--cached", "--quiet"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ // Exit code 1 means there are staged changes
+ if !output.status.success() {
+ tracing::info!("Committing staged changes before push to origin");
+
+ let commit_message = format!("feat: {}", task_name);
+ let output = Command::new("git")
+ .args(["commit", "-m", &commit_message])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to commit changes: {}",
+ stderr
+ )));
+ }
+ }
+
+ // Ensure there are commits to push
+ let output = Command::new("git")
+ .args(["log", "--oneline", "-1"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ return Err(WorktreeError::GitCommand(
+ "No commits in worktree".to_string(),
+ ));
+ }
+
+ // Push to origin
+ let output = Command::new("git")
+ .args(["push", "-u", "origin", &format!("HEAD:{}", branch_name)])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to push to origin: {}",
+ stderr
+ )));
+ }
+
+ tracing::info!(
+ branch = %branch_name,
+ "Branch pushed to origin successfully"
+ );
+
+ Ok(())
+ }
+
/// Merge a branch into the target branch in the target repository.
///
/// This pushes the branch first (if needed), then performs a merge in the target repo.
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 9159fd5..542339f 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -2711,6 +2711,9 @@ pub struct Directive {
pub local_path: Option<String>,
pub base_branch: Option<String>,
pub orchestrator_task_id: Option<Uuid>,
+ pub pr_url: Option<String>,
+ pub pr_branch: Option<String>,
+ pub completion_task_id: Option<Uuid>,
pub goal_updated_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub version: i32,
@@ -2758,6 +2761,8 @@ pub struct DirectiveSummary {
pub status: String,
pub repository_url: Option<String>,
pub orchestrator_task_id: Option<Uuid>,
+ pub pr_url: Option<String>,
+ pub completion_task_id: Option<Uuid>,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -2797,6 +2802,8 @@ pub struct UpdateDirectiveRequest {
pub local_path: Option<String>,
pub base_branch: Option<String>,
pub orchestrator_task_id: Option<Uuid>,
+ pub pr_url: Option<String>,
+ pub pr_branch: Option<String>,
pub version: Option<i32>,
}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 323e74e..358ab48 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -4991,7 +4991,8 @@ pub async fn list_directives_for_owner(
r#"
SELECT
d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url,
- d.orchestrator_task_id, d.version, d.created_at, d.updated_at,
+ d.orchestrator_task_id, d.pr_url, d.completion_task_id,
+ d.version, d.created_at, d.updated_at,
COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id), 0) as total_steps,
COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'completed'), 0) as completed_steps,
COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'running'), 0) as running_steps,
@@ -5043,12 +5044,15 @@ pub async fn update_directive_for_owner(
let local_path = req.local_path.as_deref().or(current.local_path.as_deref());
let base_branch = req.base_branch.as_deref().or(current.base_branch.as_deref());
let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id);
+ let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref());
+ let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref());
let result = sqlx::query_as::<_, Directive>(
r#"
UPDATE directives
SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7,
- base_branch = $8, orchestrator_task_id = $9, version = version + 1, updated_at = NOW()
+ base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11,
+ version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
"#,
@@ -5062,6 +5066,8 @@ pub async fn update_directive_for_owner(
.bind(local_path)
.bind(base_branch)
.bind(orchestrator_task_id)
+ .bind(pr_url)
+ .bind(pr_branch)
.fetch_optional(pool)
.await
.map_err(RepositoryError::Database)?;
@@ -5096,6 +5102,119 @@ pub async fn delete_directive_for_owner(
}
// =============================================================================
+// Directive Completion Helpers
+// =============================================================================
+
+/// Row type for completed step tasks.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct CompletedStepTask {
+ pub step_name: String,
+ pub task_id: Uuid,
+ pub task_name: String,
+}
+
+/// Row type for directive completion task status check.
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct DirectiveCompletionCheck {
+ pub directive_id: Uuid,
+ pub completion_task_id: Uuid,
+ pub task_status: String,
+ pub pr_url: Option<String>,
+}
+
+/// Get idle directives that need a completion task spawned.
+/// Conditions: status = 'idle', no completion_task_id, has repository_url,
+/// and has at least one completed step with a task_id.
+pub async fn get_idle_directives_needing_completion(
+ pool: &PgPool,
+) -> Result<Vec<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ SELECT d.*
+ FROM directives d
+ WHERE d.status = 'idle'
+ AND d.completion_task_id IS NULL
+ AND d.repository_url IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM directive_steps ds
+ WHERE ds.directive_id = d.id
+ AND ds.status = 'completed'
+ AND ds.task_id IS NOT NULL
+ )
+ "#,
+ )
+ .fetch_all(pool)
+ .await
+}
+
+/// Get directives with active completion tasks, joined with task status.
+pub async fn get_completion_tasks_to_check(
+ pool: &PgPool,
+) -> Result<Vec<DirectiveCompletionCheck>, sqlx::Error> {
+ sqlx::query_as::<_, DirectiveCompletionCheck>(
+ r#"
+ SELECT d.id as directive_id, d.completion_task_id, t.status as task_status, d.pr_url
+ FROM directives d
+ JOIN tasks t ON t.id = d.completion_task_id
+ WHERE d.completion_task_id IS NOT NULL
+ "#,
+ )
+ .fetch_all(pool)
+ .await
+}
+
+/// Assign a completion task to a directive.
+pub async fn assign_completion_task(
+ pool: &PgPool,
+ directive_id: Uuid,
+ task_id: Uuid,
+) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r#"UPDATE directives SET completion_task_id = $2, updated_at = NOW() WHERE id = $1"#,
+ )
+ .bind(directive_id)
+ .bind(task_id)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+/// Clear the completion task from a directive.
+pub async fn clear_completion_task(
+ pool: &PgPool,
+ directive_id: Uuid,
+) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r#"UPDATE directives SET completion_task_id = NULL, updated_at = NOW() WHERE id = $1"#,
+ )
+ .bind(directive_id)
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+/// Get completed step tasks for a directive (steps that have completed with an assigned task).
+pub async fn get_completed_step_tasks(
+ pool: &PgPool,
+ directive_id: Uuid,
+) -> Result<Vec<CompletedStepTask>, sqlx::Error> {
+ sqlx::query_as::<_, CompletedStepTask>(
+ r#"
+ SELECT ds.name as step_name, ds.task_id, t.name as task_name
+ FROM directive_steps ds
+ JOIN tasks t ON t.id = ds.task_id
+ WHERE ds.directive_id = $1
+ AND ds.status = 'completed'
+ AND ds.task_id IS NOT NULL
+ ORDER BY ds.order_index, ds.created_at
+ "#,
+ )
+ .bind(directive_id)
+ .fetch_all(pool)
+ .await
+}
+
+// =============================================================================
// Directive Step CRUD
// =============================================================================
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 744977e..6121529 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -29,6 +29,7 @@ impl DirectiveOrchestrator {
self.phase_execution().await?;
self.phase_monitoring().await?;
self.phase_replanning().await?;
+ self.phase_completion().await?;
Ok(())
}
@@ -330,7 +331,7 @@ impl DirectiveOrchestrator {
target_branch: None,
merge_mode: None,
target_repo_path: None,
- completion_action: None,
+ completion_action: Some("branch".to_string()),
continue_from_task_id: None,
copy_files: None,
checkpoint_sha: None,
@@ -395,7 +396,7 @@ impl DirectiveOrchestrator {
depth: 0,
is_orchestrator: false,
target_repo_path: None,
- completion_action: None,
+ completion_action: updated_task.completion_action.clone(),
continue_from_task_id: None,
copy_files: None,
contract_id: None,
@@ -437,6 +438,163 @@ impl DirectiveOrchestrator {
}
false
}
+
+ /// Phase 5: Completion — spawn PR-creation tasks for idle directives.
+ async fn phase_completion(&self) -> Result<(), anyhow::Error> {
+ // Part 1: Spawn completion tasks for idle directives
+ let directives = repository::get_idle_directives_needing_completion(&self.pool).await?;
+
+ for directive in directives {
+ tracing::info!(
+ directive_id = %directive.id,
+ title = %directive.title,
+ "Directive idle — spawning completion task for PR"
+ );
+
+ let step_tasks = repository::get_completed_step_tasks(&self.pool, directive.id).await?;
+ if step_tasks.is_empty() {
+ continue;
+ }
+
+ let base_branch = directive
+ .base_branch
+ .as_deref()
+ .unwrap_or("main");
+
+ let directive_branch = format!(
+ "makima/directive-{}-{}",
+ crate::daemon::worktree::sanitize_name(&directive.title),
+ crate::daemon::worktree::short_uuid(directive.id),
+ );
+
+ // Compute step branch names using the same formula as execute_completion_action
+ let step_branches: Vec<String> = step_tasks
+ .iter()
+ .map(|st| {
+ format!(
+ "makima/{}-{}",
+ crate::daemon::worktree::sanitize_name(&st.task_name),
+ crate::daemon::worktree::short_uuid(st.task_id),
+ )
+ })
+ .collect();
+
+ let prompt = build_completion_prompt(
+ &directive,
+ &step_tasks,
+ &step_branches,
+ &directive_branch,
+ base_branch,
+ );
+
+ match self
+ .spawn_completion_task(
+ directive.id,
+ directive.owner_id,
+ format!("PR: {}", directive.title),
+ prompt,
+ directive.repository_url.as_deref(),
+ directive.base_branch.as_deref(),
+ )
+ .await
+ {
+ Ok(task_id) => {
+ // Store pr_branch on directive immediately
+ let update = crate::db::models::UpdateDirectiveRequest {
+ pr_branch: Some(directive_branch.clone()),
+ ..Default::default()
+ };
+ let _ = repository::update_directive_for_owner(
+ &self.pool,
+ directive.owner_id,
+ directive.id,
+ update,
+ )
+ .await;
+ repository::assign_completion_task(&self.pool, directive.id, task_id).await?;
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %directive.id,
+ error = %e,
+ "Failed to spawn completion task"
+ );
+ }
+ }
+ }
+
+ // Part 2: Monitor completion tasks
+ let checks = repository::get_completion_tasks_to_check(&self.pool).await?;
+
+ for check in checks {
+ match check.task_status.as_str() {
+ "completed" | "merged" | "done" => {
+ tracing::info!(
+ directive_id = %check.directive_id,
+ task_id = %check.completion_task_id,
+ "Completion task finished"
+ );
+ repository::clear_completion_task(&self.pool, check.directive_id).await?;
+ }
+ "failed" | "interrupted" => {
+ tracing::warn!(
+ directive_id = %check.directive_id,
+ task_id = %check.completion_task_id,
+ "Completion task failed"
+ );
+ repository::clear_completion_task(&self.pool, check.directive_id).await?;
+ }
+ _ => {
+ // Still running
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Spawn a completion task that creates/updates a PR from step branches.
+ async fn spawn_completion_task(
+ &self,
+ directive_id: Uuid,
+ owner_id: Uuid,
+ name: String,
+ plan: String,
+ repo_url: Option<&str>,
+ base_branch: Option<&str>,
+ ) -> Result<Uuid, anyhow::Error> {
+ let req = CreateTaskRequest {
+ contract_id: None,
+ name,
+ description: Some("Directive PR completion task".to_string()),
+ plan,
+ parent_task_id: None,
+ is_supervisor: false,
+ priority: 0,
+ repository_url: repo_url.map(|s| s.to_string()),
+ base_branch: base_branch.map(|s| s.to_string()),
+ target_branch: None,
+ merge_mode: None,
+ target_repo_path: None,
+ completion_action: None,
+ continue_from_task_id: None,
+ copy_files: None,
+ checkpoint_sha: None,
+ branched_from_task_id: None,
+ conversation_history: None,
+ supervisor_worktree_task_id: None,
+ directive_id: Some(directive_id),
+ directive_step_id: None,
+ };
+
+ let task = repository::create_task_for_owner(&self.pool, owner_id, req).await?;
+
+ // Try to dispatch to a daemon
+ self.try_dispatch_task(task.id, owner_id, &task.name, &task.plan, task.version)
+ .await;
+
+ Ok(task.id)
+ }
}
/// Build the planning prompt for a directive.
@@ -504,3 +662,95 @@ IMPORTANT: Each step's taskPlan must be self-contained. The executing instance w
prompt
}
+
+/// Build the prompt for a completion task that creates or updates a PR.
+fn build_completion_prompt(
+ directive: &crate::db::models::Directive,
+ step_tasks: &[crate::db::repository::CompletedStepTask],
+ step_branches: &[String],
+ directive_branch: &str,
+ base_branch: &str,
+) -> String {
+ let merge_commands: String = step_branches
+ .iter()
+ .map(|b| format!("git merge origin/{} --no-edit", b))
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let step_summary: String = step_tasks
+ .iter()
+ .zip(step_branches.iter())
+ .map(|(st, branch)| format!("- {} (branch: {})", st.step_name, branch))
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ if directive.pr_url.is_some() {
+ // Re-completion: PR already exists, merge new branches into existing PR branch
+ format!(
+ r#"You are updating an existing PR for directive "{title}".
+
+The PR branch `{directive_branch}` already exists. Merge any new step branches into it.
+
+Steps completed:
+{step_summary}
+
+Run these commands:
+```
+git fetch origin
+git checkout {directive_branch}
+git pull origin {directive_branch}
+{merge_commands}
+git push origin {directive_branch}
+```
+
+Already-merged branches will be a no-op. If there are merge conflicts, resolve them sensibly.
+"#,
+ title = directive.title,
+ directive_branch = directive_branch,
+ step_summary = step_summary,
+ merge_commands = merge_commands,
+ )
+ } else {
+ // First completion: create new PR
+ format!(
+ r#"You are creating a PR for directive "{title}".
+
+Goal: {goal}
+
+Steps completed:
+{step_summary}
+
+Run these commands to create a combined branch and PR:
+```
+git fetch origin
+git checkout -b {directive_branch} origin/{base_branch}
+{merge_commands}
+git push -u origin {directive_branch}
+```
+
+Then create the PR:
+```
+gh pr create --title "{title}" --body "{pr_body}" --head {directive_branch} --base {base_branch}
+```
+
+After creating the PR, store the URL:
+```
+makima directive update --pr-url "<the PR URL from gh pr create output>"
+```
+
+If there are merge conflicts, resolve them sensibly before pushing.
+"#,
+ title = directive.title,
+ goal = directive.goal,
+ directive_branch = directive_branch,
+ base_branch = base_branch,
+ step_summary = step_summary,
+ merge_commands = merge_commands,
+ pr_body = format!(
+ "## Directive\\n\\n{}\\n\\n## Steps\\n\\n{}",
+ directive.goal.replace('\n', "\\n").replace('"', "\\\""),
+ step_summary.replace('\n', "\\n").replace('"', "\\\""),
+ ),
+ )
+ }
+}