diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 88f6662fe8..b41ff956e7 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -14,7 +14,14 @@ import { useShallow } from 'zustand/react/shallow' import { getAdsEnabled } from './commands/ads' import { routeUserPrompt, addBashMessageToHistory } from './commands/router' +import { MissionTodosTracker } from './components/mission-todos-tracker' import { SingleAdBanner } from './components/ad-banner' +import { + buildMissionContinuation, + loadMission, + refreshMissionCompletion, +} from './missions/mission-store' +import { getMissionAutopilotAction } from './missions/mission-autopilot' import { ChatInputBar } from './components/chat-input-bar' import { ChatHeader } from './components/chat-header' import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary' @@ -52,7 +59,7 @@ import { usePublishMutation } from './hooks/use-publish-mutation' import { useSuggestionEngine } from './hooks/use-suggestion-engine' import { useUsageMonitor } from './hooks/use-usage-monitor' import { WEBSITE_URL } from './login/constants' -import { getProjectRoot } from './project-files' +import { getMissionScopeId, getProjectRoot } from './project-files' import { useChatHistoryStore } from './state/chat-history-store' import { useChatStore } from './state/chat-store' import { useQueuePanelStore } from './state/queue-panel-store' @@ -570,6 +577,13 @@ export const Chat = ({ }, ) + const missionAutopilotPendingRef = useRef(false) + let missionAutopilotActive = false + try { + missionAutopilotActive = + loadMission(getProjectRoot(), getMissionScopeId())?.status === 'active' + } catch {} + // Retire onboarding suggested prompts once the user submits anything // (typed or clicked), persisting so they don't return on future launches. useEffect(() => { @@ -1486,6 +1500,52 @@ export const Chat = ({ IS_FREEBUFF && freebuffSession?.status === 'active' const isFreebuffSessionOver = IS_FREEBUFF && freebuffSession?.status === 'ended' + + // A mission is a persistent worker, not a single assistant turn. Whenever + // its chat becomes idle, re-check the authoritative plan and start the next + // turn. The per-chat mission file prevents parallel terminals from sharing + // or completing each other's work. + useEffect(() => { + let root: string + let scopeId: string + try { + root = getProjectRoot() + scopeId = getMissionScopeId() + } catch { + return + } + const mission = refreshMissionCompletion(root, scopeId) + const action = getMissionAutopilotAction({ + active: mission?.status === 'active', + idle: + !isStreaming && + !isWaitingForResponse && + !isChainInProgressRef.current && + !askUserState && + !reviewMode, + sessionOver: isFreebuffSessionOver, + }) + if (action !== 'continue' || !mission || missionAutopilotPendingRef.current) return + + missionAutopilotPendingRef.current = true + const timer = setTimeout(() => { + const current = refreshMissionCompletion(root, scopeId) + if (!current || current.status !== 'active') { + missionAutopilotPendingRef.current = false + return + } + onSubmitPrompt(buildMissionContinuation(root, current, scopeId), agentMode) + .catch((error) => logger.error({ error }, '[mission-autopilot] Failed to continue mission')) + .finally(() => { + missionAutopilotPendingRef.current = false + }) + }, 1500) + return () => { + clearTimeout(timer) + missionAutopilotPendingRef.current = false + } + }, [messages.length, isStreaming, isWaitingForResponse, isFreebuffSessionOver, askUserState, reviewMode, agentMode, onSubmitPrompt]) + const shouldShowStatusLine = !feedbackMode && (hasStatusIndicatorContent || @@ -1628,6 +1688,8 @@ export const Chat = ({ /> )} + + {reviewMode ? ( // Review and ask_user take precedence over the session-ended banner: // during the grace window the agent may still be asking to run tools @@ -1649,9 +1711,10 @@ export const Chat = ({ width={separatorWidth} maxVisibleRows={isCompactHeight ? 4 : 8} /> - ) : isFreebuffSessionOver && !askUserState ? ( + ) : isFreebuffSessionOver && !askUserState && !isStreaming && !isWaitingForResponse ? ( ) : ( <> diff --git a/cli/src/commands/mission.ts b/cli/src/commands/mission.ts new file mode 100644 index 0000000000..fb02806521 --- /dev/null +++ b/cli/src/commands/mission.ts @@ -0,0 +1,74 @@ +import { getMissionScopeId, getProjectRoot } from '../project-files' +import { + buildMissionPrompt, + cancelMission, + completeMission, + createMission, + formatMissionStatus, + loadMission, +} from '../missions/mission-store' + +function root(): string { + return getProjectRoot() || process.cwd() +} + +function scope(): string | undefined { + try { + return getMissionScopeId() + } catch { + return undefined + } +} + +export type MissionCommandResult = + | { kind: 'message'; message: string } + | { kind: 'start'; prompt: string } + +export function runMissionCommand(args: string): MissionCommandResult { + const trimmed = args.trim() + let [verb, ...rest] = trimmed ? trimmed.split(/\s+/) : ['status'] + + if (verb.endsWith(',')) { + verb = verb.slice(0, -1) + } + + const value = rest.join(' ').trim() + + if (verb === 'status') { + return { kind: 'message', message: formatMissionStatus(loadMission(root(), scope())) } + } + if (verb === 'start') { + if (!value) { + return { kind: 'message', message: 'Usage: /mission start ' } + } + const mission = createMission(root(), value, scope()) + return { kind: 'start', prompt: buildMissionPrompt(root(), mission, scope()) } + } + if (verb === 'complete') { + const evidence = value ? value.split('|').map((item) => item.trim()) : [] + try { + const mission = completeMission(root(), evidence, scope()) + return { kind: 'message', message: formatMissionStatus(mission) } + } catch (error) { + return { + kind: 'message', + message: error instanceof Error ? error.message : String(error), + } + } + } + if (verb === 'cancel') { + try { + const mission = cancelMission(root(), scope()) + return { kind: 'message', message: formatMissionStatus(mission) } + } catch (error) { + return { + kind: 'message', + message: error instanceof Error ? error.message : String(error), + } + } + } + return { + kind: 'message', + message: 'Uso: /mission [status|start |complete [evidência]|cancel]', + } +} diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index d9b08aa766..e6a93eb19f 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -13,6 +13,10 @@ import { } from './router-utils' import { buildInterviewPrompt, buildPlanPrompt, buildReviewPrompt } from './prompt-builders' import { getProjectRoot } from '../project-files' +import { + buildMissionContinuation, + loadMission, +} from '../missions/mission-store' import { useChatStore } from '../state/chat-store' import { useFreebuffSessionStore } from '../state/freebuff-session-store' import { trackEvent } from '../utils/analytics' @@ -457,7 +461,13 @@ export async function routeUserPrompt( return } - sendMessage({ content: trimmed, agentMode }) + const projectRoot = getProjectRoot() || process.cwd() + const mission = loadMission(projectRoot) + const content = + mission?.status === 'active' + ? trimmed + buildMissionContinuation(projectRoot, mission) + : trimmed + sendMessage({ content, agentMode }) setTimeout(() => { scrollToLatest() diff --git a/cli/src/components/mission-todos-tracker.tsx b/cli/src/components/mission-todos-tracker.tsx new file mode 100644 index 0000000000..4a82823694 --- /dev/null +++ b/cli/src/components/mission-todos-tracker.tsx @@ -0,0 +1,118 @@ +import React, { useMemo, useState } from 'react' +import fs from 'node:fs' +import { TextAttributes } from '@opentui/core' +import { useTheme } from '../hooks/use-theme' +import { getMissionScopeId, getProjectRoot } from '../project-files' +import { getMissionPath } from '../missions/mission-store' +import { ClickableTitleBox } from './clickable-title-box' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { ChatMessage, ToolContentBlock } from '../types/chat' + +interface MissionTodosTrackerProps { + messages: ChatMessage[] +} + +export const MissionTodosTracker: React.FC = ({ messages }) => { + const theme = useTheme() + const projectRoot = getProjectRoot() ?? process.cwd() + const [isExpanded, setIsExpanded] = useState(false) + + const missionJsonPath = useMemo(() => { + try { + return getMissionPath(projectRoot, getMissionScopeId()) + } catch { + return getMissionPath(projectRoot) + } + }, [projectRoot, messages]) + + const missionText = useMemo(() => { + try { + if (fs.existsSync(missionJsonPath)) { + const data = JSON.parse(fs.readFileSync(missionJsonPath, 'utf8')) + if (data.status === 'active' || data.status === 'completed' || data.status === 'blocked') { + return { objective: data.objective, status: data.status } + } + } + } catch {} + return null + }, [missionJsonPath, messages]) + + const todos = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.blocks) { + for (const block of msg.blocks) { + if (block.type === 'tool' && (block as ToolContentBlock).toolName === 'write_todos') { + try { + const input = (block as ToolContentBlock).input + if (input && Array.isArray(input.todos)) { + return input.todos as Array<{task: string, completed: boolean}> + } + } catch {} + } + } + } + } + return null + }, [messages]) + + if (!missionText && !todos) return null + + // Clean objective text for single line, heavily truncated to avoid Opentui dropping the title on small terminals + const shortObjective = missionText?.objective + ? missionText.objective.replace(/[\r\n]+/g, ' ').substring(0, 30) + (missionText.objective.length > 30 ? '...' : '') + : 'Sem meta definida' + + const completedCount = todos ? todos.filter(t => t.completed).length : 0 + const totalCount = todos ? todos.length : 0 + + const title = ` META: ${shortObjective} | ETAPAS (${completedCount}/${totalCount}) [${isExpanded ? '▲' : '▼'}] ` + + let visibleTodos = todos || [] + + if (!isExpanded && todos && todos.length > 0) { + const firstUncompletedIndex = todos.findIndex(t => !t.completed) + const targetIndex = firstUncompletedIndex === -1 ? todos.length - 1 : firstUncompletedIndex + visibleTodos = [todos[targetIndex]] + // Attach index so we know which one it is + visibleTodos[0] = { ...visibleTodos[0], originalIndex: targetIndex } as any + } else if (todos) { + visibleTodos = todos.map((t, idx) => ({ ...t, originalIndex: idx })) as any + } + + return ( + setIsExpanded(!isExpanded)} + style={{ + flexDirection: 'column', + width: '100%', + marginBottom: 1, + paddingLeft: 1, + paddingRight: 1, + borderStyle: 'single', + customBorderChars: BORDER_CHARS, + borderColor: theme.success, + }} + > + {visibleTodos.map((todo: any) => ( + + + {todo.completed ? ( + <> + [x] + {todo.task} + + ) : ( + <> + [ ] + {todo.task} + + )} + + + ))} + + ) +} diff --git a/cli/src/components/session-ended-banner.tsx b/cli/src/components/session-ended-banner.tsx index 382b1235f0..4d69232e31 100644 --- a/cli/src/components/session-ended-banner.tsx +++ b/cli/src/components/session-ended-banner.tsx @@ -7,7 +7,7 @@ import { import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import { TextAttributes } from '@opentui/core' import { useKeyboard } from '@opentui/react' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useState, useEffect } from 'react' import { Button } from './button' import { @@ -28,6 +28,8 @@ interface SessionEndedBannerProps { * grace window. Swaps the Enter-to-rejoin affordance for a "let it * finish" hint so the user doesn't abort their in-flight work. */ isStreaming: boolean + onSessionRenewed?: () => void + autoRestart?: boolean } /** @@ -37,6 +39,8 @@ interface SessionEndedBannerProps { */ export const SessionEndedBanner: React.FC = ({ isStreaming, + onSessionRenewed, + autoRestart, }) => { const theme = useTheme() const [pendingAction, setPendingAction] = useState< @@ -112,8 +116,19 @@ export const SessionEndedBanner: React.FC = ({ } // Re-POST with the currently selected model and keep the chat/run state // intact so the next prompt continues the same conversation. - refreshFreebuffSession().catch(() => setPendingAction(null)) - }, [canRestart, continueOnFallback]) + refreshFreebuffSession() + .then(() => { + onSessionRenewed?.() + }) + .catch(() => setPendingAction(null)) + }, [canRestart, continueOnFallback, onSessionRenewed]) + + useEffect(() => { + if (autoRestart && canRestart) { + const timer = setTimeout(startSameChatSession, 1500) + return () => clearTimeout(timer) + } + }, [autoRestart, canRestart, startSameChatSession]) useKeyboard( useCallback( diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 39065317ee..830fb1ed0d 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto' import { useCallback, useEffect, useRef } from 'react' -import { setCurrentChatId } from '../project-files' +import { setCurrentChatId, getMissionScopeId, getProjectRoot } from '../project-files' +import { loadMission, buildMissionContinuation } from '../missions/mission-store' import { createStreamController } from './stream-state' import { useChatStore } from '../state/chat-store' import { @@ -93,12 +94,13 @@ const resolveAgent = ( agentId: string | undefined, agentDefinitions: AgentDefinition[], ): AgentDefinition | string => { + const targetId = agentId ?? getAgentIdForMode(agentMode) const selectedAgentDefinition = - agentId && agentDefinitions.length > 0 - ? agentDefinitions.find((definition) => definition.id === agentId) + agentDefinitions.length > 0 + ? agentDefinitions.find((definition) => definition.id === targetId) : undefined - return selectedAgentDefinition ?? agentId ?? getAgentIdForMode(agentMode) + return selectedAgentDefinition ?? targetId } // Respect bash context, but avoid sending empty prompts when only images are attached. @@ -250,7 +252,7 @@ export const useSendMessage = ({ ) const sendMessage = useCallback( - async ({ content, agentMode, postUserMessage, attachments }) => { + async function runSendMessage({ content, agentMode, postUserMessage, attachments }) { // CRITICAL: Set chain in progress immediately (synchronously) before any async work. // This ensures the router can detect that we're busy and queue subsequent messages. // Set the ref directly first to guarantee immediate visibility to other code paths, @@ -540,7 +542,17 @@ export const useSendMessage = ({ // Execute SDK run with streaming handlers try { const agentDefinitions = loadAgentDefinitions() - const resolvedAgent = resolveAgent(agentMode, agentId, agentDefinitions) + let resolvedAgent = resolveAgent(agentMode, agentId, agentDefinitions) + + // INJECT MISSION INTO SYSTEM PROMPT SO HE NEVER FORGETS IT DURING LONG LOOPS + try { + const root = getProjectRoot() || process.cwd() + const scopeId = getMissionScopeId() + const mission = loadMission(root, scopeId) + if (mission && mission.status === 'active' && typeof resolvedAgent !== 'string' && resolvedAgent.systemPrompt) { + resolvedAgent = { ...resolvedAgent, systemPrompt: resolvedAgent.systemPrompt + '\n\n' + buildMissionContinuation(root, mission, scopeId) } + } + } catch(e) {} const promptWithBashContext = bashContextForPrompt ? bashContextForPrompt + finalContent @@ -692,6 +704,30 @@ export const useSendMessage = ({ isQueuePausedRef, hasReceivedContent: hasReceivedContentRef.current, }) + + const errorStr = (error instanceof Error ? error.message : String(error)).toLowerCase() + const isNetworkError = + errorStr.includes('internal server error') || + errorStr.includes('fetch failed') || + errorStr.includes('network') || + errorStr.includes('socket hang up') || + errorStr.includes('econnreset') + + // Extract retryCount from the first argument (hidden property) + const currentRetry = (arguments[0] as any).retryCount || 0 + + if (isNetworkError && currentRetry < 5) { + const retryContent = hasReceivedContentRef.current ? 'continue' : (typeof content === 'string' ? content : 'continue') + const backoff = Math.min(3000 * Math.pow(1.5, currentRetry), 15000) + setTimeout(() => { + if (runChatIsCurrent() && !abortController.signal.aborted) { + const nextArgs = { content: retryContent, agentMode, postUserMessage: false, attachments: [] } + ;(nextArgs as any).retryCount = currentRetry + 1 + runSendMessage(nextArgs as any) + } + }, backoff) + } + // Persist the last checkpoint plus the error banner so a restart // after a failed run still shows this turn. Settle async checkpoints // first so a stale write can't clobber this one. Skipped after a diff --git a/cli/src/missions/__tests__/mission-autopilot.test.ts b/cli/src/missions/__tests__/mission-autopilot.test.ts new file mode 100644 index 0000000000..64131833c9 --- /dev/null +++ b/cli/src/missions/__tests__/mission-autopilot.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test' + +import { getMissionAutopilotAction, shouldAutoStartMissionSession } from '../mission-autopilot' + +describe('mission autopilot', () => { + it('continues an active mission whenever the chat becomes idle', () => { + expect(getMissionAutopilotAction({ active: true, idle: true, sessionOver: false })).toBe('continue') + }) + + it('renews an ended session instead of waiting for Enter', () => { + expect(getMissionAutopilotAction({ active: true, idle: true, sessionOver: true })).toBe('renew') + }) + + it('does nothing while work is running or after completion', () => { + expect(getMissionAutopilotAction({ active: true, idle: false, sessionOver: false })).toBe('none') + expect(getMissionAutopilotAction({ active: false, idle: true, sessionOver: true })).toBe('none') + }) + + it('starts DeepSeek automatically from the landing screen for active missions', () => { + expect(shouldAutoStartMissionSession(true, 'none')).toBe(true) + expect(shouldAutoStartMissionSession(false, 'none')).toBe(false) + expect(shouldAutoStartMissionSession(true, 'active')).toBe(false) + }) +}) diff --git a/cli/src/missions/__tests__/mission-store.test.ts b/cli/src/missions/__tests__/mission-store.test.ts new file mode 100644 index 0000000000..9fbbb5ee3c --- /dev/null +++ b/cli/src/missions/__tests__/mission-store.test.ts @@ -0,0 +1,52 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { + buildMissionPrompt, + completeMission, + createMission, + loadMission, +} from '../mission-store' + +describe('mission store', () => { + let root: string + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-mission-')) + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + it('persists a resumable mission atomically', () => { + const mission = createMission(root, 'Corrigir streaming e provar com testes') + + expect(loadMission(root)).toEqual(mission) + expect(mission.status).toBe('active') + expect(mission.objective).toContain('streaming') + }) + + it('marks a mission complete with evidence', () => { + createMission(root, 'Finalizar suporte MCP') + const completed = completeMission(root, ['60 testes passaram', 'CLI abriu']) + + expect(completed.status).toBe('completed') + expect(completed.evidence).toHaveLength(2) + expect(loadMission(root)?.status).toBe('completed') + }) + + it('builds an action-first prompt that prevents premature completion', () => { + const mission = createMission(root, 'Entregar recurso completo') + const prompt = buildMissionPrompt(root, mission) + + expect(prompt).toContain('MISSÃO ATIVA') + expect(prompt).toContain('Entregar recurso completo') + expect(prompt).toContain('não encerre') + expect(prompt).toContain('.freebuff/mission') + expect(prompt).toContain('evidência') + }) +}) diff --git a/cli/src/missions/mission-autopilot.ts b/cli/src/missions/mission-autopilot.ts new file mode 100644 index 0000000000..d1a9490613 --- /dev/null +++ b/cli/src/missions/mission-autopilot.ts @@ -0,0 +1,12 @@ +export type MissionAutopilotParams = { + active: boolean + idle: boolean + sessionOver: boolean +} + +export function getMissionAutopilotAction(params: MissionAutopilotParams): 'continue' | 'none' { + if (params.active && params.idle) { + return 'continue' + } + return 'none' +} diff --git a/cli/src/missions/mission-store.ts b/cli/src/missions/mission-store.ts new file mode 100644 index 0000000000..d639b3e26f --- /dev/null +++ b/cli/src/missions/mission-store.ts @@ -0,0 +1,141 @@ +import fs from 'fs' +import path from 'path' + +export type MissionStatus = 'active' | 'completed' | 'cancelled' | 'blocked' + +export type MissionState = { + version: 1 + id: string + objective: string + status: MissionStatus + createdAt: string + updatedAt: string + evidence: string[] +} + +const MISSION_DIRECTORY = '.freebuff' + +export function getMissionPath(projectRoot: string, scopeId?: string): string { + // Isolate by scope (chat/conversation) instead of branch + const id = scopeId ? scopeId.replace(/[^a-zA-Z0-9_-]/g, '_') : 'default' + return path.join(projectRoot, MISSION_DIRECTORY, `mission-${id}.json`) +} + +function writeMission(projectRoot: string, mission: MissionState, scopeId?: string): void { + const missionPath = getMissionPath(projectRoot, scopeId) + fs.mkdirSync(path.dirname(missionPath), { recursive: true }) + const temporaryPath = `${missionPath}.${process.pid}.${Date.now()}.tmp` + fs.writeFileSync(temporaryPath, `${JSON.stringify(mission, null, 2)}\n`, 'utf8') + fs.renameSync(temporaryPath, missionPath) +} + +export function loadMission(projectRoot: string, scopeId?: string): MissionState | null { + const missionPath = getMissionPath(projectRoot, scopeId) + if (!fs.existsSync(missionPath)) return null + try { + const value = JSON.parse(fs.readFileSync(missionPath, 'utf8')) as MissionState + if ( + value.version !== 1 || + typeof value.id !== 'string' || + typeof value.objective !== 'string' || + !Array.isArray(value.evidence) + ) { + return null + } + return value + } catch { + return null + } +} + +export function createMission( + projectRoot: string, + objective: string, + scopeId?: string, +): MissionState { + const trimmedObjective = objective.trim() + if (!trimmedObjective) throw new Error('Mission requires an objective.') + const now = new Date().toISOString() + const mission: MissionState = { + version: 1, + id: crypto.randomUUID(), + objective: trimmedObjective, + status: 'active', + createdAt: now, + updatedAt: now, + evidence: [], + } + writeMission(projectRoot, mission, scopeId) + return mission +} + +export function completeMission( + projectRoot: string, + evidence: string[], + scopeId?: string, +): MissionState { + const mission = loadMission(projectRoot, scopeId) + if (!mission) throw new Error('No mission found.') + const completed: MissionState = { + ...mission, + status: 'completed', + evidence: evidence.map((item) => item.trim()).filter(Boolean), + updatedAt: new Date().toISOString(), + } + writeMission(projectRoot, completed, scopeId) + return completed +} + +export function cancelMission(projectRoot: string, scopeId?: string): MissionState { + const mission = loadMission(projectRoot, scopeId) + if (!mission) throw new Error('No mission found.') + const cancelled: MissionState = { + ...mission, + status: 'cancelled', + updatedAt: new Date().toISOString(), + } + writeMission(projectRoot, cancelled, scopeId) + return cancelled +} + +export function refreshMissionCompletion(projectRoot: string, scopeId?: string): MissionState | null { + const mission = loadMission(projectRoot, scopeId) + if (!mission) return null + + if (mission.status === 'completed') { + // Mission is marked as completed. However, if task_plan.md has pending tasks... + const planPath = path.join(projectRoot, 'task_plan.md') + if (fs.existsSync(planPath)) { + const plan = fs.readFileSync(planPath, 'utf8') + if (plan.includes('- [ ]') || plan.includes('- [/]')) { + mission.status = 'active' + mission.evidence = ['Forced back to active: there are pending tasks in the plan'] + writeMission(projectRoot, mission, scopeId) + } + } + } + return mission +} + +export function buildMissionPrompt(projectRoot: string, mission: MissionState, scopeId?: string): string { + const relPath = path.relative(projectRoot, getMissionPath(projectRoot, scopeId)).replace(/\\/g, '/') + return `ACTIVE MISSION: ${mission.objective} + +Execute the entire mission autonomously. Strictly follow the task_plan.md file. Do not edit the mission via str_replace, as this is ignored. Instead, use the write_todos tool to update progress in the task plan. + +The persistent state of this conversation is in ${relPath}. **PROTOCOLO DE DOUBLE-CHECK OBRIGATÓRIO**: Antes de se dar por satisfeito, você DEVE garantir que não há itens \`- [ ]\` no arquivo task_plan.md. Se houver falhas, CONTINUE TRABALHANDO. +` +} + +export function buildMissionContinuation(projectRoot: string, mission: MissionState, scopeId?: string): string { + const relPath = path.relative(projectRoot, getMissionPath(projectRoot, scopeId)).replace(/\\/g, '/') + return `\n\n[Persistent mission ${mission.id}] Continue until complete: ${mission.objective}. Do not stop while there are unchecked boxes in task_plan.md.` +} + +export function formatMissionStatus(mission: MissionState | null): string { + if (!mission) return 'No persistent mission.' + const evidence = mission.evidence.length + ? `\nEvidence:\n${mission.evidence.map((item) => `- ${item}`).join('\n')}` + : '' + return `Mission ${mission.status}: ${mission.objective}${evidence}` +} diff --git a/cli/src/project-files.ts b/cli/src/project-files.ts index 99ffa73705..53621ab2e6 100644 --- a/cli/src/project-files.ts +++ b/cli/src/project-files.ts @@ -43,6 +43,10 @@ export function startNewChat() { return currentChatId } +export function getMissionScopeId() { + return getCurrentChatId() +} + // Get the project-specific data directory export function getProjectDataDir(): string { const root = getProjectRoot()