mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07:00
440 lines
11 KiB
TypeScript
440 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright 2026 Google LLC
|
||
|
|
* SPDX-License-Identifier: Apache-2.0
|
||
|
|
*/
|
||
|
|
|
||
|
|
import * as fs from 'node:fs/promises';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
import { randomUUID } from 'node:crypto';
|
||
|
|
import type { Config } from '../config/config.js';
|
||
|
|
|
||
|
|
export const DEFAULT_DEEP_WORK_MAX_RUNS = 5;
|
||
|
|
export const DEFAULT_DEEP_WORK_MAX_TIME_MINUTES = 60;
|
||
|
|
const DEEP_WORK_DIR_NAME = 'deep-work';
|
||
|
|
const DEEP_WORK_STATE_FILE_NAME = 'state.json';
|
||
|
|
|
||
|
|
export type DeepWorkStatus =
|
||
|
|
| 'configured'
|
||
|
|
| 'ready'
|
||
|
|
| 'running'
|
||
|
|
| 'paused'
|
||
|
|
| 'stopped'
|
||
|
|
| 'completed'
|
||
|
|
| 'rejected';
|
||
|
|
|
||
|
|
export type DeepWorkReadinessVerdict = 'ready' | 'needs_answers' | 'reject';
|
||
|
|
|
||
|
|
export interface DeepWorkQuestion {
|
||
|
|
id: string;
|
||
|
|
question: string;
|
||
|
|
required: boolean;
|
||
|
|
answer: string;
|
||
|
|
done: boolean;
|
||
|
|
updatedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DeepWorkReadinessReport {
|
||
|
|
verdict: DeepWorkReadinessVerdict;
|
||
|
|
missingRequiredQuestionIds: string[];
|
||
|
|
followUpQuestions: string[];
|
||
|
|
blockingReasons: string[];
|
||
|
|
singleShotRecommendation: boolean;
|
||
|
|
recommendationText?: string;
|
||
|
|
reviewer?: 'subagent' | 'heuristic';
|
||
|
|
generatedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface DeepWorkState {
|
||
|
|
runId: string;
|
||
|
|
status: DeepWorkStatus;
|
||
|
|
prompt: string;
|
||
|
|
approvedPlanPath: string | null;
|
||
|
|
maxRuns: number;
|
||
|
|
maxTimeMinutes: number;
|
||
|
|
completionPromise: string | null;
|
||
|
|
requiredQuestions: DeepWorkQuestion[];
|
||
|
|
iteration: number;
|
||
|
|
createdAt: string;
|
||
|
|
startedAt: string | null;
|
||
|
|
lastUpdatedAt: string;
|
||
|
|
rejectionReason: string | null;
|
||
|
|
readinessReport: DeepWorkReadinessReport | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MULTI_STEP_KEYWORDS = [
|
||
|
|
'implement',
|
||
|
|
'refactor',
|
||
|
|
'migrate',
|
||
|
|
'architecture',
|
||
|
|
'system',
|
||
|
|
'workflow',
|
||
|
|
'pipeline',
|
||
|
|
'multiple',
|
||
|
|
'across',
|
||
|
|
'iterations',
|
||
|
|
'loop',
|
||
|
|
'phases',
|
||
|
|
'build out',
|
||
|
|
'full feature',
|
||
|
|
'comprehensive',
|
||
|
|
'end-to-end',
|
||
|
|
'validation',
|
||
|
|
'test suite',
|
||
|
|
'multi-step',
|
||
|
|
'deep',
|
||
|
|
];
|
||
|
|
|
||
|
|
const VALID_DEEP_WORK_STATUSES = new Set<DeepWorkStatus>([
|
||
|
|
'configured',
|
||
|
|
'ready',
|
||
|
|
'running',
|
||
|
|
'paused',
|
||
|
|
'stopped',
|
||
|
|
'completed',
|
||
|
|
'rejected',
|
||
|
|
]);
|
||
|
|
|
||
|
|
const VALID_READINESS_VERDICTS = new Set<DeepWorkReadinessVerdict>([
|
||
|
|
'ready',
|
||
|
|
'needs_answers',
|
||
|
|
'reject',
|
||
|
|
]);
|
||
|
|
|
||
|
|
function nowIsoString(): string {
|
||
|
|
return new Date().toISOString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeQuestionId(value: string): string {
|
||
|
|
const trimmed = value.trim();
|
||
|
|
if (!trimmed) {
|
||
|
|
return `question-${randomUUID().slice(0, 8)}`;
|
||
|
|
}
|
||
|
|
return trimmed
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeQuestion(
|
||
|
|
question: Partial<DeepWorkQuestion>,
|
||
|
|
index: number,
|
||
|
|
): DeepWorkQuestion {
|
||
|
|
const answer = (question.answer ?? '').trim();
|
||
|
|
const required = question.required ?? true;
|
||
|
|
const doneFromInput = question.done ?? false;
|
||
|
|
const done = required ? answer.length > 0 || doneFromInput : doneFromInput;
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: normalizeQuestionId(question.id ?? `question-${index + 1}`),
|
||
|
|
question: (question.question ?? '').trim(),
|
||
|
|
required,
|
||
|
|
answer,
|
||
|
|
done,
|
||
|
|
updatedAt: question.updatedAt ?? nowIsoString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeQuestions(
|
||
|
|
questions: Array<Partial<DeepWorkQuestion>> | undefined,
|
||
|
|
): DeepWorkQuestion[] {
|
||
|
|
if (!questions || questions.length === 0) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
const deduped = new Map<string, DeepWorkQuestion>();
|
||
|
|
for (let i = 0; i < questions.length; i++) {
|
||
|
|
const normalized = normalizeQuestion(questions[i], i);
|
||
|
|
if (!normalized.question) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
deduped.set(normalized.id, normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
return Array.from(deduped.values());
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeReadinessReport(
|
||
|
|
input: unknown,
|
||
|
|
): DeepWorkReadinessReport | null {
|
||
|
|
if (!input || typeof input !== 'object') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = input as Partial<DeepWorkReadinessReport>;
|
||
|
|
if (!report.verdict || !VALID_READINESS_VERDICTS.has(report.verdict)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
!Array.isArray(report.missingRequiredQuestionIds) ||
|
||
|
|
!Array.isArray(report.followUpQuestions) ||
|
||
|
|
!Array.isArray(report.blockingReasons)
|
||
|
|
) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
verdict: report.verdict,
|
||
|
|
missingRequiredQuestionIds: report.missingRequiredQuestionIds.filter(
|
||
|
|
(item): item is string => typeof item === 'string',
|
||
|
|
),
|
||
|
|
followUpQuestions: report.followUpQuestions.filter(
|
||
|
|
(item): item is string => typeof item === 'string',
|
||
|
|
),
|
||
|
|
blockingReasons: report.blockingReasons.filter(
|
||
|
|
(item): item is string => typeof item === 'string',
|
||
|
|
),
|
||
|
|
singleShotRecommendation: report.singleShotRecommendation === true,
|
||
|
|
recommendationText:
|
||
|
|
typeof report.recommendationText === 'string'
|
||
|
|
? report.recommendationText
|
||
|
|
: undefined,
|
||
|
|
reviewer: report.reviewer === 'subagent' ? 'subagent' : 'heuristic',
|
||
|
|
generatedAt:
|
||
|
|
typeof report.generatedAt === 'string'
|
||
|
|
? report.generatedAt
|
||
|
|
: nowIsoString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildDefaultState(): DeepWorkState {
|
||
|
|
const now = nowIsoString();
|
||
|
|
return {
|
||
|
|
runId: `deep-work-${randomUUID().slice(0, 12)}`,
|
||
|
|
status: 'configured',
|
||
|
|
prompt: '',
|
||
|
|
approvedPlanPath: null,
|
||
|
|
maxRuns: DEFAULT_DEEP_WORK_MAX_RUNS,
|
||
|
|
maxTimeMinutes: DEFAULT_DEEP_WORK_MAX_TIME_MINUTES,
|
||
|
|
completionPromise: null,
|
||
|
|
requiredQuestions: [],
|
||
|
|
iteration: 0,
|
||
|
|
createdAt: now,
|
||
|
|
startedAt: null,
|
||
|
|
lastUpdatedAt: now,
|
||
|
|
rejectionReason: null,
|
||
|
|
readinessReport: null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function sanitizeDeepWorkState(input: unknown): DeepWorkState {
|
||
|
|
const base = buildDefaultState();
|
||
|
|
if (!input || typeof input !== 'object') {
|
||
|
|
return base;
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidate = input as Partial<DeepWorkState>;
|
||
|
|
return {
|
||
|
|
runId:
|
||
|
|
typeof candidate.runId === 'string' && candidate.runId.trim().length > 0
|
||
|
|
? candidate.runId
|
||
|
|
: base.runId,
|
||
|
|
status:
|
||
|
|
typeof candidate.status === 'string'
|
||
|
|
? VALID_DEEP_WORK_STATUSES.has(candidate.status)
|
||
|
|
? candidate.status
|
||
|
|
: base.status
|
||
|
|
: base.status,
|
||
|
|
prompt:
|
||
|
|
typeof candidate.prompt === 'string' ? candidate.prompt : base.prompt,
|
||
|
|
approvedPlanPath:
|
||
|
|
typeof candidate.approvedPlanPath === 'string' &&
|
||
|
|
candidate.approvedPlanPath.trim().length > 0
|
||
|
|
? candidate.approvedPlanPath
|
||
|
|
: null,
|
||
|
|
maxRuns:
|
||
|
|
typeof candidate.maxRuns === 'number' && candidate.maxRuns > 0
|
||
|
|
? Math.floor(candidate.maxRuns)
|
||
|
|
: base.maxRuns,
|
||
|
|
maxTimeMinutes:
|
||
|
|
typeof candidate.maxTimeMinutes === 'number' &&
|
||
|
|
candidate.maxTimeMinutes > 0
|
||
|
|
? Math.floor(candidate.maxTimeMinutes)
|
||
|
|
: base.maxTimeMinutes,
|
||
|
|
completionPromise:
|
||
|
|
typeof candidate.completionPromise === 'string' &&
|
||
|
|
candidate.completionPromise.trim().length > 0
|
||
|
|
? candidate.completionPromise
|
||
|
|
: null,
|
||
|
|
requiredQuestions: normalizeQuestions(candidate.requiredQuestions),
|
||
|
|
iteration:
|
||
|
|
typeof candidate.iteration === 'number' && candidate.iteration >= 0
|
||
|
|
? Math.floor(candidate.iteration)
|
||
|
|
: 0,
|
||
|
|
createdAt:
|
||
|
|
typeof candidate.createdAt === 'string'
|
||
|
|
? candidate.createdAt
|
||
|
|
: base.createdAt,
|
||
|
|
startedAt:
|
||
|
|
typeof candidate.startedAt === 'string' ? candidate.startedAt : null,
|
||
|
|
lastUpdatedAt:
|
||
|
|
typeof candidate.lastUpdatedAt === 'string'
|
||
|
|
? candidate.lastUpdatedAt
|
||
|
|
: base.lastUpdatedAt,
|
||
|
|
rejectionReason:
|
||
|
|
typeof candidate.rejectionReason === 'string'
|
||
|
|
? candidate.rejectionReason
|
||
|
|
: null,
|
||
|
|
readinessReport: sanitizeReadinessReport(candidate.readinessReport) ?? null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getDeepWorkStateDirectory(config: Config): string {
|
||
|
|
return path.join(config.storage.getProjectTempDir(), DEEP_WORK_DIR_NAME);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getDeepWorkStatePath(config: Config): string {
|
||
|
|
return path.join(
|
||
|
|
getDeepWorkStateDirectory(config),
|
||
|
|
DEEP_WORK_STATE_FILE_NAME,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function loadDeepWorkState(
|
||
|
|
config: Config,
|
||
|
|
): Promise<DeepWorkState | undefined> {
|
||
|
|
try {
|
||
|
|
const raw = await fs.readFile(getDeepWorkStatePath(config), 'utf-8');
|
||
|
|
const parsed = JSON.parse(raw) as unknown;
|
||
|
|
return sanitizeDeepWorkState(parsed);
|
||
|
|
} catch (error) {
|
||
|
|
if (
|
||
|
|
error &&
|
||
|
|
typeof error === 'object' &&
|
||
|
|
'code' in error &&
|
||
|
|
error.code === 'ENOENT'
|
||
|
|
) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function saveDeepWorkState(
|
||
|
|
config: Config,
|
||
|
|
state: DeepWorkState,
|
||
|
|
): Promise<void> {
|
||
|
|
const normalized = sanitizeDeepWorkState(state);
|
||
|
|
normalized.lastUpdatedAt = nowIsoString();
|
||
|
|
await fs.mkdir(getDeepWorkStateDirectory(config), { recursive: true });
|
||
|
|
await fs.writeFile(
|
||
|
|
getDeepWorkStatePath(config),
|
||
|
|
JSON.stringify(normalized, null, 2),
|
||
|
|
'utf-8',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function loadOrCreateDeepWorkState(
|
||
|
|
config: Config,
|
||
|
|
): Promise<DeepWorkState> {
|
||
|
|
return (await loadDeepWorkState(config)) ?? buildDefaultState();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function inferDeepWorkStatusFromReadiness(
|
||
|
|
state: DeepWorkState,
|
||
|
|
): DeepWorkStatus {
|
||
|
|
if (state.readinessReport?.verdict === 'ready') {
|
||
|
|
return 'ready';
|
||
|
|
}
|
||
|
|
if (state.readinessReport?.verdict === 'reject') {
|
||
|
|
return 'rejected';
|
||
|
|
}
|
||
|
|
return 'configured';
|
||
|
|
}
|
||
|
|
|
||
|
|
function looksSingleShot(prompt: string): boolean {
|
||
|
|
const normalizedPrompt = prompt.trim().toLowerCase();
|
||
|
|
if (!normalizedPrompt) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (normalizedPrompt.length < 70) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasMultiStepSignal = MULTI_STEP_KEYWORDS.some((keyword) =>
|
||
|
|
normalizedPrompt.includes(keyword),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hasMultiStepSignal) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const conjunctionCount =
|
||
|
|
(normalizedPrompt.match(/\band\b/g) ?? []).length +
|
||
|
|
(normalizedPrompt.match(/\bthen\b/g) ?? []).length;
|
||
|
|
|
||
|
|
return conjunctionCount === 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function evaluateDeepWorkReadinessHeuristic(
|
||
|
|
state: DeepWorkState,
|
||
|
|
): DeepWorkReadinessReport {
|
||
|
|
const missingRequiredQuestions = state.requiredQuestions
|
||
|
|
.filter((q) => q.required && q.answer.trim().length === 0)
|
||
|
|
.map((q) => q.id);
|
||
|
|
|
||
|
|
const followUpQuestions = state.requiredQuestions
|
||
|
|
.filter((q) => q.required && q.answer.trim().length === 0)
|
||
|
|
.map((q) => `Please answer required question "${q.id}": ${q.question}`);
|
||
|
|
|
||
|
|
const blockingReasons: string[] = [];
|
||
|
|
|
||
|
|
if (!state.prompt.trim()) {
|
||
|
|
blockingReasons.push('Deep Work prompt is required before starting.');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (missingRequiredQuestions.length > 0) {
|
||
|
|
blockingReasons.push(
|
||
|
|
`Required questions missing answers: ${missingRequiredQuestions.join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const singleShot = looksSingleShot(state.prompt);
|
||
|
|
if (singleShot) {
|
||
|
|
blockingReasons.push(
|
||
|
|
'This looks like a single-shot/simple request; Deep Work is best for multi-step iterative tasks.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
let verdict: DeepWorkReadinessVerdict = 'ready';
|
||
|
|
if (singleShot) {
|
||
|
|
verdict = 'reject';
|
||
|
|
} else if (blockingReasons.length > 0) {
|
||
|
|
verdict = 'needs_answers';
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
verdict,
|
||
|
|
missingRequiredQuestionIds: missingRequiredQuestions,
|
||
|
|
followUpQuestions,
|
||
|
|
blockingReasons,
|
||
|
|
singleShotRecommendation: singleShot,
|
||
|
|
recommendationText: singleShot
|
||
|
|
? 'Use standard execution mode for single-step tasks. Switch to Deep Work for multi-iteration implementation work.'
|
||
|
|
: undefined,
|
||
|
|
reviewer: 'heuristic',
|
||
|
|
generatedAt: nowIsoString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function upsertQuestions(
|
||
|
|
state: DeepWorkState,
|
||
|
|
questions: Array<Partial<DeepWorkQuestion>>,
|
||
|
|
): DeepWorkState {
|
||
|
|
const normalized = normalizeQuestions(questions);
|
||
|
|
const merged = new Map<string, DeepWorkQuestion>();
|
||
|
|
for (const question of state.requiredQuestions) {
|
||
|
|
merged.set(question.id, question);
|
||
|
|
}
|
||
|
|
for (const question of normalized) {
|
||
|
|
merged.set(question.id, question);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
...state,
|
||
|
|
requiredQuestions: Array.from(merged.values()),
|
||
|
|
lastUpdatedAt: nowIsoString(),
|
||
|
|
};
|
||
|
|
}
|