diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts
index 8d0cbdd..0211b7a 100644
--- a/apps/web/src/hooks/index.ts
+++ b/apps/web/src/hooks/index.ts
@@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useLiveUpdates } from './useLiveUpdates.js';
export { useRefineAgent } from './useRefineAgent.js';
+export { useConflictAgent } from './useConflictAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
export type {
RefineAgentState,
SpawnRefineAgentOptions,
UseRefineAgentResult,
-} from './useRefineAgent.js';
\ No newline at end of file
+} from './useRefineAgent.js';
+
+export type {
+ ConflictAgentState,
+ UseConflictAgentResult,
+} from './useConflictAgent.js';
\ No newline at end of file
diff --git a/apps/web/src/hooks/useConflictAgent.ts b/apps/web/src/hooks/useConflictAgent.ts
new file mode 100644
index 0000000..3594fdf
--- /dev/null
+++ b/apps/web/src/hooks/useConflictAgent.ts
@@ -0,0 +1,214 @@
+import { useCallback, useMemo, useRef } from 'react';
+import { trpc } from '@/lib/trpc';
+import type { PendingQuestions } from '@codewalk-district/shared';
+
+export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
+
+type ConflictAgent = NonNullable['data']>;
+
+export interface UseConflictAgentResult {
+ agent: ConflictAgent | null;
+ state: ConflictAgentState;
+ questions: PendingQuestions | null;
+ spawn: {
+ mutate: (options: { initiativeId: string; provider?: string }) => void;
+ isPending: boolean;
+ error: Error | null;
+ };
+ resume: {
+ mutate: (answers: Record) => void;
+ isPending: boolean;
+ error: Error | null;
+ };
+ stop: {
+ mutate: () => void;
+ isPending: boolean;
+ };
+ dismiss: () => void;
+ isLoading: boolean;
+ refresh: () => void;
+}
+
+export function useConflictAgent(initiativeId: string): UseConflictAgentResult {
+ const utils = trpc.useUtils();
+
+ const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
+ const agent = agentQuery.data ?? null;
+
+ const state: ConflictAgentState = useMemo(() => {
+ if (!agent) return 'none';
+ switch (agent.status) {
+ case 'running':
+ return 'running';
+ case 'waiting_for_input':
+ return 'waiting';
+ case 'idle':
+ return 'completed';
+ case 'crashed':
+ return 'crashed';
+ default:
+ return 'none';
+ }
+ }, [agent]);
+
+ const questionsQuery = trpc.getAgentQuestions.useQuery(
+ { id: agent?.id ?? '' },
+ { enabled: state === 'waiting' && !!agent },
+ );
+
+ const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({
+ onMutate: async () => {
+ await utils.listAgents.cancel();
+ await utils.getActiveConflictAgent.cancel({ initiativeId });
+
+ const previousAgents = utils.listAgents.getData();
+ const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
+
+ const tempAgent = {
+ id: `temp-${Date.now()}`,
+ name: `conflict-${Date.now()}`,
+ mode: 'execute' as const,
+ status: 'running' as const,
+ initiativeId,
+ taskId: null,
+ phaseId: null,
+ provider: 'claude',
+ accountId: null,
+ instruction: null,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ userDismissedAt: null,
+ completedAt: null,
+ };
+
+ utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
+ utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any);
+
+ return { previousAgents, previousConflictAgent };
+ },
+ onError: (_err, _variables, context) => {
+ if (context?.previousAgents) {
+ utils.listAgents.setData(undefined, context.previousAgents);
+ }
+ if (context?.previousConflictAgent !== undefined) {
+ utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
+ }
+ },
+ onSettled: () => {
+ void utils.listAgents.invalidate();
+ void utils.getActiveConflictAgent.invalidate({ initiativeId });
+ },
+ });
+
+ const resumeMutation = trpc.resumeAgent.useMutation({
+ onSuccess: () => {
+ void utils.listAgents.invalidate();
+ },
+ });
+
+ const stopMutation = trpc.stopAgent.useMutation({
+ onSuccess: () => {
+ void utils.listAgents.invalidate();
+ void utils.listWaitingAgents.invalidate();
+ },
+ });
+
+ const dismissMutation = trpc.dismissAgent.useMutation({
+ onMutate: async ({ id }) => {
+ await utils.listAgents.cancel();
+ await utils.getActiveConflictAgent.cancel({ initiativeId });
+
+ const previousAgents = utils.listAgents.getData();
+ const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
+
+ utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id));
+ utils.getActiveConflictAgent.setData({ initiativeId }, null);
+
+ return { previousAgents, previousConflictAgent };
+ },
+ onError: (_err, _variables, context) => {
+ if (context?.previousAgents) {
+ utils.listAgents.setData(undefined, context.previousAgents);
+ }
+ if (context?.previousConflictAgent !== undefined) {
+ utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
+ }
+ },
+ onSettled: () => {
+ void utils.listAgents.invalidate();
+ void utils.getActiveConflictAgent.invalidate({ initiativeId });
+ },
+ });
+
+ const spawnMutateRef = useRef(spawnMutation.mutate);
+ spawnMutateRef.current = spawnMutation.mutate;
+ const agentRef = useRef(agent);
+ agentRef.current = agent;
+ const resumeMutateRef = useRef(resumeMutation.mutate);
+ resumeMutateRef.current = resumeMutation.mutate;
+ const stopMutateRef = useRef(stopMutation.mutate);
+ stopMutateRef.current = stopMutation.mutate;
+ const dismissMutateRef = useRef(dismissMutation.mutate);
+ dismissMutateRef.current = dismissMutation.mutate;
+
+ const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => {
+ spawnMutateRef.current({ initiativeId, provider });
+ }, []);
+
+ const spawn = useMemo(() => ({
+ mutate: spawnFn,
+ isPending: spawnMutation.isPending,
+ error: spawnMutation.error,
+ }), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
+
+ const resumeFn = useCallback((answers: Record) => {
+ const a = agentRef.current;
+ if (a) {
+ resumeMutateRef.current({ id: a.id, answers });
+ }
+ }, []);
+
+ const resume = useMemo(() => ({
+ mutate: resumeFn,
+ isPending: resumeMutation.isPending,
+ error: resumeMutation.error,
+ }), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
+
+ const stopFn = useCallback(() => {
+ const a = agentRef.current;
+ if (a) {
+ stopMutateRef.current({ id: a.id });
+ }
+ }, []);
+
+ const stop = useMemo(() => ({
+ mutate: stopFn,
+ isPending: stopMutation.isPending,
+ }), [stopFn, stopMutation.isPending]);
+
+ const dismiss = useCallback(() => {
+ const a = agentRef.current;
+ if (a) {
+ dismissMutateRef.current({ id: a.id });
+ }
+ }, []);
+
+ const refresh = useCallback(() => {
+ void utils.getActiveConflictAgent.invalidate({ initiativeId });
+ }, [utils, initiativeId]);
+
+ const isLoading = agentQuery.isLoading ||
+ (state === 'waiting' && questionsQuery.isLoading);
+
+ return {
+ agent,
+ state,
+ questions: questionsQuery.data ?? null,
+ spawn,
+ resume,
+ stop,
+ dismiss,
+ isLoading,
+ refresh,
+ };
+}
diff --git a/docs/agent.md b/docs/agent.md
index 5529f63..7083585 100644
--- a/docs/agent.md
+++ b/docs/agent.md
@@ -24,7 +24,7 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
-| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
+| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
## Key Flows
diff --git a/docs/frontend.md b/docs/frontend.md
index cd95313..6488640 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -116,6 +116,7 @@ The initiative detail page has three tabs managed via local state (not URL param
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) |
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
+| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
| `ProposalCard` | Individual proposal display |
@@ -127,6 +128,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog,
| Hook | Purpose |
|------|---------|
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
+| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review |
| `useDetailAgent` | Manages detail agent for phase planning |
| `useAgentOutput` | Subscribes to live agent output stream |
| `useChatSession` | Manages chat session for phase/task refinement |
diff --git a/docs/git-process-logging.md b/docs/git-process-logging.md
index b35efa2..2e5d8c4 100644
--- a/docs/git-process-logging.md
+++ b/docs/git-process-logging.md
@@ -46,6 +46,8 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
+| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') |
+| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects |
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
diff --git a/docs/server-api.md b/docs/server-api.md
index 48c62bd..ec11000 100644
--- a/docs/server-api.md
+++ b/docs/server-api.md
@@ -64,6 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgentQuestions | query | Pending questions |
| getAgentOutput | query | Full output from DB log chunks |
| getActiveRefineAgent | query | Active refine agent for initiative |
+| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
| listWaitingAgents | query | Agents waiting for input |
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
@@ -96,6 +97,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getInitiativeCommitDiff | query | Single commit diff for initiative review |
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
| requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active |
+| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}` → `{mergeable, conflictFiles[], targetBranch}` |
+| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task |
### Phases
| Procedure | Type | Description |