summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-26 22:12:57 +0000
committerGitHub <noreply@github.com>2026-01-26 22:12:57 +0000
commitd1f5dadb549d499c5aeee9cacf6c9aa0a233c198 (patch)
treea47e3d68a6b25bc39044a52b63099a199dce677d
parentbc1ce8013bc36a1585be05b928f2386ab56529c2 (diff)
downloadsoryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.tar.gz
soryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.zip
Add local-only mode for contracts with patch export support (#34)
* Add local_only flag to contracts database and models Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Task completion checkpoint * Skip automatic completion actions in local_only mode Add `local_only` flag to contracts that prevents automatic completion actions (branch, merge, pr) from executing when tasks complete. This allows users to manually handle code changes via patch files or other means when operating in local-only mode. Changes: - Add `local_only` field to Contract model and request types - Add database migration for the new column - Add `local_only` parameter to SpawnTask command in both state.rs and daemon protocol.rs - Modify task manager to skip completion action execution when `local_only` is true, with appropriate logging - Pass `local_only` flag through all task spawning paths: - mesh_supervisor.rs (task spawn, retry, resume) - mesh.rs (task start, reassign, continue) - mesh_chat.rs (run task) - contract_chat.rs (run task) - Update repository create/update functions to handle `local_only` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Task completion checkpoint * Implement core patch export system Add functionality to create uncompressed, human-readable git patches for export. This enables users to generate patches that can be manually applied or shared, without the compression used for internal checkpoints. Changes: - Add ExportPatchResult struct with patch content, file count, and line stats - Add create_export_patch() function that generates diffs against a base SHA - Add get_head_sha() utility function - Add parse_diff_stat() helper to extract line counts from git output - Add CreateExportPatch command to daemon protocol - Add ExportPatchCreated response message to protocol - Add handler in task manager to process export patch requests - Add server-side handling to broadcast patch results to UI The export patch system automatically finds the merge-base when no base SHA is provided, trying upstream tracking branch first, then common default branches (origin/main, origin/master, main, master). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Task completion checkpoint * Add GitActionsPanel frontend component * Add WorktreeFilesPanel and PatchesListPanel components * Add local-only mode toggle to contract creation --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--makima/frontend/src/components/contracts/ContractDetail.tsx5
-rw-r--r--makima/frontend/src/components/contracts/ContractList.tsx15
-rw-r--r--makima/frontend/src/components/mesh/GitActionsPanel.tsx181
-rw-r--r--makima/frontend/src/components/mesh/PatchesListPanel.tsx236
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx25
-rw-r--r--makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx197
-rw-r--r--makima/frontend/src/lib/api.ts215
-rw-r--r--makima/frontend/src/routes/contracts.tsx44
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20250127000000_add_local_only.sql8
-rw-r--r--makima/migrations/20260126000000_add_local_only_mode.sql7
-rw-r--r--makima/src/daemon/storage/mod.rs5
-rw-r--r--makima/src/daemon/storage/patch.rs303
-rw-r--r--makima/src/daemon/task/manager.rs106
-rw-r--r--makima/src/daemon/ws/protocol.rs38
-rw-r--r--makima/src/db/models.rs18
-rw-r--r--makima/src/db/repository.rs19
-rw-r--r--makima/src/server/handlers/contract_chat.rs12
-rw-r--r--makima/src/server/handlers/contracts.rs4
-rw-r--r--makima/src/server/handlers/mesh.rs46
-rw-r--r--makima/src/server/handlers/mesh_chat.rs11
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs92
-rw-r--r--makima/src/server/handlers/mesh_supervisor.rs13
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs1
-rw-r--r--makima/src/server/state.rs3
25 files changed, 1587 insertions, 19 deletions
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx
index 90b6967..6e31c84 100644
--- a/makima/frontend/src/components/contracts/ContractDetail.tsx
+++ b/makima/frontend/src/components/contracts/ContractDetail.tsx
@@ -177,6 +177,11 @@ export function ContractDetail({
>
{statusConfig[contract.status].label}
</span>
+ {contract.localOnly && (
+ <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10">
+ Local-Only
+ </span>
+ )}
</div>
{contract.description && (
<p className="font-mono text-sm text-[#9bc3ff] mb-3">
diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx
index ebde497..98f8ff6 100644
--- a/makima/frontend/src/components/contracts/ContractList.tsx
+++ b/makima/frontend/src/components/contracts/ContractList.tsx
@@ -127,11 +127,18 @@ export function ContractList({
`}
>
<div className="flex items-start justify-between gap-2 mb-2">
- <h3 className="font-mono text-sm text-[#dbe7ff] truncate">
- {contract.name}
- </h3>
+ <div className="flex items-center gap-2 min-w-0">
+ <h3 className="font-mono text-sm text-[#dbe7ff] truncate">
+ {contract.name}
+ </h3>
+ {contract.localOnly && (
+ <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0">
+ Local
+ </span>
+ )}
+ </div>
<span
- className={`text-[10px] font-mono uppercase ${
+ className={`text-[10px] font-mono uppercase shrink-0 ${
statusColors[contract.status]
}`}
>
diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx
new file mode 100644
index 0000000..be2e06d
--- /dev/null
+++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx
@@ -0,0 +1,181 @@
+import { useState, useCallback } from "react";
+import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api";
+
+interface GitActionsPanelProps {
+ taskId: string;
+ isLocalOnly: boolean;
+ taskStatus: string;
+}
+
+interface ToastMessage {
+ type: "success" | "error" | "info";
+ message: string;
+}
+
+export function GitActionsPanel({
+ taskId,
+ isLocalOnly,
+ taskStatus,
+}: GitActionsPanelProps) {
+ const [isExporting, setIsExporting] = useState(false);
+ const [isPushing, setIsPushing] = useState(false);
+ const [isCreatingPR, setIsCreatingPR] = useState(false);
+ const [toast, setToast] = useState<ToastMessage | null>(null);
+ const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(null);
+
+ // Only show for completed tasks
+ if (taskStatus !== "done") return null;
+
+ const showToast = (type: ToastMessage["type"], message: string) => {
+ setToast({ type, message });
+ setTimeout(() => setToast(null), 5000);
+ };
+
+ const handleExportPatch = useCallback(async () => {
+ setIsExporting(true);
+ try {
+ const result = await exportTaskPatch(taskId);
+ setExportedPatch(result);
+ showToast("success", `Patch generated: ${result.fileName}`);
+ } catch (e) {
+ showToast("error", e instanceof Error ? e.message : "Failed to generate patch");
+ } finally {
+ setIsExporting(false);
+ }
+ }, [taskId]);
+
+ const handlePushBranch = useCallback(async () => {
+ if (isLocalOnly) {
+ showToast("error", "Push disabled in local-only mode");
+ return;
+ }
+ setIsPushing(true);
+ try {
+ const result = await pushTaskBranch(taskId);
+ showToast("success", `Branch pushed: ${result.branchName}`);
+ } catch (e) {
+ showToast("error", e instanceof Error ? e.message : "Failed to push branch");
+ } finally {
+ setIsPushing(false);
+ }
+ }, [taskId, isLocalOnly]);
+
+ const handleCreatePR = useCallback(async () => {
+ if (isLocalOnly) {
+ showToast("error", "PR creation disabled in local-only mode");
+ return;
+ }
+ setIsCreatingPR(true);
+ try {
+ const result = await createTaskPR(taskId);
+ if (result.prUrl) {
+ showToast("success", `PR created: ${result.prUrl}`);
+ // Open PR in new tab
+ window.open(result.prUrl, "_blank", "noopener,noreferrer");
+ } else {
+ showToast("success", "PR creation initiated");
+ }
+ } catch (e) {
+ showToast("error", e instanceof Error ? e.message : "Failed to create PR");
+ } finally {
+ setIsCreatingPR(false);
+ }
+ }, [taskId, isLocalOnly]);
+
+ return (
+ <div className="space-y-3">
+ {/* Section Header */}
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Git Actions
+ </div>
+
+ {/* Local-only mode alert */}
+ {isLocalOnly && (
+ <div className="bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 font-mono text-xs text-yellow-400 flex items-center gap-2">
+ <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full" />
+ Contract is in local-only mode. Push/PR actions disabled.
+ </div>
+ )}
+
+ {/* Toast notification */}
+ {toast && (
+ <div
+ className={`px-3 py-2 font-mono text-xs border ${
+ toast.type === "success"
+ ? "bg-green-400/10 border-green-400/30 text-green-400"
+ : toast.type === "error"
+ ? "bg-red-400/10 border-red-400/30 text-red-400"
+ : "bg-blue-400/10 border-blue-400/30 text-blue-400"
+ }`}
+ >
+ {toast.message}
+ </div>
+ )}
+
+ {/* Action buttons */}
+ <div className="flex flex-wrap gap-2">
+ <button
+ onClick={handleExportPatch}
+ disabled={isExporting}
+ className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isExporting ? "Generating..." : "Generate Patch"}
+ </button>
+
+ <button
+ onClick={handlePushBranch}
+ disabled={isPushing || isLocalOnly}
+ className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+ isLocalOnly
+ ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed"
+ : "text-cyan-400 border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10"
+ }`}
+ title={isLocalOnly ? "Disabled in local-only mode" : "Push changes to remote branch"}
+ >
+ {isPushing ? "Pushing..." : "Push Remote Branch"}
+ </button>
+
+ <button
+ onClick={handleCreatePR}
+ disabled={isCreatingPR || isLocalOnly}
+ className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+ isLocalOnly
+ ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed"
+ : "text-green-400 border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10"
+ }`}
+ title={isLocalOnly ? "Disabled in local-only mode" : "Create pull request"}
+ >
+ {isCreatingPR ? "Creating PR..." : "Create PR"}
+ </button>
+ </div>
+
+ {/* Export result info */}
+ {exportedPatch && (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2">
+ <div className="font-mono text-xs text-[#555]">Exported Patch</div>
+ <div className="font-mono text-xs text-[#75aafc] break-all">
+ <span className="text-[#555]">File:</span> {exportedPatch.fileName}
+ </div>
+ {exportedPatch.filePath && (
+ <div className="font-mono text-xs text-[#75aafc] break-all">
+ <span className="text-[#555]">Path:</span> {exportedPatch.filePath}
+ </div>
+ )}
+ {exportedPatch.patchSize && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Size:</span> {formatBytes(exportedPatch.patchSize)}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
diff --git a/makima/frontend/src/components/mesh/PatchesListPanel.tsx b/makima/frontend/src/components/mesh/PatchesListPanel.tsx
new file mode 100644
index 0000000..b63d7e7
--- /dev/null
+++ b/makima/frontend/src/components/mesh/PatchesListPanel.tsx
@@ -0,0 +1,236 @@
+import { useState, useEffect, useCallback } from "react";
+import type { PatchSummary } from "../../lib/api";
+import { listTaskPatches } from "../../lib/api";
+
+interface PatchesListPanelProps {
+ taskId: string;
+ contractId: string;
+}
+
+/** Format a date for display */
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/** Copy text to clipboard and show feedback */
+async function copyToClipboard(text: string): Promise<boolean> {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fallback for older browsers
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ document.execCommand("copy");
+ return true;
+ } catch {
+ return false;
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ }
+}
+
+export function PatchesListPanel({ taskId, contractId }: PatchesListPanelProps) {
+ const [patches, setPatches] = useState<PatchSummary[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [copiedPatchId, setCopiedPatchId] = useState<string | null>(null);
+ const [expandedPatchId, setExpandedPatchId] = useState<string | null>(null);
+
+ const fetchPatches = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const patchList = await listTaskPatches(taskId, contractId);
+ setPatches(patchList);
+ } catch (e) {
+ console.error("Failed to fetch patches:", e);
+ setError(e instanceof Error ? e.message : "Failed to fetch patches");
+ } finally {
+ setLoading(false);
+ }
+ }, [taskId, contractId]);
+
+ useEffect(() => {
+ fetchPatches();
+ }, [fetchPatches]);
+
+ const handleCopyApplyCommand = useCallback(async (patch: PatchSummary) => {
+ // Generate the apply command for this patch
+ const command = `makima patch apply ${patch.id}`;
+ const success = await copyToClipboard(command);
+ if (success) {
+ setCopiedPatchId(patch.id);
+ setTimeout(() => setCopiedPatchId(null), 2000);
+ }
+ }, []);
+
+ const handleViewPatch = useCallback((patchId: string) => {
+ setExpandedPatchId(expandedPatchId === patchId ? null : patchId);
+ }, [expandedPatchId]);
+
+ if (loading) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2">
+ Exported Patches
+ </div>
+ <div className="font-mono text-xs text-[#555] animate-pulse">
+ Loading patches...
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ <div className="font-mono text-xs text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (patches.length === 0) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Refresh
+ </button>
+ </div>
+ <div className="font-mono text-xs text-[#555]">
+ No patches exported yet
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Exported Patches
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {patches.length} patch{patches.length !== 1 ? "es" : ""}
+ </span>
+ <button
+ onClick={fetchPatches}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Refresh"
+ >
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {/* Patch list */}
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {patches.map((patch) => (
+ <div
+ key={patch.id}
+ className="p-3"
+ >
+ {/* Patch header */}
+ <div className="flex items-start justify-between gap-2 mb-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm text-[#dbe7ff] truncate" title={patch.name}>
+ {patch.name}
+ </div>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 font-mono text-[10px] text-[#555]">
+ <span title={new Date(patch.createdAt).toLocaleString()}>
+ {formatDate(patch.createdAt)}
+ </span>
+ <span>
+ {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""}
+ </span>
+ <span className="text-green-400">+{patch.linesAdded}</span>
+ <span className="text-red-400">-{patch.linesRemoved}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action buttons */}
+ <div className="flex flex-wrap gap-2">
+ <button
+ onClick={() => handleViewPatch(patch.id)}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ {expandedPatchId === patch.id ? "Hide" : "View"}
+ </button>
+ <button
+ onClick={() => handleCopyApplyCommand(patch)}
+ className={`px-2 py-1 font-mono text-[10px] border transition-colors ${
+ copiedPatchId === patch.id
+ ? "text-green-400 border-green-400/30 bg-green-400/10"
+ : "text-[#9bc3ff] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)]"
+ }`}
+ >
+ {copiedPatchId === patch.id ? "Copied!" : "Copy Apply Command"}
+ </button>
+ </div>
+
+ {/* Expanded patch content */}
+ {expandedPatchId === patch.id && patch.description && (
+ <div className="mt-3 p-2 bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.1)]">
+ <pre className="font-mono text-[10px] text-[#75aafc] whitespace-pre-wrap overflow-x-auto">
+ {patch.description}
+ </pre>
+ </div>
+ )}
+
+ {/* Expanded - show files if available */}
+ {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && (
+ <div className="mt-2">
+ <div className="font-mono text-[10px] text-[#555] mb-1">Changed files:</div>
+ <div className="space-y-0.5">
+ {patch.files.map((file, idx) => (
+ <div key={idx} className="font-mono text-[10px] text-[#75aafc] pl-2">
+ {file}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
index 8936d28..fdcc58b 100644
--- a/makima/frontend/src/components/mesh/TaskDetail.tsx
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -7,6 +7,9 @@ import { PRPreview } from "./PRPreview";
import { InlineSubtaskEditor } from "./InlineSubtaskEditor";
import { DirectoryInput } from "./DirectoryInput";
import { BranchTaskModal } from "./BranchTaskModal";
+import { GitActionsPanel } from "./GitActionsPanel";
+import { WorktreeFilesPanel } from "./WorktreeFilesPanel";
+import { PatchesListPanel } from "./PatchesListPanel";
interface TaskDetailProps {
task: TaskWithSubtasks;
@@ -36,6 +39,8 @@ interface TaskDetailProps {
fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
/** For supervisor tasks: all tasks in the contract (excluding the supervisor itself) */
contractTasks?: TaskSummary[];
+ /** Whether the contract is in local-only mode (no push/PR) */
+ isLocalOnly?: boolean;
}
function formatDate(dateStr: string): string {
@@ -119,6 +124,7 @@ export function TaskDetail({
onAutoMerge,
fetchSubtasks,
contractTasks,
+ isLocalOnly = false,
}: TaskDetailProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(task.name);
@@ -879,6 +885,25 @@ export function TaskDetail({
)}
</div>
)}
+
+ {/* Git Actions Panel for manual git operations */}
+ {task.status === "done" && (
+ <GitActionsPanel
+ taskId={task.id}
+ isLocalOnly={isLocalOnly}
+ taskStatus={task.status}
+ />
+ )}
+
+ {/* Worktree Files Panel - show changed files in the worktree */}
+ {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && (
+ <WorktreeFilesPanel taskId={task.id} />
+ )}
+
+ {/* Patches List Panel - show exported patches for this task */}
+ {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && (
+ <PatchesListPanel taskId={task.id} contractId={task.contractId} />
+ )}
</div>
{/* Overlay Diff Modal */}
diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx
new file mode 100644
index 0000000..b529588
--- /dev/null
+++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx
@@ -0,0 +1,197 @@
+import { useState, useEffect, useCallback } from "react";
+import type { WorktreeInfo } from "../../lib/api";
+import { getWorktreeInfo } from "../../lib/api";
+
+interface WorktreeFilesPanelProps {
+ taskId: string;
+}
+
+/** Get status badge styling based on file status */
+function getStatusStyle(status: string): { color: string; bgColor: string; label: string } {
+ switch (status) {
+ case "M":
+ case "modified":
+ return { color: "text-yellow-400", bgColor: "bg-yellow-400/10", label: "M" };
+ case "A":
+ case "added":
+ return { color: "text-green-400", bgColor: "bg-green-400/10", label: "A" };
+ case "D":
+ case "deleted":
+ return { color: "text-red-400", bgColor: "bg-red-400/10", label: "D" };
+ case "R":
+ case "renamed":
+ return { color: "text-cyan-400", bgColor: "bg-cyan-400/10", label: "R" };
+ case "C":
+ case "copied":
+ return { color: "text-purple-400", bgColor: "bg-purple-400/10", label: "C" };
+ case "U":
+ case "unmerged":
+ return { color: "text-orange-400", bgColor: "bg-orange-400/10", label: "U" };
+ case "?":
+ case "untracked":
+ return { color: "text-[#555]", bgColor: "bg-[#555]/10", label: "?" };
+ default:
+ return { color: "text-[#9bc3ff]", bgColor: "bg-[rgba(117,170,252,0.1)]", label: status.charAt(0).toUpperCase() };
+ }
+}
+
+export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) {
+ const [worktreeInfo, setWorktreeInfo] = useState<WorktreeInfo | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [expanded, setExpanded] = useState(false);
+
+ const fetchWorktreeInfo = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const info = await getWorktreeInfo(taskId);
+ setWorktreeInfo(info);
+ } catch (e) {
+ console.error("Failed to fetch worktree info:", e);
+ setError(e instanceof Error ? e.message : "Failed to fetch worktree info");
+ } finally {
+ setLoading(false);
+ }
+ }, [taskId]);
+
+ useEffect(() => {
+ fetchWorktreeInfo();
+ }, [fetchWorktreeInfo]);
+
+ if (loading) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2">
+ Worktree Changes
+ </div>
+ <div className="font-mono text-xs text-[#555] animate-pulse">
+ Loading worktree info...
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Retry
+ </button>
+ </div>
+ <div className="font-mono text-xs text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (!worktreeInfo || worktreeInfo.files.length === 0) {
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ Refresh
+ </button>
+ </div>
+ <div className="font-mono text-xs text-[#555]">
+ No changes in worktree
+ </div>
+ </div>
+ );
+ }
+
+ const { stats, files } = worktreeInfo;
+ const displayFiles = expanded ? files : files.slice(0, 10);
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Worktree Changes
+ </div>
+ <div className="flex items-center gap-3">
+ {/* Stats */}
+ <div className="flex items-center gap-2 font-mono text-[10px]">
+ <span className="text-[#75aafc]">
+ {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""}
+ </span>
+ <span className="text-green-400">+{stats.insertions}</span>
+ <span className="text-red-400">-{stats.deletions}</span>
+ </div>
+ <button
+ onClick={fetchWorktreeInfo}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
+ title="Refresh"
+ >
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {/* File list */}
+ <div className="divide-y divide-[rgba(117,170,252,0.05)]">
+ {displayFiles.map((file) => {
+ const statusStyle = getStatusStyle(file.status);
+ return (
+ <div
+ key={file.path}
+ className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]"
+ >
+ {/* Status badge */}
+ <span
+ className={`w-5 h-5 flex items-center justify-center font-mono text-[10px] font-medium ${statusStyle.color} ${statusStyle.bgColor} border border-current/20`}
+ title={file.status}
+ >
+ {statusStyle.label}
+ </span>
+
+ {/* File path */}
+ <span className="flex-1 font-mono text-xs text-[#dbe7ff] truncate" title={file.path}>
+ {file.path}
+ </span>
+
+ {/* Line stats */}
+ <div className="flex items-center gap-1.5 font-mono text-[10px] shrink-0">
+ {file.linesAdded > 0 && (
+ <span className="text-green-400">+{file.linesAdded}</span>
+ )}
+ {file.linesRemoved > 0 && (
+ <span className="text-red-400">-{file.linesRemoved}</span>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {/* Show more/less button */}
+ {files.length > 10 && (
+ <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)]">
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ {expanded ? `Show less` : `Show ${files.length - 10} more files...`}
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index b3c18a5..7c9fcd6 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -811,6 +811,112 @@ export async function retryCompletionAction(
return res.json();
}
+// =============================================================================
+// Git Actions for Tasks (Manual)
+// =============================================================================
+
+/** Response from export patch */
+export interface ExportPatchResponse {
+ success: boolean;
+ taskId: string;
+ fileName: string;
+ filePath?: string;
+ patchSize?: number;
+ message: string;
+}
+
+/**
+ * Export a task's changes as a patch file.
+ * The patch will be saved to the contract's patch directory.
+ */
+export async function exportTaskPatch(
+ taskId: string,
+ fileName?: string
+): Promise<ExportPatchResponse> {
+ const body: Record<string, unknown> = {};
+ if (fileName) {
+ body.fileName = fileName;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/export-patch`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to export patch: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Response from push branch */
+export interface PushBranchResponse {
+ success: boolean;
+ taskId: string;
+ branchName: string;
+ remote?: string;
+ message: string;
+}
+
+/**
+ * Push a task's changes to a remote branch.
+ * Creates a branch if it doesn't exist and pushes the commits.
+ */
+export async function pushTaskBranch(
+ taskId: string,
+ branchName?: string
+): Promise<PushBranchResponse> {
+ const body: Record<string, unknown> = {};
+ if (branchName) {
+ body.branchName = branchName;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/push-branch`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to push branch: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Response from create PR */
+export interface CreatePRResponse {
+ success: boolean;
+ taskId: string;
+ prUrl?: string;
+ prNumber?: number;
+ branchName?: string;
+ message: string;
+}
+
+/**
+ * Create a pull request for a task's changes.
+ * First pushes the branch if needed, then creates the PR.
+ */
+export async function createTaskPR(
+ taskId: string,
+ title?: string,
+ body?: string
+): Promise<CreatePRResponse> {
+ const reqBody: Record<string, unknown> = {};
+ if (title) {
+ reqBody.title = title;
+ }
+ if (body) {
+ reqBody.body = body;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/create-pr`, {
+ method: "POST",
+ body: JSON.stringify(reqBody),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to create PR: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
/** A suggested directory from a connected daemon */
export interface DaemonDirectory {
/** Path to the directory */
@@ -1567,6 +1673,8 @@ export interface ContractSummary {
status: ContractStatus;
/** Supervisor task ID for contract orchestration */
supervisorTaskId: string | null;
+ /** When true, tasks won't auto-push or create PRs - use patch files instead */
+ localOnly: boolean;
fileCount: number;
taskCount: number;
repositoryCount: number;
@@ -1589,6 +1697,8 @@ export interface Contract {
autonomousLoop: boolean;
/** Whether to wait for user confirmation before progressing to the next phase */
phaseGuard: boolean;
+ /** When true, tasks won't auto-push or create PRs - use patch files instead */
+ localOnly: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -1622,6 +1732,8 @@ export interface CreateContractRequest {
contractType?: ContractType;
/** Initial phase to start in (defaults based on contract type) */
initialPhase?: ContractPhase;
+ /** When true, tasks won't auto-push or create PRs - use patch files instead */
+ localOnly?: boolean;
}
export interface UpdateContractRequest {
@@ -2710,3 +2822,106 @@ export function getSupervisorStatus(
export async function dismissTask(taskId: string): Promise<Task> {
return updateTask(taskId, { hidden: true });
}
+
+// =============================================================================
+// Worktree Info Types and Functions
+// =============================================================================
+
+/** File status in the worktree (git status) */
+export type FileStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "modified" | "added" | "deleted" | "renamed" | "copied" | "unmerged" | "untracked";
+
+/** A single changed file in the worktree */
+export interface WorktreeFile {
+ /** File path relative to worktree root */
+ path: string;
+ /** Git status code (M=modified, A=added, D=deleted, R=renamed, C=copied, U=unmerged, ?=untracked) */
+ status: FileStatus;
+ /** Lines added (0 if deleted or unavailable) */
+ linesAdded: number;
+ /** Lines removed (0 if added or unavailable) */
+ linesRemoved: number;
+}
+
+/** Statistics about worktree changes */
+export interface WorktreeStats {
+ /** Number of files changed */
+ filesChanged: number;
+ /** Total lines inserted */
+ insertions: number;
+ /** Total lines deleted */
+ deletions: number;
+}
+
+/** Worktree information for a task */
+export interface WorktreeInfo {
+ /** Task ID */
+ taskId: string;
+ /** Path to the worktree directory */
+ worktreePath: string | null;
+ /** Whether the worktree exists on the daemon */
+ exists: boolean;
+ /** Aggregate statistics */
+ stats: WorktreeStats;
+ /** Changed files list */
+ files: WorktreeFile[];
+ /** Current branch name */
+ branch: string | null;
+ /** Current HEAD commit SHA */
+ headSha: string | null;
+}
+
+/**
+ * Get worktree information for a task.
+ * Returns changed files, stats, and metadata about the worktree.
+ */
+export async function getWorktreeInfo(taskId: string): Promise<WorktreeInfo> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-info`);
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to get worktree info: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Patch Types and Functions
+// =============================================================================
+
+/** Summary of a patch file (contract file of type "patch") */
+export interface PatchSummary {
+ /** Patch/file ID */
+ id: string;
+ /** Patch name */
+ name: string;
+ /** Optional description */
+ description: string | null;
+ /** Task ID this patch was created from */
+ taskId: string | null;
+ /** Contract ID */
+ contractId: string;
+ /** Number of files in the patch */
+ filesCount: number;
+ /** Total lines added */
+ linesAdded: number;
+ /** Total lines removed */
+ linesRemoved: number;
+ /** List of file paths in the patch (if available) */
+ files: string[] | null;
+ /** When the patch was created */
+ createdAt: string;
+ /** When the patch was last updated */
+ updatedAt: string;
+}
+
+/**
+ * List patches for a task.
+ * Returns contract files of type "patch" associated with the task.
+ */
+export async function listTaskPatches(taskId: string, contractId: string): Promise<PatchSummary[]> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/patches?contractId=${contractId}`);
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to list patches: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
index 36eb980..aa62bd9 100644
--- a/makima/frontend/src/routes/contracts.tsx
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -92,6 +92,7 @@ function ContractsPageContent() {
const [showRepoSuggestions, setShowRepoSuggestions] = useState(false);
const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]);
const [contractTypesLoading, setContractTypesLoading] = useState(false);
+ const [localOnly, setLocalOnly] = useState(false);
// Fetch contract types when modal opens - merges built-in types with user templates
useEffect(() => {
@@ -263,6 +264,7 @@ function ContractsPageContent() {
description: newContractDescription.trim() || undefined,
contractType: contractType,
initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined,
+ localOnly: localOnly || undefined,
};
try {
@@ -303,6 +305,7 @@ function ContractsPageContent() {
setRepoName("");
setRepoUrl("");
setRepoPath("");
+ setLocalOnly(false);
navigate(`/contracts/${contract.id}`);
}
} catch (err) {
@@ -336,6 +339,7 @@ function ContractsPageContent() {
setRepoName("");
setRepoUrl("");
setRepoPath("");
+ setLocalOnly(false);
setCreateError(null);
}, []);
@@ -661,6 +665,46 @@ function ContractsPageContent() {
</p>
</div>
+ {/* Local-Only Mode */}
+ <div className="space-y-2">
+ <div className="flex items-center space-x-3">
+ <button
+ type="button"
+ onClick={() => setLocalOnly(!localOnly)}
+ className={`w-5 h-5 flex items-center justify-center border transition-colors ${
+ localOnly
+ ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]"
+ : "bg-[#0d1b2d] border-[#3f6fb3] text-transparent"
+ }`}
+ >
+ {localOnly && (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="3"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="w-3 h-3"
+ >
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+ )}
+ </button>
+ <label
+ className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none"
+ onClick={() => setLocalOnly(!localOnly)}
+ >
+ Local-Only Mode
+ </label>
+ </div>
+ <p className="font-mono text-xs text-[#8b949e] pl-8">
+ When enabled, tasks won't automatically push to remote or create PRs.
+ Use patch files to export changes.
+ </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">
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 538c301..eff7718 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/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.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/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.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/templates/templateeditor.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.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/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/templates.tsx","./src/routes/workflow.tsx","./src/types/messages.ts","./src/types/templates.ts"],"errors":true,"version":"5.9.3"}
+{"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/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/autopilotpanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.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/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.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/templates/templateeditor.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.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/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/templates.tsx","./src/routes/workflow.tsx","./src/types/messages.ts","./src/types/templates.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20250127000000_add_local_only.sql b/makima/migrations/20250127000000_add_local_only.sql
new file mode 100644
index 0000000..2cd594e
--- /dev/null
+++ b/makima/migrations/20250127000000_add_local_only.sql
@@ -0,0 +1,8 @@
+-- Add local_only column to contracts table
+-- When enabled, automatic completion actions (branch, merge, pr) are skipped,
+-- allowing users to manually handle code changes via patch files or other means.
+
+ALTER TABLE contracts
+ADD COLUMN IF NOT EXISTS local_only BOOLEAN NOT NULL DEFAULT FALSE;
+
+COMMENT ON COLUMN contracts.local_only IS 'Whether to skip automatic completion actions (branch, merge, pr) for this contract';
diff --git a/makima/migrations/20260126000000_add_local_only_mode.sql b/makima/migrations/20260126000000_add_local_only_mode.sql
new file mode 100644
index 0000000..27af087
--- /dev/null
+++ b/makima/migrations/20260126000000_add_local_only_mode.sql
@@ -0,0 +1,7 @@
+-- Add local_only column to contracts
+ALTER TABLE contracts ADD COLUMN local_only BOOLEAN NOT NULL DEFAULT false;
+
+-- Index for filtering by local_only mode
+CREATE INDEX idx_contracts_local_only ON contracts(local_only) WHERE local_only = true;
+
+COMMENT ON COLUMN contracts.local_only IS 'When true, tasks do not auto-execute completion actions and work stays in worktrees';
diff --git a/makima/src/daemon/storage/mod.rs b/makima/src/daemon/storage/mod.rs
index cc5441a..e5457f7 100644
--- a/makima/src/daemon/storage/mod.rs
+++ b/makima/src/daemon/storage/mod.rs
@@ -5,4 +5,7 @@
mod patch;
-pub use patch::{create_patch, apply_patch, PatchError};
+pub use patch::{
+ apply_patch, create_export_patch, create_patch, get_head_sha, get_parent_sha, ExportPatchResult,
+ PatchError,
+};
diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs
index 45624b5..0da4eda 100644
--- a/makima/src/daemon/storage/patch.rs
+++ b/makima/src/daemon/storage/patch.rs
@@ -141,6 +141,223 @@ pub async fn get_parent_sha(worktree_path: &Path) -> Result<String, PatchError>
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
+/// Get the current HEAD commit SHA from a worktree.
+pub async fn get_head_sha(worktree_path: &Path) -> Result<String, PatchError> {
+ let output = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .await
+ .map_err(|e| PatchError::GitCommand(format!("Failed to get HEAD SHA: {}", e)))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(PatchError::GitCommand(format!(
+ "git rev-parse HEAD failed: {}",
+ stderr
+ )));
+ }
+
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}
+
+/// Result of creating an export patch.
+#[derive(Debug, Clone)]
+pub struct ExportPatchResult {
+ /// The uncompressed, human-readable patch content.
+ pub patch_content: String,
+ /// Number of files changed in the patch.
+ pub files_count: usize,
+ /// Number of lines added.
+ pub lines_added: usize,
+ /// Number of lines removed.
+ pub lines_removed: usize,
+ /// The base commit SHA that the patch is diffed against.
+ pub base_commit_sha: String,
+}
+
+/// Create an uncompressed git diff patch for export.
+///
+/// This creates a human-readable patch that can be applied manually or
+/// shared as a file. Unlike `create_patch`, this version is not compressed
+/// and is suitable for display or export.
+///
+/// If `base_sha` is provided, the diff is between that commit and HEAD.
+/// If `base_sha` is None, it attempts to find the merge-base with the default branch
+/// or falls back to diffing uncommitted changes against HEAD.
+pub async fn create_export_patch(
+ worktree_path: &Path,
+ base_sha: Option<&str>,
+) -> Result<ExportPatchResult, PatchError> {
+ // Determine the base SHA to diff against
+ let resolved_base_sha = match base_sha {
+ Some(sha) => sha.to_string(),
+ None => {
+ // Try to find the merge-base with the default branch
+ // First, try to get the upstream tracking branch
+ let upstream_result = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
+ .output()
+ .await;
+
+ let base = if let Ok(output) = upstream_result {
+ if output.status.success() {
+ let upstream = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ // Get merge-base with upstream
+ let merge_base = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["merge-base", "HEAD", &upstream])
+ .output()
+ .await;
+
+ if let Ok(mb_output) = merge_base {
+ if mb_output.status.success() {
+ Some(String::from_utf8_lossy(&mb_output.stdout).trim().to_string())
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ // If we couldn't find upstream, try common default branches
+ let base = if base.is_none() {
+ let default_branches = ["origin/main", "origin/master", "main", "master"];
+ let mut found_base = None;
+
+ for branch in default_branches {
+ let merge_base = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["merge-base", "HEAD", branch])
+ .output()
+ .await;
+
+ if let Ok(output) = merge_base {
+ if output.status.success() {
+ found_base = Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
+ break;
+ }
+ }
+ }
+ found_base
+ } else {
+ base
+ };
+
+ // If still nothing, get the first commit or use HEAD~1
+ base.unwrap_or_else(|| {
+ // This will be used, but if HEAD~1 doesn't exist (only one commit),
+ // git diff will handle it gracefully
+ "HEAD~1".to_string()
+ })
+ }
+ };
+
+ // Get diff stats using --stat
+ let stat_output = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["diff", "--stat", &resolved_base_sha, "HEAD"])
+ .output()
+ .await
+ .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff --stat: {}", e)))?;
+
+ // Parse the stat output to get line counts
+ let (lines_added, lines_removed) = if stat_output.status.success() {
+ parse_diff_stat(&String::from_utf8_lossy(&stat_output.stdout))
+ } else {
+ (0, 0)
+ };
+
+ // Get the actual diff content
+ let diff_output = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["diff", &resolved_base_sha, "HEAD"])
+ .output()
+ .await
+ .map_err(|e| PatchError::GitCommand(format!("Failed to run git diff: {}", e)))?;
+
+ if !diff_output.status.success() {
+ let stderr = String::from_utf8_lossy(&diff_output.stderr);
+ return Err(PatchError::GitCommand(format!("git diff failed: {}", stderr)));
+ }
+
+ let patch_content = String::from_utf8_lossy(&diff_output.stdout).to_string();
+
+ // Check for empty patch
+ if patch_content.trim().is_empty() {
+ return Err(PatchError::EmptyPatch);
+ }
+
+ // Count files changed
+ let files_output = Command::new("git")
+ .current_dir(worktree_path)
+ .args(["diff", "--name-only", &resolved_base_sha, "HEAD"])
+ .output()
+ .await
+ .map_err(|e| PatchError::GitCommand(format!("Failed to count files: {}", e)))?;
+
+ let files_count = if files_output.status.success() {
+ String::from_utf8_lossy(&files_output.stdout)
+ .lines()
+ .filter(|l| !l.is_empty())
+ .count()
+ } else {
+ 0
+ };
+
+ Ok(ExportPatchResult {
+ patch_content,
+ files_count,
+ lines_added,
+ lines_removed,
+ base_commit_sha: resolved_base_sha,
+ })
+}
+
+/// Parse git diff --stat output to extract lines added and removed.
+/// The last line typically looks like: " 3 files changed, 45 insertions(+), 12 deletions(-)"
+fn parse_diff_stat(stat_output: &str) -> (usize, usize) {
+ let mut lines_added = 0;
+ let mut lines_removed = 0;
+
+ // Look for the summary line at the end
+ for line in stat_output.lines().rev() {
+ let line = line.trim();
+ if line.contains("changed") || line.contains("insertion") || line.contains("deletion") {
+ // Parse insertions
+ if let Some(idx) = line.find("insertion") {
+ let before = &line[..idx];
+ if let Some(num_str) = before.split_whitespace().last() {
+ if let Ok(num) = num_str.parse::<usize>() {
+ lines_added = num;
+ }
+ }
+ }
+ // Parse deletions
+ if let Some(idx) = line.find("deletion") {
+ let before = &line[..idx];
+ if let Some(num_str) = before.split(',').last() {
+ if let Some(num_str) = num_str.trim().split_whitespace().next() {
+ if let Ok(num) = num_str.parse::<usize>() {
+ lines_removed = num;
+ }
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ (lines_added, lines_removed)
+}
+
/// Checkout a specific commit in the worktree.
pub async fn checkout_commit(worktree_path: &Path, sha: &str) -> Result<(), PatchError> {
let output = Command::new("git")
@@ -290,4 +507,90 @@ mod tests {
let result = create_patch(path, &head_sha).await;
assert!(matches!(result, Err(PatchError::EmptyPatch)));
}
+
+ #[tokio::test]
+ async fn test_create_export_patch() {
+ let dir = setup_test_repo().await;
+ let path = dir.path();
+
+ // Get the initial commit SHA before making changes
+ let initial_sha = get_head_sha(path).await.unwrap();
+
+ // Make some changes and commit
+ fs::write(path.join("file.txt"), "modified content").unwrap();
+ fs::write(path.join("new_file.txt"), "new file content").unwrap();
+ Command::new("git")
+ .current_dir(path)
+ .args(["add", "."])
+ .output()
+ .await
+ .unwrap();
+ Command::new("git")
+ .current_dir(path)
+ .args(["commit", "-m", "changes for export"])
+ .output()
+ .await
+ .unwrap();
+
+ // Create export patch with explicit base
+ let result = create_export_patch(path, Some(&initial_sha)).await.unwrap();
+
+ // Verify the result
+ assert!(!result.patch_content.is_empty());
+ assert_eq!(result.files_count, 2); // file.txt and new_file.txt
+ assert!(result.lines_added > 0);
+ assert_eq!(result.base_commit_sha, initial_sha);
+
+ // The patch should contain diff headers
+ assert!(result.patch_content.contains("diff --git"));
+ assert!(result.patch_content.contains("new_file.txt"));
+ }
+
+ #[tokio::test]
+ async fn test_create_export_patch_no_base() {
+ let dir = setup_test_repo().await;
+ let path = dir.path();
+
+ // Make a second commit so we have something to diff
+ fs::write(path.join("file.txt"), "modified").unwrap();
+ Command::new("git")
+ .current_dir(path)
+ .args(["add", "."])
+ .output()
+ .await
+ .unwrap();
+ Command::new("git")
+ .current_dir(path)
+ .args(["commit", "-m", "second commit"])
+ .output()
+ .await
+ .unwrap();
+
+ // Create export patch without explicit base (will use HEAD~1)
+ let result = create_export_patch(path, None).await.unwrap();
+
+ // Verify the result
+ assert!(!result.patch_content.is_empty());
+ assert_eq!(result.files_count, 1);
+ assert!(result.patch_content.contains("diff --git"));
+ }
+
+ #[tokio::test]
+ async fn test_parse_diff_stat() {
+ // Test the parse_diff_stat function with various formats
+ let stat1 = " 3 files changed, 45 insertions(+), 12 deletions(-)";
+ let (added, removed) = parse_diff_stat(stat1);
+ assert_eq!(added, 45);
+ assert_eq!(removed, 12);
+
+ let stat2 = " 1 file changed, 10 insertions(+)";
+ let (added, removed) = parse_diff_stat(stat2);
+ assert_eq!(added, 10);
+ assert_eq!(removed, 0);
+
+ let stat3 = " 2 files changed, 5 deletions(-)";
+ let (added, removed) = parse_diff_stat(stat3);
+ assert_eq!(added, 0);
+ assert_eq!(removed, 5);
+ }
}
diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs
index 8abff3f..bbcf661 100644
--- a/makima/src/daemon/task/manager.rs
+++ b/makima/src/daemon/task/manager.rs
@@ -995,6 +995,8 @@ pub struct ManagedTask {
pub concurrency_key: Uuid,
/// Whether to run in autonomous loop mode.
pub autonomous_loop: bool,
+ /// Whether the contract is in local-only mode (skips automatic completion actions).
+ pub local_only: bool,
/// Time task was created.
pub created_at: Instant,
/// Time task started running.
@@ -1692,6 +1694,7 @@ impl TaskManager {
conversation_history,
patch_data,
patch_base_sha,
+ local_only,
} => {
tracing::info!(
task_id = %task_id,
@@ -1718,7 +1721,7 @@ impl TaskManager {
parent_task_id, depth, is_orchestrator, is_supervisor,
target_repo_path, completion_action, continue_from_task_id,
copy_files, contract_id, autonomous_loop, resume_session,
- conversation_history, patch_data, patch_base_sha,
+ conversation_history, patch_data, patch_base_sha, local_only,
).await?;
}
DaemonCommand::PauseTask { task_id } => {
@@ -1796,6 +1799,7 @@ impl TaskManager {
let target_repo_path = task.target_repo_path.clone();
let completion_action = task.completion_action.clone();
let contract_id = task.contract_id;
+ let local_only = task.local_only;
// Spawn in background to not block the command handler
tokio::spawn(async move {
@@ -1818,6 +1822,7 @@ impl TaskManager {
None, // conversation_history - not needed for fresh respawn
None, // patch_data - not available for respawn
None, // patch_base_sha - not available for respawn
+ local_only,
).await {
tracing::error!(
task_id = %task_id,
@@ -2009,6 +2014,10 @@ impl TaskManager {
tracing::info!(source_dir = ?source_dir, "Inheriting git config");
self.handle_inherit_git_config(source_dir).await?;
}
+ DaemonCommand::CreateExportPatch { task_id, base_sha } => {
+ tracing::info!(task_id = %task_id, base_sha = ?base_sha, "Creating export patch");
+ self.handle_create_export_patch(task_id, base_sha).await?;
+ }
DaemonCommand::RestartDaemon => {
tracing::info!("Received restart command from server, initiating daemon restart...");
// Shutdown all running tasks gracefully
@@ -2046,6 +2055,7 @@ impl TaskManager {
conversation_history: Option<serde_json::Value>,
patch_data: Option<String>,
patch_base_sha: Option<String>,
+ local_only: bool,
) -> TaskResult<()> {
tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, depth = depth, patch_available = patch_data.is_some(), "=== SPAWN_TASK START ===");
@@ -2096,6 +2106,7 @@ impl TaskManager {
contract_id,
concurrency_key,
autonomous_loop,
+ local_only,
created_at: Instant::now(),
started_at: None,
completed_at: None,
@@ -2122,7 +2133,7 @@ impl TaskManager {
task_id, task_name, plan, repo_url, base_branch, target_branch,
is_orchestrator, is_supervisor, target_repo_path, completion_action,
continue_from_task_id, copy_files, contract_id, autonomous_loop, resume_session,
- conversation_history, patch_data, patch_base_sha,
+ conversation_history, patch_data, patch_base_sha, local_only,
).await {
tracing::error!(task_id = %task_id, error = %e, "Task execution failed");
inner.mark_failed(task_id, &e.to_string()).await;
@@ -2729,6 +2740,86 @@ impl TaskManager {
Ok(())
}
+ /// Handle CreateExportPatch command.
+ ///
+ /// Creates an uncompressed, human-readable git patch for export.
+ async fn handle_create_export_patch(
+ &self,
+ task_id: Uuid,
+ base_sha: Option<String>,
+ ) -> Result<(), DaemonError> {
+ // Get task's worktree path
+ let worktree_result = self.get_task_worktree_path(task_id).await;
+
+ let msg = match worktree_result {
+ Ok(worktree_path) => {
+ // Create the export patch
+ match storage::create_export_patch(&worktree_path, base_sha.as_deref()).await {
+ Ok(result) => {
+ tracing::info!(
+ task_id = %task_id,
+ files_count = result.files_count,
+ lines_added = result.lines_added,
+ lines_removed = result.lines_removed,
+ base_commit_sha = %result.base_commit_sha,
+ "Export patch created successfully"
+ );
+
+ DaemonMessage::ExportPatchCreated {
+ task_id,
+ success: true,
+ patch_content: Some(result.patch_content),
+ files_count: Some(result.files_count),
+ lines_added: Some(result.lines_added),
+ lines_removed: Some(result.lines_removed),
+ base_commit_sha: Some(result.base_commit_sha),
+ error: None,
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task_id,
+ error = %e,
+ "Failed to create export patch"
+ );
+
+ DaemonMessage::ExportPatchCreated {
+ task_id,
+ success: false,
+ patch_content: None,
+ files_count: None,
+ lines_added: None,
+ lines_removed: None,
+ base_commit_sha: None,
+ error: Some(e.to_string()),
+ }
+ }
+ }
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task_id,
+ error = %e,
+ "Failed to get worktree path for export patch"
+ );
+
+ DaemonMessage::ExportPatchCreated {
+ task_id,
+ success: false,
+ patch_content: None,
+ files_count: None,
+ lines_added: None,
+ lines_removed: None,
+ base_commit_sha: None,
+ error: Some(format!("Task not found or has no worktree: {}", e)),
+ }
+ }
+ };
+
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
/// Handle ReadRepoFile command.
///
/// Reads a file from a repository on the daemon's filesystem and sends
@@ -3570,6 +3661,7 @@ impl TaskManagerInner {
conversation_history: Option<serde_json::Value>,
patch_data: Option<String>,
patch_base_sha: Option<String>,
+ local_only: bool,
) -> Result<(), DaemonError> {
tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, is_supervisor = is_supervisor, resume_session = resume_session, has_patch = patch_data.is_some(), "=== RUN_TASK START ===");
@@ -4704,9 +4796,15 @@ impl TaskManagerInner {
}
}
- // Execute completion action if task succeeded
+ // Execute completion action if task succeeded (skip in local_only mode)
let completion_result = if success {
- if let Some(ref action) = completion_action {
+ if local_only {
+ tracing::info!(
+ task_id = %task_id,
+ "Skipping completion action - contract is in local_only mode"
+ );
+ Ok(None)
+ } else if let Some(ref action) = completion_action {
if action != "none" {
self.execute_completion_action(
task_id,
diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs
index 2e7caef..018dc7b 100644
--- a/makima/src/daemon/ws/protocol.rs
+++ b/makima/src/daemon/ws/protocol.rs
@@ -316,6 +316,30 @@ pub enum DaemonMessage {
message: String,
},
+ /// Response to CreateExportPatch command.
+ ExportPatchCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ /// The uncompressed, human-readable patch content.
+ #[serde(rename = "patchContent")]
+ patch_content: Option<String>,
+ /// Number of files changed.
+ #[serde(rename = "filesCount")]
+ files_count: Option<usize>,
+ /// Lines added.
+ #[serde(rename = "linesAdded")]
+ lines_added: Option<usize>,
+ /// Lines removed.
+ #[serde(rename = "linesRemoved")]
+ lines_removed: Option<usize>,
+ /// The base commit SHA that the patch is diffed against.
+ #[serde(rename = "baseCommitSha")]
+ base_commit_sha: Option<String>,
+ /// Error message if failed.
+ error: Option<String>,
+ },
+
/// Response to InheritGitConfig command.
GitConfigInherited {
success: bool,
@@ -422,6 +446,9 @@ pub enum DaemonCommand {
/// Commit SHA to apply the patch on top of.
#[serde(rename = "patchBaseSha", default, skip_serializing_if = "Option::is_none")]
patch_base_sha: Option<String>,
+ /// Whether the contract is in local-only mode (skips automatic completion actions).
+ #[serde(rename = "localOnly", default)]
+ local_only: bool,
},
/// Pause a running task.
@@ -646,6 +673,17 @@ pub enum DaemonCommand {
delete_branch: bool,
},
+ /// Create an uncompressed git patch for export.
+ /// Returns a human-readable patch that can be applied manually or shared.
+ CreateExportPatch {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Optional base SHA to diff against. If not provided, will try to find
+ /// the merge-base with the default branch.
+ #[serde(rename = "baseSha")]
+ base_sha: Option<String>,
+ },
+
/// Inherit git config (user.email, user.name) from a directory.
/// This config will be applied to all future worktrees.
InheritGitConfig {
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 95517a1..9c2d072 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -1326,6 +1326,11 @@ pub struct Contract {
#[sqlx(json)]
#[serde(default)]
pub completed_deliverables: serde_json::Value,
+ /// Whether this contract operates in local-only mode.
+ /// When enabled, automatic completion actions (branch, merge, pr) are skipped,
+ /// allowing users to manually handle code changes via patch files or other means.
+ #[serde(default)]
+ pub local_only: bool,
pub version: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -1441,6 +1446,9 @@ pub struct ContractSummary {
pub status: String,
/// Supervisor task ID for contract orchestration
pub supervisor_task_id: Option<Uuid>,
+ /// When true, tasks do not auto-execute completion actions and work stays in worktrees.
+ #[serde(default)]
+ pub local_only: bool,
pub file_count: i64,
pub task_count: i64,
pub repository_count: i64,
@@ -1495,6 +1503,11 @@ pub struct CreateContractRequest {
/// phase outputs before progressing to the next phase.
#[serde(default)]
pub phase_guard: Option<bool>,
+ /// Enable local-only mode for this contract.
+ /// When enabled, automatic completion actions (branch, merge, pr) are skipped,
+ /// allowing users to manually handle code changes via patch files or other means.
+ #[serde(default)]
+ pub local_only: Option<bool>,
}
/// Request payload for updating a contract
@@ -1516,6 +1529,11 @@ pub struct UpdateContractRequest {
/// phase outputs before progressing to the next phase.
#[serde(default)]
pub phase_guard: Option<bool>,
+ /// Enable or disable local-only mode for this contract.
+ /// When enabled, automatic completion actions (branch, merge, pr) are skipped,
+ /// allowing users to manually handle code changes via patch files or other means.
+ #[serde(default)]
+ pub local_only: Option<bool>,
/// Version for optimistic locking
pub version: Option<i32>,
}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index b55b05e..6d6642b 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -2175,11 +2175,12 @@ pub async fn create_contract_for_owner(
let autonomous_loop = req.autonomous_loop.unwrap_or(false);
let phase_guard = req.phase_guard.unwrap_or(false);
+ let local_only = req.local_only.unwrap_or(false);
sqlx::query_as::<_, Contract>(
r#"
- INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
+ INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard, local_only)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
"#,
)
@@ -2190,6 +2191,7 @@ pub async fn create_contract_for_owner(
.bind(phase)
.bind(autonomous_loop)
.bind(phase_guard)
+ .bind(local_only)
.fetch_one(pool)
.await
}
@@ -2222,7 +2224,7 @@ pub async fn list_contracts_for_owner(
r#"
SELECT
c.id, c.name, c.description, c.contract_type, c.phase, c.status,
- c.supervisor_task_id, c.version, c.created_at,
+ c.supervisor_task_id, c.local_only, 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
@@ -2246,7 +2248,7 @@ pub async fn get_contract_summary_for_owner(
r#"
SELECT
c.id, c.name, c.description, c.contract_type, c.phase, c.status,
- c.supervisor_task_id, c.version, c.created_at,
+ c.supervisor_task_id, c.local_only, 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
@@ -2290,14 +2292,15 @@ pub async fn update_contract_for_owner(
let supervisor_task_id = req.supervisor_task_id.or(existing.supervisor_task_id);
let autonomous_loop = req.autonomous_loop.unwrap_or(existing.autonomous_loop);
let phase_guard = req.phase_guard.unwrap_or(existing.phase_guard);
+ let local_only = req.local_only.unwrap_or(existing.local_only);
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, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW()
- WHERE id = $1 AND owner_id = $2 AND version = $10
+ supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, local_only = $10, version = version + 1, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $11
RETURNING *
"#,
)
@@ -2310,6 +2313,7 @@ pub async fn update_contract_for_owner(
.bind(supervisor_task_id)
.bind(autonomous_loop)
.bind(phase_guard)
+ .bind(local_only)
.bind(req.version.unwrap())
.fetch_optional(pool)
.await?
@@ -2318,7 +2322,7 @@ pub async fn update_contract_for_owner(
r#"
UPDATE contracts
SET name = $3, description = $4, phase = $5, status = $6,
- supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, version = version + 1, updated_at = NOW()
+ supervisor_task_id = $7, autonomous_loop = $8, phase_guard = $9, local_only = $10, version = version + 1, updated_at = NOW()
WHERE id = $1 AND owner_id = $2
RETURNING *
"#,
@@ -2332,6 +2336,7 @@ pub async fn update_contract_for_owner(
.bind(supervisor_task_id)
.bind(autonomous_loop)
.bind(phase_guard)
+ .bind(local_only)
.fetch_optional(pool)
.await?
};
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
index e035368..e6ee8d4 100644
--- a/makima/src/server/handlers/contract_chat.rs
+++ b/makima/src/server/handlers/contract_chat.rs
@@ -1567,6 +1567,16 @@ async fn handle_contract_request(
}
};
+ // Get local_only from contract if task has one
+ let local_only = if let Some(contract_id) = task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Send SpawnTask command to daemon
let command = DaemonCommand::SpawnTask {
task_id,
@@ -1589,6 +1599,7 @@ async fn handle_contract_request(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
if let Err(e) = command_sender.send(command).await {
@@ -2574,6 +2585,7 @@ async fn handle_contract_request(
initial_phase: Some("research".to_string()),
autonomous_loop: None,
phase_guard: None,
+ local_only: None,
};
let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await {
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
index de3164c..3498063 100644
--- a/makima/src/server/handlers/contracts.rs
+++ b/makima/src/server/handlers/contracts.rs
@@ -366,6 +366,7 @@ pub async fn create_contract(
phase: contract.phase,
status: contract.status,
supervisor_task_id: contract.supervisor_task_id,
+ local_only: contract.local_only,
file_count: 0,
task_count: 0,
repository_count: 0,
@@ -387,6 +388,7 @@ pub async fn create_contract(
phase: contract.phase,
status: contract.status,
supervisor_task_id: contract.supervisor_task_id,
+ local_only: contract.local_only,
file_count: 0,
task_count: 0,
repository_count: 0,
@@ -515,6 +517,7 @@ pub async fn update_contract(
phase: contract.phase,
status: contract.status,
supervisor_task_id: contract.supervisor_task_id,
+ local_only: contract.local_only,
file_count: 0,
task_count: 0,
repository_count: 0,
@@ -1399,6 +1402,7 @@ pub async fn change_phase(
phase: updated_contract.phase,
status: updated_contract.status,
supervisor_task_id: updated_contract.supervisor_task_id,
+ local_only: updated_contract.local_only,
file_count: 0,
task_count: 0,
repository_count: 0,
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 545d1ea..19958e7 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -599,6 +599,16 @@ pub async fn start_task(
.into_response();
}
+ // Get local_only flag from contract if task has one
+ let local_only = if let Some(contract_id) = task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Get list of daemons that have previously failed this task
let mut exclude_daemon_ids: Vec<Uuid> = task.failed_daemon_ids.clone().unwrap_or_default();
@@ -694,6 +704,7 @@ pub async fn start_task(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
tracing::info!(
@@ -746,6 +757,7 @@ pub async fn start_task(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
if state.send_daemon_command(alt_daemon_id, alt_command).await.is_ok() {
@@ -1128,6 +1140,16 @@ pub async fn send_message(
};
if let Ok(Some(updated_task)) = repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await {
+ // Get local_only from contract if task has one
+ let local_only = if let Some(contract_id) = updated_task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Send spawn command to new daemon
let spawn_cmd = DaemonCommand::SpawnTask {
task_id: id,
@@ -1150,6 +1172,7 @@ pub async fn send_message(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
if state.send_daemon_command(new_daemon_id, spawn_cmd).await.is_ok() {
@@ -2293,6 +2316,16 @@ pub async fn reassign_task(
}
};
+ // Get local_only from contract if task has one
+ let local_only = if let Some(contract_id) = task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Send SpawnTask command to daemon for the new task
let command = DaemonCommand::SpawnTask {
task_id: new_task.id,
@@ -2315,6 +2348,7 @@ pub async fn reassign_task(
conversation_history: None,
patch_data,
patch_base_sha,
+ local_only,
};
tracing::info!(
@@ -2620,6 +2654,16 @@ pub async fn continue_task(
};
let is_orchestrator = task.depth == 0 && subtask_count > 0;
+ // Get local_only from contract if task has one
+ let local_only = if let Some(contract_id) = task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Send SpawnTask command to daemon
let command = DaemonCommand::SpawnTask {
task_id: id,
@@ -2642,6 +2686,7 @@ pub async fn continue_task(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
tracing::info!(
@@ -3562,6 +3607,7 @@ pub async fn branch_task(
conversation_history: updated_task.conversation_state.clone(),
patch_data,
patch_base_sha,
+ local_only: false, // No contract, so not local_only
};
if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs
index 1ff0724..eb35728 100644
--- a/makima/src/server/handlers/mesh_chat.rs
+++ b/makima/src/server/handlers/mesh_chat.rs
@@ -1131,6 +1131,16 @@ async fn handle_mesh_request(
}
};
+ // Get local_only from contract if task has one
+ let local_only = if let Some(contract_id) = task.contract_id {
+ match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
+ Ok(Some(contract)) => contract.local_only,
+ _ => false,
+ }
+ } else {
+ false
+ };
+
// Send SpawnTask command to daemon
let command = DaemonCommand::SpawnTask {
task_id,
@@ -1153,6 +1163,7 @@ async fn handle_mesh_request(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only,
};
match state.send_daemon_command(target_daemon_id, command).await {
diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs
index 0ba37d2..0aea40e 100644
--- a/makima/src/server/handlers/mesh_daemon.rs
+++ b/makima/src/server/handlers/mesh_daemon.rs
@@ -448,6 +448,29 @@ pub enum DaemonMessage {
/// Error message if operation failed
error: Option<String>,
},
+ /// Response to CreateExportPatch command
+ ExportPatchCreated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ /// The uncompressed, human-readable patch content
+ #[serde(rename = "patchContent")]
+ patch_content: Option<String>,
+ /// Number of files changed
+ #[serde(rename = "filesCount")]
+ files_count: Option<usize>,
+ /// Lines added
+ #[serde(rename = "linesAdded")]
+ lines_added: Option<usize>,
+ /// Lines removed
+ #[serde(rename = "linesRemoved")]
+ lines_removed: Option<usize>,
+ /// The base commit SHA that the patch is diffed against
+ #[serde(rename = "baseCommitSha")]
+ base_commit_sha: Option<String>,
+ /// Error message if failed
+ error: Option<String>,
+ },
/// Response to MergeTaskToTarget command
MergeToTargetResult {
#[serde(rename = "taskId")]
@@ -1778,6 +1801,75 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re
);
}
}
+ Ok(DaemonMessage::ExportPatchCreated {
+ task_id,
+ success,
+ patch_content,
+ files_count,
+ lines_added,
+ lines_removed,
+ base_commit_sha,
+ error,
+ }) => {
+ if success {
+ tracing::info!(
+ task_id = %task_id,
+ files_count = ?files_count,
+ lines_added = ?lines_added,
+ lines_removed = ?lines_removed,
+ base_commit_sha = ?base_commit_sha,
+ patch_len = patch_content.as_ref().map(|p| p.len()),
+ "Export patch created successfully"
+ );
+
+ // Broadcast as task output so UI can access the result
+ let output_text = format!(
+ "✓ Export patch created: {} files changed, +{} -{} lines (base: {})",
+ files_count.unwrap_or(0),
+ lines_added.unwrap_or(0),
+ lines_removed.unwrap_or(0),
+ base_commit_sha.as_deref().unwrap_or("unknown")
+ );
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "export_patch".to_string(),
+ content: output_text,
+ tool_name: None,
+ tool_input: Some(serde_json::json!({
+ "patchContent": patch_content,
+ "filesCount": files_count,
+ "linesAdded": lines_added,
+ "linesRemoved": lines_removed,
+ "baseCommitSha": base_commit_sha,
+ })),
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ } else {
+ tracing::warn!(
+ task_id = %task_id,
+ error = ?error,
+ "Failed to create export patch"
+ );
+
+ // Broadcast error
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "error".to_string(),
+ content: format!("✗ Export patch failed: {}", error.unwrap_or_else(|| "Unknown error".to_string())),
+ tool_name: None,
+ tool_input: None,
+ is_error: Some(true),
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ }
+ }
Err(e) => {
tracing::warn!("Failed to parse daemon message: {}", e);
}
diff --git a/makima/src/server/handlers/mesh_supervisor.rs b/makima/src/server/handlers/mesh_supervisor.rs
index d1a1a99..a654a05 100644
--- a/makima/src/server/handlers/mesh_supervisor.rs
+++ b/makima/src/server/handlers/mesh_supervisor.rs
@@ -297,6 +297,12 @@ pub async fn try_start_pending_task(
return Ok(None);
}
+ // Get contract to check local_only flag
+ let contract = repository::get_contract_for_owner(pool, contract_id, owner_id)
+ .await
+ .map_err(|e| format!("Failed to get contract: {}", e))?
+ .ok_or_else(|| "Contract not found".to_string())?;
+
// Try each pending task until we find one we can start
for task in &pending_tasks {
// Get excluded daemon IDs for this task (daemons that have already failed it)
@@ -399,6 +405,7 @@ pub async fn try_start_pending_task(
conversation_history: None,
patch_data,
patch_base_sha,
+ local_only: contract.local_only,
};
if let Err(e) = state.send_daemon_command(daemon.id, cmd).await {
@@ -532,8 +539,8 @@ pub async fn spawn_task(
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 {
+ // Verify contract exists and get local_only flag
+ let contract = match repository::get_contract_for_owner(pool, request.contract_id, owner_id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
@@ -711,6 +718,7 @@ pub async fn spawn_task(
conversation_history: None,
patch_data: None,
patch_base_sha: None,
+ local_only: contract.local_only,
};
if let Err(e) = state.send_daemon_command(daemon.id, cmd).await {
@@ -2133,6 +2141,7 @@ pub async fn resume_supervisor(
conversation_history: Some(supervisor_state.conversation_history.clone()), // Fallback if worktree missing
patch_data,
patch_base_sha,
+ local_only: contract.local_only,
};
if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs
index 3b71eca..8eb50c7 100644
--- a/makima/src/server/handlers/transcript_analysis.rs
+++ b/makima/src/server/handlers/transcript_analysis.rs
@@ -278,6 +278,7 @@ pub async fn create_contract_from_analysis(
initial_phase: Some("research".to_string()),
autonomous_loop: None,
phase_guard: None,
+ local_only: None,
};
let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await {
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 988f657..c579f0f 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -238,6 +238,9 @@ pub enum DaemonCommand {
/// Commit SHA to apply the patch on top of
#[serde(rename = "patchBaseSha", default, skip_serializing_if = "Option::is_none")]
patch_base_sha: Option<String>,
+ /// Whether the contract is in local-only mode (skips automatic completion actions)
+ #[serde(rename = "localOnly", default)]
+ local_only: bool,
},
/// Pause a running task
PauseTask {