From ade42fd5ac512b9602c792206479a08ba51d20a0 Mon Sep 17 00:00:00 2001 From: Anjali Sridhar Date: Wed, 18 Feb 2026 11:56:18 -0800 Subject: [PATCH] feat(core,cli): implement task tracker foundation with feature flag --- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 9 + packages/core/src/config/config.ts | 51 ++ .../src/config/trackerFeatureFlag.test.ts | 47 ++ packages/core/src/index.ts | 3 + .../core/src/services/trackerService.test.ts | 147 +++++ packages/core/src/services/trackerService.ts | 191 ++++++ packages/core/src/services/trackerTypes.ts | 21 + .../src/tools/definitions/trackerTools.ts | 174 ++++++ packages/core/src/tools/tool-names.ts | 14 + packages/core/src/tools/trackerTools.test.ts | 112 ++++ packages/core/src/tools/trackerTools.ts | 563 ++++++++++++++++++ plans/task-tracker-implementation.md | 101 ++++ 13 files changed, 1434 insertions(+) create mode 100644 packages/core/src/config/trackerFeatureFlag.test.ts create mode 100644 packages/core/src/services/trackerService.test.ts create mode 100644 packages/core/src/services/trackerService.ts create mode 100644 packages/core/src/services/trackerTypes.ts create mode 100644 packages/core/src/tools/definitions/trackerTools.ts create mode 100644 packages/core/src/tools/trackerTools.test.ts create mode 100644 packages/core/src/tools/trackerTools.ts create mode 100644 plans/task-tracker-implementation.md diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 87eb1e8fa7..e9dea22b70 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -789,6 +789,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + tracker: settings.experimental?.taskTracker, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 07d2faec49..675ae06ca1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1593,6 +1593,15 @@ const SETTINGS_SCHEMA = { description: 'Enable planning features (Plan Mode and tools).', showInDialog: true, }, + taskTracker: { + type: 'boolean', + label: 'Task Tracker', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable task tracker tools.', + showInDialog: true, + }, }, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6d811799bc..bf502090cb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -68,6 +68,15 @@ import { ideContextStore } from '../ide/ideContext.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import type { FileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; +import { + TrackerInitTool, + TrackerCreateTaskTool, + TrackerUpdateTaskTool, + TrackerGetTaskTool, + TrackerListTasksTool, + TrackerAddDependencyTool, + TrackerVisualizeTool, +} from '../tools/trackerTools.js'; import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js'; import { RipgrepFallbackEvent, @@ -89,6 +98,7 @@ import type { import { ModelConfigService } from '../services/modelConfigService.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { ContextManager } from '../services/contextManager.js'; +import { TrackerService } from '../services/trackerService.js'; import type { GenerateContentParameters } from '@google/genai'; // Re-export OAuth config type @@ -478,6 +488,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + tracker?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; extensionsEnabled?: boolean; @@ -505,6 +516,7 @@ export class Config { private sessionId: string; private clientVersion: string; private fileSystemService: FileSystemService; + private trackerService?: TrackerService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; readonly modelConfigService: ModelConfigService; @@ -663,6 +675,7 @@ export class Config { private readonly experimentalJitContext: boolean; private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; + private readonly trackerEnabled: boolean; private contextManager?: ContextManager; private terminalBackground: string | undefined = undefined; private remoteAdminSettings: AdminControlsSettings | undefined; @@ -751,6 +764,7 @@ export class Config { this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? false; + this.trackerEnabled = params.tracker ?? false; this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true; this.skillsSupport = params.skillsSupport ?? true; this.disabledSkills = params.disabledSkills ?? []; @@ -1896,6 +1910,13 @@ export class Config { return this.bugCommand; } + getTrackerService(): TrackerService { + if (!this.trackerService) { + this.trackerService = new TrackerService(this.targetDir); + } + return this.trackerService; + } + getFileService(): FileDiscoveryService { if (!this.fileDiscoveryService) { this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, { @@ -1957,6 +1978,10 @@ export class Config { return this.planEnabled; } + isTrackerEnabled(): boolean { + return this.trackerEnabled; + } + getApprovedPlanPath(): string | undefined { return this.approvedPlanPath; } @@ -2440,6 +2465,32 @@ export class Config { ); } + if (this.isTrackerEnabled()) { + maybeRegister(TrackerInitTool, () => + registry.registerTool(new TrackerInitTool(this, this.messageBus)), + ); + maybeRegister(TrackerCreateTaskTool, () => + registry.registerTool(new TrackerCreateTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerUpdateTaskTool, () => + registry.registerTool(new TrackerUpdateTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerGetTaskTool, () => + registry.registerTool(new TrackerGetTaskTool(this, this.messageBus)), + ); + maybeRegister(TrackerListTasksTool, () => + registry.registerTool(new TrackerListTasksTool(this, this.messageBus)), + ); + maybeRegister(TrackerAddDependencyTool, () => + registry.registerTool( + new TrackerAddDependencyTool(this, this.messageBus), + ), + ); + maybeRegister(TrackerVisualizeTool, () => + registry.registerTool(new TrackerVisualizeTool(this, this.messageBus)), + ); + } + // Register Subagents as Tools this.registerSubAgentTools(registry); diff --git a/packages/core/src/config/trackerFeatureFlag.test.ts b/packages/core/src/config/trackerFeatureFlag.test.ts new file mode 100644 index 0000000000..d0182ace96 --- /dev/null +++ b/packages/core/src/config/trackerFeatureFlag.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { Config } from './config.js'; +import { TRACKER_INIT_TOOL_NAME } from '../tools/tool-names.js'; +import * as os from 'node:os'; + +describe('Config Tracker Feature Flag', () => { + const baseParams = { + sessionId: 'test-session', + targetDir: os.tmpdir(), + cwd: os.tmpdir(), + model: 'gemini-1.5-pro', + debugMode: false, + }; + + it('should not register tracker tools by default', async () => { + const config = new Config(baseParams); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_INIT_TOOL_NAME)).toBeUndefined(); + }); + + it('should register tracker tools when tracker is enabled', async () => { + const config = new Config({ + ...baseParams, + tracker: true, + }); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_INIT_TOOL_NAME)).toBeDefined(); + }); + + it('should not register tracker tools when tracker is explicitly disabled', async () => { + const config = new Config({ + ...baseParams, + tracker: false, + }); + await config.initialize(); + const registry = config.getToolRegistry(); + expect(registry.getTool(TRACKER_INIT_TOOL_NAME)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8232f73570..ad47c3765c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,6 +112,8 @@ export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; +export * from './services/trackerService.js'; +export * from './services/trackerTypes.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; @@ -157,6 +159,7 @@ export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; export * from './tools/write-todos.js'; +export * from './tools/trackerTools.js'; // MCP OAuth export { MCPOAuthProvider } from './mcp/oauth-provider.js'; diff --git a/packages/core/src/services/trackerService.test.ts b/packages/core/src/services/trackerService.test.ts new file mode 100644 index 0000000000..136953994b --- /dev/null +++ b/packages/core/src/services/trackerService.test.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { TrackerService } from './trackerService.js'; +import type { TrackerTask } from './trackerTypes.js'; + +describe('TrackerService', () => { + let testRootDir: string; + let service: TrackerService; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'tracker-service-test-'), + ); + service = new TrackerService(testRootDir); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + }); + + it('should initialize the tracker directory', async () => { + await service.ensureInitialized(); + const tasksDir = path.join(testRootDir, '.tracker', 'tasks'); + const stats = await fs.stat(tasksDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should create a task with a generated 6-char hex ID', async () => { + const taskData: Omit = { + title: 'Test Task', + description: 'Test Description', + type: 'task', + status: 'open', + dependencies: [], + }; + + const task = await service.createTask(taskData); + expect(task.id).toMatch(/^[0-9a-f]{6}$/); + expect(task.title).toBe(taskData.title); + + const savedTask = await service.getTask(task.id); + expect(savedTask).toEqual(task); + }); + + it('should list all tasks', async () => { + await service.createTask({ + title: 'Task 1', + description: 'Desc 1', + type: 'task', + status: 'open', + dependencies: [], + }); + await service.createTask({ + title: 'Task 2', + description: 'Desc 2', + type: 'task', + status: 'open', + dependencies: [], + }); + + const tasks = await service.listTasks(); + expect(tasks.length).toBe(2); + expect(tasks.map((t) => t.title)).toContain('Task 1'); + expect(tasks.map((t) => t.title)).toContain('Task 2'); + }); + + it('should update a task', async () => { + const task = await service.createTask({ + title: 'Original Title', + description: 'Original Desc', + type: 'task', + status: 'open', + dependencies: [], + }); + + const updated = await service.updateTask(task.id, { + title: 'New Title', + status: 'in_progress', + }); + expect(updated.title).toBe('New Title'); + expect(updated.status).toBe('in_progress'); + expect(updated.description).toBe('Original Desc'); + + const retrieved = await service.getTask(task.id); + expect(retrieved).toEqual(updated); + }); + + it('should prevent closing a task if dependencies are not closed', async () => { + const dep = await service.createTask({ + title: 'Dependency', + description: 'Must be closed first', + type: 'task', + status: 'open', + dependencies: [], + }); + + const task = await service.createTask({ + title: 'Main Task', + description: 'Depends on dep', + type: 'task', + status: 'open', + dependencies: [dep.id], + }); + + await expect( + service.updateTask(task.id, { status: 'closed' }), + ).rejects.toThrow(/Cannot close task/); + + // Close dependency + await service.updateTask(dep.id, { status: 'closed' }); + + // Now it should work + const updated = await service.updateTask(task.id, { status: 'closed' }); + expect(updated.status).toBe('closed'); + }); + + it('should detect circular dependencies', async () => { + const taskA = await service.createTask({ + title: 'Task A', + description: 'A', + type: 'task', + status: 'open', + dependencies: [], + }); + + const taskB = await service.createTask({ + title: 'Task B', + description: 'B', + type: 'task', + status: 'open', + dependencies: [taskA.id], + }); + + // Try to make A depend on B + await expect( + service.updateTask(taskA.id, { dependencies: [taskB.id] }), + ).rejects.toThrow(/Circular dependency detected/); + }); +}); diff --git a/packages/core/src/services/trackerService.ts b/packages/core/src/services/trackerService.ts new file mode 100644 index 0000000000..f50eb711c1 --- /dev/null +++ b/packages/core/src/services/trackerService.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { TrackerTask } from './trackerTypes.js'; + +export class TrackerService { + private readonly trackerDir: string; + private readonly tasksDir: string; + + constructor(private readonly workspaceRoot: string) { + this.trackerDir = path.join(this.workspaceRoot, '.tracker'); + this.tasksDir = path.join(this.trackerDir, 'tasks'); + } + + /** + * Initializes the tracker storage if it doesn't exist. + */ + async ensureInitialized(): Promise { + await fs.mkdir(this.tasksDir, { recursive: true }); + } + + /** + * Generates a 6-character hex ID. + */ + private generateId(): string { + return Math.random().toString(16).substring(2, 8).padEnd(6, '0'); + } + + /** + * Creates a new task and saves it to disk. + */ + async createTask(taskData: Omit): Promise { + await this.ensureInitialized(); + const id = this.generateId(); + const task: TrackerTask = { + ...taskData, + id, + }; + + await this.saveTask(task); + return task; + } + + /** + * Reads a task by ID. + */ + async getTask(id: string): Promise { + const taskPath = path.join(this.tasksDir, `${id}.json`); + try { + const content = await fs.readFile(taskPath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as TrackerTask; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } + } + + /** + * Lists all tasks in the tracker. + */ + async listTasks(): Promise { + await this.ensureInitialized(); + try { + const files = await fs.readdir(this.tasksDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + const tasks = await Promise.all( + jsonFiles.map(async (f) => { + const content = await fs.readFile( + path.join(this.tasksDir, f), + 'utf8', + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as TrackerTask; + }), + ); + return tasks; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return []; + } + throw error; + } + } + + /** + * Updates an existing task and saves it to disk. + */ + async updateTask( + id: string, + updates: Partial, + ): Promise { + const task = await this.getTask(id); + if (!task) { + throw new Error(`Task with ID ${id} not found.`); + } + + const updatedTask = { ...task, ...updates }; + + // Validate status transition if closing + if (updatedTask.status === 'closed' && task.status !== 'closed') { + await this.validateCanClose(updatedTask); + } + + // Validate circular dependencies if dependencies changed + if (updates.dependencies) { + await this.validateNoCircularDependencies(updatedTask); + } + + await this.saveTask(updatedTask); + return updatedTask; + } + + /** + * Saves a task to disk. + */ + private async saveTask(task: TrackerTask): Promise { + const taskPath = path.join(this.tasksDir, `${task.id}.json`); + await fs.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8'); + } + + /** + * Validates that a task can be closed (all dependencies must be closed). + */ + private async validateCanClose(task: TrackerTask): Promise { + for (const depId of task.dependencies) { + const dep = await this.getTask(depId); + if (!dep) { + throw new Error(`Dependency ${depId} not found for task ${task.id}.`); + } + if (dep.status !== 'closed') { + throw new Error( + `Cannot close task ${task.id} because dependency ${depId} is still ${dep.status}.`, + ); + } + } + } + + /** + * Validates that there are no circular dependencies. + */ + private async validateNoCircularDependencies( + task: TrackerTask, + ): Promise { + const visited = new Set(); + const stack = new Set(); + + const check = async (currentId: string) => { + if (stack.has(currentId)) { + throw new Error( + `Circular dependency detected involving task ${currentId}.`, + ); + } + if (visited.has(currentId)) { + return; + } + + visited.add(currentId); + stack.add(currentId); + + const currentTask = + currentId === task.id ? task : await this.getTask(currentId); + if (currentTask) { + for (const depId of currentTask.dependencies) { + await check(depId); + } + } + + stack.delete(currentId); + }; + + await check(task.id); + } +} diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts new file mode 100644 index 0000000000..06509fc770 --- /dev/null +++ b/packages/core/src/services/trackerTypes.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type TaskType = 'epic' | 'task' | 'bug'; + +export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'closed'; + +export interface TrackerTask { + id: string; + title: string; + description: string; + type: TaskType; + status: TaskStatus; + parentId?: string; + dependencies: string[]; + subagentSessionId?: string; + metadata?: Record; +} diff --git a/packages/core/src/tools/definitions/trackerTools.ts b/packages/core/src/tools/definitions/trackerTools.ts new file mode 100644 index 0000000000..d5c5a678fd --- /dev/null +++ b/packages/core/src/tools/definitions/trackerTools.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolDefinition } from './types.js'; +import { + TRACKER_INIT_TOOL_NAME, + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, +} from '../tool-names.js'; + +export const TRACKER_INIT_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_INIT_TOOL_NAME, + description: + 'Initializes the task tracker in the current workspace by creating the .tracker directory.', + parametersJsonSchema: { + type: 'object', + properties: {}, + }, + }, +}; + +export const TRACKER_CREATE_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_CREATE_TASK_TOOL_NAME, + description: 'Creates a new task in the tracker.', + parametersJsonSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Short title of the task.', + }, + description: { + type: 'string', + description: 'Detailed description of the task.', + }, + type: { + type: 'string', + enum: ['epic', 'task', 'bug'], + description: 'Type of the task.', + }, + parentId: { + type: 'string', + description: 'Optional ID of the parent task.', + }, + dependencies: { + type: 'array', + items: { type: 'string' }, + description: 'Optional list of task IDs that this task depends on.', + }, + }, + required: ['title', 'description', 'type'], + }, + }, +}; + +export const TRACKER_UPDATE_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_UPDATE_TASK_TOOL_NAME, + description: 'Updates an existing task in the tracker.', + parametersJsonSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The 6-character hex ID of the task to update.', + }, + title: { + type: 'string', + description: 'New title for the task.', + }, + description: { + type: 'string', + description: 'New description for the task.', + }, + status: { + type: 'string', + enum: ['open', 'in_progress', 'blocked', 'closed'], + description: 'New status for the task.', + }, + dependencies: { + type: 'array', + items: { type: 'string' }, + description: 'New list of dependency IDs.', + }, + }, + required: ['id'], + }, + }, +}; + +export const TRACKER_GET_TASK_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_GET_TASK_TOOL_NAME, + description: 'Retrieves details for a specific task.', + parametersJsonSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The 6-character hex ID of the task.', + }, + }, + required: ['id'], + }, + }, +}; + +export const TRACKER_LIST_TASKS_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_LIST_TASKS_TOOL_NAME, + description: + 'Lists tasks in the tracker, optionally filtered by status, type, or parent.', + parametersJsonSchema: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['open', 'in_progress', 'blocked', 'closed'], + description: 'Filter by status.', + }, + type: { + type: 'string', + enum: ['epic', 'task', 'bug'], + description: 'Filter by type.', + }, + parentId: { + type: 'string', + description: 'Filter by parent task ID.', + }, + }, + }, + }, +}; + +export const TRACKER_ADD_DEPENDENCY_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_ADD_DEPENDENCY_TOOL_NAME, + description: 'Adds a dependency between two tasks.', + parametersJsonSchema: { + type: 'object', + properties: { + taskId: { + type: 'string', + description: 'The ID of the task that has a dependency.', + }, + dependencyId: { + type: 'string', + description: 'The ID of the task that is being depended upon.', + }, + }, + required: ['taskId', 'dependencyId'], + }, + }, +}; + +export const TRACKER_VISUALIZE_DEFINITION: ToolDefinition = { + base: { + name: TRACKER_VISUALIZE_TOOL_NAME, + description: 'Renders an ASCII tree visualization of the task graph.', + parametersJsonSchema: { + type: 'object', + properties: {}, + }, + }, +}; diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index f837edbe29..03712d725c 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -41,6 +41,13 @@ export const ASK_USER_TOOL_NAME = 'ask_user'; export const ASK_USER_DISPLAY_NAME = 'Ask User'; export const EXIT_PLAN_MODE_TOOL_NAME = 'exit_plan_mode'; export const ENTER_PLAN_MODE_TOOL_NAME = 'enter_plan_mode'; +export const TRACKER_INIT_TOOL_NAME = 'tracker_init'; +export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task'; +export const TRACKER_UPDATE_TASK_TOOL_NAME = 'tracker_update_task'; +export const TRACKER_GET_TASK_TOOL_NAME = 'tracker_get_task'; +export const TRACKER_LIST_TASKS_TOOL_NAME = 'tracker_list_tasks'; +export const TRACKER_ADD_DEPENDENCY_TOOL_NAME = 'tracker_add_dependency'; +export const TRACKER_VISUALIZE_TOOL_NAME = 'tracker_visualize'; /** * Mapping of legacy tool names to their current names. @@ -94,6 +101,13 @@ export const ALL_BUILTIN_TOOL_NAMES = [ MEMORY_TOOL_NAME, ACTIVATE_SKILL_TOOL_NAME, ASK_USER_TOOL_NAME, + TRACKER_INIT_TOOL_NAME, + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, ] as const; /** diff --git a/packages/core/src/tools/trackerTools.test.ts b/packages/core/src/tools/trackerTools.test.ts new file mode 100644 index 0000000000..be5bcba5ac --- /dev/null +++ b/packages/core/src/tools/trackerTools.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Config } from '../config/config.js'; +import { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import { + TrackerInitTool, + TrackerCreateTaskTool, + TrackerListTasksTool, + TrackerUpdateTaskTool, +} from './trackerTools.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('Tracker Tools Integration', () => { + let tempDir: string; + let config: Config; + let messageBus: MessageBus; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-')); + config = new Config({ + sessionId: 'test-session', + targetDir: tempDir, + cwd: tempDir, + model: 'gemini-2.0-flash', + debugMode: false, + }); + messageBus = new MessageBus(null as unknown as PolicyEngine, false); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + const getSignal = () => new AbortController().signal; + + it('runs tracker_init and creates the directory', async () => { + const tool = new TrackerInitTool(config, messageBus); + const result = await tool.buildAndExecute({}, getSignal()); + + expect(result.llmContent).toContain('Task tracker initialized'); + const tasksDir = path.join(tempDir, '.tracker', 'tasks'); + const stats = await fs.stat(tasksDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('creates and lists tasks', async () => { + // Init first + await new TrackerInitTool(config, messageBus).buildAndExecute( + {}, + getSignal(), + ); + + const createTool = new TrackerCreateTaskTool(config, messageBus); + const createResult = await createTool.buildAndExecute( + { + title: 'Test Task', + description: 'Test Description', + type: 'task', + }, + getSignal(), + ); + + expect(createResult.llmContent).toContain('Created task'); + + const listTool = new TrackerListTasksTool(config, messageBus); + const listResult = await listTool.buildAndExecute({}, getSignal()); + expect(listResult.llmContent).toContain('Test Task'); + expect(listResult.llmContent).toContain('(open)'); + }); + + it('updates task status', async () => { + await new TrackerInitTool(config, messageBus).buildAndExecute( + {}, + getSignal(), + ); + + const createTool = new TrackerCreateTaskTool(config, messageBus); + await createTool.buildAndExecute( + { + title: 'Update Me', + description: '...', + type: 'task', + }, + getSignal(), + ); + + const tasks = await config.getTrackerService().listTasks(); + const taskId = tasks[0].id; + + const updateTool = new TrackerUpdateTaskTool(config, messageBus); + const updateResult = await updateTool.buildAndExecute( + { + id: taskId, + status: 'in_progress', + }, + getSignal(), + ); + + expect(updateResult.llmContent).toContain('Status: in_progress'); + + const task = await config.getTrackerService().getTask(taskId); + expect(task?.status).toBe('in_progress'); + }); +}); diff --git a/packages/core/src/tools/trackerTools.ts b/packages/core/src/tools/trackerTools.ts new file mode 100644 index 0000000000..74efd18f74 --- /dev/null +++ b/packages/core/src/tools/trackerTools.ts @@ -0,0 +1,563 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + TRACKER_ADD_DEPENDENCY_DEFINITION, + TRACKER_CREATE_TASK_DEFINITION, + TRACKER_GET_TASK_DEFINITION, + TRACKER_INIT_DEFINITION, + TRACKER_LIST_TASKS_DEFINITION, + TRACKER_UPDATE_TASK_DEFINITION, + TRACKER_VISUALIZE_DEFINITION, +} from './definitions/trackerTools.js'; +import { resolveToolDeclaration } from './definitions/resolver.js'; +import { + TRACKER_ADD_DEPENDENCY_TOOL_NAME, + TRACKER_CREATE_TASK_TOOL_NAME, + TRACKER_GET_TASK_TOOL_NAME, + TRACKER_INIT_TOOL_NAME, + TRACKER_LIST_TASKS_TOOL_NAME, + TRACKER_UPDATE_TASK_TOOL_NAME, + TRACKER_VISUALIZE_TOOL_NAME, +} from './tool-names.js'; +import type { ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolErrorType } from './tool-error.js'; +import type { + TrackerTask, + TaskStatus, + TaskType, +} from '../services/trackerTypes.js'; + +// --- Shared Base --- + +abstract class BaseTrackerInvocation< + P extends object, + R extends ToolResult, +> extends BaseToolInvocation { + constructor( + protected readonly config: Config, + params: P, + messageBus: MessageBus, + toolName: string, + ) { + super(params, messageBus, toolName); + } + + protected get service() { + return this.config.getTrackerService(); + } + + abstract override getDescription(): string; +} + +// --- tracker_init --- + +class TrackerInitInvocation extends BaseTrackerInvocation< + Record, + ToolResult +> { + getDescription(): string { + return 'Initializing the task tracker storage.'; + } + + override async execute(_signal: AbortSignal): Promise { + await this.service.ensureInitialized(); + return { + llmContent: + 'Task tracker initialized successfully. Storage is ready at .tracker/tasks/', + returnDisplay: 'Tracker initialized.', + }; + } +} + +export class TrackerInitTool extends BaseDeclarativeTool< + Record, + ToolResult +> { + static readonly Name = TRACKER_INIT_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerInitTool.Name, + 'Initialize Tracker', + TRACKER_INIT_DEFINITION.base.description!, + Kind.Edit, + TRACKER_INIT_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation( + params: Record, + messageBus: MessageBus, + ) { + return new TrackerInitInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_INIT_DEFINITION, modelId); + } +} + +// --- tracker_create_task --- + +interface CreateTaskParams { + title: string; + description: string; + type: TaskType; + parentId?: string; + dependencies?: string[]; +} + +class TrackerCreateTaskInvocation extends BaseTrackerInvocation< + CreateTaskParams, + ToolResult +> { + getDescription(): string { + return `Creating task: ${this.params.title}`; + } + + override async execute(_signal: AbortSignal): Promise { + const task = await this.service.createTask({ + title: this.params.title, + description: this.params.description, + type: this.params.type, + status: 'open', + parentId: this.params.parentId, + dependencies: this.params.dependencies ?? [], + }); + return { + llmContent: `Created task ${task.id}: ${task.title}`, + returnDisplay: `Created task ${task.id}.`, + }; + } +} + +export class TrackerCreateTaskTool extends BaseDeclarativeTool< + CreateTaskParams, + ToolResult +> { + static readonly Name = TRACKER_CREATE_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerCreateTaskTool.Name, + 'Create Task', + TRACKER_CREATE_TASK_DEFINITION.base.description!, + Kind.Edit, + TRACKER_CREATE_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: CreateTaskParams, messageBus: MessageBus) { + return new TrackerCreateTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_CREATE_TASK_DEFINITION, modelId); + } +} + +// --- tracker_update_task --- + +interface UpdateTaskParams { + id: string; + title?: string; + description?: string; + status?: TaskStatus; + dependencies?: string[]; +} + +class TrackerUpdateTaskInvocation extends BaseTrackerInvocation< + UpdateTaskParams, + ToolResult +> { + getDescription(): string { + return `Updating task ${this.params.id}`; + } + + override async execute(_signal: AbortSignal): Promise { + const { id, ...updates } = this.params; + try { + const task = await this.service.updateTask(id, updates); + return { + llmContent: `Updated task ${task.id}. Status: ${task.status}`, + returnDisplay: `Updated task ${task.id}.`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error updating task: ${errorMessage}`, + returnDisplay: 'Failed to update task.', + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + } +} + +export class TrackerUpdateTaskTool extends BaseDeclarativeTool< + UpdateTaskParams, + ToolResult +> { + static readonly Name = TRACKER_UPDATE_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerUpdateTaskTool.Name, + 'Update Task', + TRACKER_UPDATE_TASK_DEFINITION.base.description!, + Kind.Edit, + TRACKER_UPDATE_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: UpdateTaskParams, messageBus: MessageBus) { + return new TrackerUpdateTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_UPDATE_TASK_DEFINITION, modelId); + } +} + +// --- tracker_get_task --- + +interface GetTaskParams { + id: string; +} + +class TrackerGetTaskInvocation extends BaseTrackerInvocation< + GetTaskParams, + ToolResult +> { + getDescription(): string { + return `Retrieving task ${this.params.id}`; + } + + override async execute(_signal: AbortSignal): Promise { + const task = await this.service.getTask(this.params.id); + if (!task) { + return { + llmContent: `Task ${this.params.id} not found.`, + returnDisplay: 'Task not found.', + }; + } + return { + llmContent: JSON.stringify(task, null, 2), + returnDisplay: `Retrieved task ${task.id}.`, + }; + } +} + +export class TrackerGetTaskTool extends BaseDeclarativeTool< + GetTaskParams, + ToolResult +> { + static readonly Name = TRACKER_GET_TASK_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerGetTaskTool.Name, + 'Get Task', + TRACKER_GET_TASK_DEFINITION.base.description!, + Kind.Read, + TRACKER_GET_TASK_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: GetTaskParams, messageBus: MessageBus) { + return new TrackerGetTaskInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_GET_TASK_DEFINITION, modelId); + } +} + +// --- tracker_list_tasks --- + +interface ListTasksParams { + status?: TaskStatus; + type?: TaskType; + parentId?: string; +} + +class TrackerListTasksInvocation extends BaseTrackerInvocation< + ListTasksParams, + ToolResult +> { + getDescription(): string { + return 'Listing tasks.'; + } + + override async execute(_signal: AbortSignal): Promise { + let tasks = await this.service.listTasks(); + if (this.params.status) { + tasks = tasks.filter((t) => t.status === this.params.status); + } + if (this.params.type) { + tasks = tasks.filter((t) => t.type === this.params.type); + } + if (this.params.parentId) { + tasks = tasks.filter((t) => t.parentId === this.params.parentId); + } + + if (tasks.length === 0) { + return { + llmContent: 'No tasks found matching the criteria.', + returnDisplay: 'No matching tasks.', + }; + } + + const content = tasks + .map((t) => `- [${t.id}] ${t.title} (${t.status})`) + .join('\n'); + return { + llmContent: content, + returnDisplay: `Listed ${tasks.length} tasks.`, + }; + } +} + +export class TrackerListTasksTool extends BaseDeclarativeTool< + ListTasksParams, + ToolResult +> { + static readonly Name = TRACKER_LIST_TASKS_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerListTasksTool.Name, + 'List Tasks', + TRACKER_LIST_TASKS_DEFINITION.base.description!, + Kind.Search, + TRACKER_LIST_TASKS_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation(params: ListTasksParams, messageBus: MessageBus) { + return new TrackerListTasksInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_LIST_TASKS_DEFINITION, modelId); + } +} + +// --- tracker_add_dependency --- + +interface AddDependencyParams { + taskId: string; + dependencyId: string; +} + +class TrackerAddDependencyInvocation extends BaseTrackerInvocation< + AddDependencyParams, + ToolResult +> { + getDescription(): string { + return `Adding dependency: ${this.params.taskId} depends on ${this.params.dependencyId}`; + } + + override async execute(_signal: AbortSignal): Promise { + const task = await this.service.getTask(this.params.taskId); + if (!task) { + return { + llmContent: `Task ${this.params.taskId} not found.`, + returnDisplay: 'Task not found.', + }; + } + const dep = await this.service.getTask(this.params.dependencyId); + if (!dep) { + return { + llmContent: `Dependency task ${this.params.dependencyId} not found.`, + returnDisplay: 'Dependency not found.', + }; + } + + const newDeps = Array.from( + new Set([...task.dependencies, this.params.dependencyId]), + ); + try { + await this.service.updateTask(task.id, { dependencies: newDeps }); + return { + llmContent: `Linked ${task.id} -> ${dep.id}.`, + returnDisplay: 'Dependency added.', + }; + } catch (error) { + return { + llmContent: `Error adding dependency: ${ + error instanceof Error ? error.message : String(error) + }`, + returnDisplay: 'Failed to add dependency.', + }; + } + } +} + +export class TrackerAddDependencyTool extends BaseDeclarativeTool< + AddDependencyParams, + ToolResult +> { + static readonly Name = TRACKER_ADD_DEPENDENCY_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerAddDependencyTool.Name, + 'Add Dependency', + TRACKER_ADD_DEPENDENCY_DEFINITION.base.description!, + Kind.Edit, + TRACKER_ADD_DEPENDENCY_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation( + params: AddDependencyParams, + messageBus: MessageBus, + ) { + return new TrackerAddDependencyInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_ADD_DEPENDENCY_DEFINITION, modelId); + } +} + +// --- tracker_visualize --- + +class TrackerVisualizeInvocation extends BaseTrackerInvocation< + Record, + ToolResult +> { + getDescription(): string { + return 'Visualizing the task graph.'; + } + + override async execute(_signal: AbortSignal): Promise { + const tasks = await this.service.listTasks(); + if (tasks.length === 0) { + return { + llmContent: 'No tasks to visualize.', + returnDisplay: 'Empty tracker.', + }; + } + + const statusEmojis: Record = { + open: '⭕', + in_progress: '🚧', + blocked: '🚫', + closed: '✅', + }; + + const typeLabels: Record = { + epic: '[EPIC]', + task: '[TASK]', + bug: '[BUG]', + }; + + // Simple list-based visualization for now (can enhance to tree later if needed) + // We'll organize by epic/parent + const roots = tasks.filter((t) => !t.parentId); + let output = 'Task Tracker Graph:\n'; + + const renderTask = (task: TrackerTask, depth: number) => { + const indent = ' '.repeat(depth); + output += `${indent}${statusEmojis[task.status]} ${task.id} ${typeLabels[task.type]} ${task.title}\n`; + if (task.dependencies.length > 0) { + output += `${indent} └─ Depends on: ${task.dependencies.join(', ')}\n`; + } + const children = tasks.filter((t) => t.parentId === task.id); + for (const child of children) { + renderTask(child, depth + 1); + } + }; + + for (const root of roots) { + renderTask(root, 0); + } + + return { + llmContent: output, + returnDisplay: 'Graph rendered.', + }; + } +} + +export class TrackerVisualizeTool extends BaseDeclarativeTool< + Record, + ToolResult +> { + static readonly Name = TRACKER_VISUALIZE_TOOL_NAME; + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + TrackerVisualizeTool.Name, + 'Visualize Tracker', + TRACKER_VISUALIZE_DEFINITION.base.description!, + Kind.Read, + TRACKER_VISUALIZE_DEFINITION.base.parametersJsonSchema, + messageBus, + ); + } + protected createInvocation( + params: Record, + messageBus: MessageBus, + ) { + return new TrackerVisualizeInvocation( + this.config, + params, + messageBus, + this.name, + ); + } + override getSchema(modelId?: string) { + return resolveToolDeclaration(TRACKER_VISUALIZE_DEFINITION, modelId); + } +} diff --git a/plans/task-tracker-implementation.md b/plans/task-tracker-implementation.md new file mode 100644 index 0000000000..54fc998bef --- /dev/null +++ b/plans/task-tracker-implementation.md @@ -0,0 +1,101 @@ +# Task Tracker Implementation Plan + +This document outlines the phased implementation of the Git-backed, graph-based +Task Tracker for Gemini CLI. + +## Phase 1: Foundation & Data Model + +**Goal:** Establish the storage mechanism and the core task schema. + +### Tasks + +- [x] **Storage Infrastructure:** + - Implement a `TrackerService` in `packages/core/src/services/`. + - Create logic to manage `.tracker/tasks/` directory. + - Implement 6-character alphanumeric ID generation (hex). +- [x] **Data Model (JSON Schema):** + - `id`: string (6 chars) + - `title`: string + - `description`: string + - `type`: `epic` | `task` | `bug` + - `status`: `open` | `in_progress` | `blocked` | `closed` + - `parentId`: string (optional) + - `dependencies`: string[] (list of IDs) + - `subagentSessionId`: string (optional) + - `metadata`: object (optional) +- [x] **Graph Validation Logic:** + - Prevent `closed` status if dependencies are not `closed`. + - Ensure no circular dependencies. + +**Success Criteria:** Can manually create and read task files with valid schemas +and basic dependency checks. + +--- + +## Phase 2: CRUD Tools & Visualization + +**Goal:** Enable the agent to interact with the tracker via CLI tools. + +### Tasks + +- [x] **Infrastructure:** + - [x] Add `trackerEnabled` to `ConfigParams` and `Config` in `packages/core`. + - [x] Guard tracker tool registration in `Config.createToolRegistry`. + - [x] Add `experimental.taskTracker` to `SETTINGS_SCHEMA` in `packages/cli`. + - [x] Pass `taskTracker` setting to `Config` in `loadCliConfig`. +- [x] **Core Tools:** + - `tracker_init`: Setup `.tracker` in current workspace. + - `tracker_create_task`: Create a new JSON node. + - `tracker_update_task`: Modify existing node (handle status transitions). + - `tracker_get_task`: Retrieve single task details. + - `tracker_list_tasks`: Filtered list (by status, parent, etc.). +- [x] **Relationship Tools:** + - `tracker_add_dependency`: Link two existing tasks. +- [x] **CLI Visualization:** + - `tracker_visualize`: Render ASCII tree with emojis (⭕, 🚧, ✅, 🚫). +- [x] **Testing:** + - Implement integration tests in `trackerTools.test.ts`. + +**Success Criteria:** Tools are registered and usable in the CLI; +`tracker_visualize` shows a clear hierarchy. + +--- + +## Phase 3: System Instruction (SI) & Integration + +**Goal:** Shift the agent's behavior to treat the tracker as the Single Source +of Truth (SSOT). + +### Tasks + +- [ ] **System Instruction Update:** + - Inject the "TASK MANAGEMENT PROTOCOL" into the core prompt. + - Mandate use of `tracker_list_tasks` at session start. +- [ ] **Plan Mode Integration:** + - Implement `tracker_hydrate(planPath)` to turn a plan into tracker nodes. +- [ ] **Session Restoration:** + - Modify the startup flow to check for existing `.tracker` and prompt the + agent to resume pending tasks. + +**Success Criteria:** Agent stops using markdown checklists and consistently +uses `tracker_create_task` for multi-step goals. + +--- + +## Phase 4: Persistence & Advanced Features + +**Goal:** Ensure long-term durability and multi-agent support. + +### Tasks + +- [ ] **Git Synchronization:** + - `tracker_sync`: Commit the `.tracker` directory to the current branch. +- [ ] **Git Worktree (V2):** + - Implement mounting a `tracker-data` orphan branch to `.tracker/` to allow + cross-branch persistence. +- [ ] **Subagent Coordination:** + - Update `SubagentService` to automatically update the tracker when a subagent + is spawned. + +**Success Criteria:** Task state persists across branch switches and multiple +agent sessions.