summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
Diffstat (limited to 'makima')
-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 {