mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -07:00
feat(core,cli): implement task tracker foundation with feature flag
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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<ToolOutputMaskingConfig>;
|
||||
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);
|
||||
|
||||
|
||||
47
packages/core/src/config/trackerFeatureFlag.test.ts
Normal file
47
packages/core/src/config/trackerFeatureFlag.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
147
packages/core/src/services/trackerService.test.ts
Normal file
147
packages/core/src/services/trackerService.test.ts
Normal file
@@ -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<TrackerTask, 'id'> = {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
191
packages/core/src/services/trackerService.ts
Normal file
191
packages/core/src/services/trackerService.ts
Normal file
@@ -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<void> {
|
||||
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<TrackerTask, 'id'>): Promise<TrackerTask> {
|
||||
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<TrackerTask | null> {
|
||||
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<TrackerTask[]> {
|
||||
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<TrackerTask>,
|
||||
): Promise<TrackerTask> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const visited = new Set<string>();
|
||||
const stack = new Set<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
21
packages/core/src/services/trackerTypes.ts
Normal file
21
packages/core/src/services/trackerTypes.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
174
packages/core/src/tools/definitions/trackerTools.ts
Normal file
174
packages/core/src/tools/definitions/trackerTools.ts
Normal file
@@ -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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
112
packages/core/src/tools/trackerTools.test.ts
Normal file
112
packages/core/src/tools/trackerTools.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
563
packages/core/src/tools/trackerTools.ts
Normal file
563
packages/core/src/tools/trackerTools.ts
Normal file
@@ -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<P, R> {
|
||||
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<string, never>,
|
||||
ToolResult
|
||||
> {
|
||||
getDescription(): string {
|
||||
return 'Initializing the task tracker storage.';
|
||||
}
|
||||
|
||||
override async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
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<string, never>,
|
||||
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<string, never>,
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<string, never>,
|
||||
ToolResult
|
||||
> {
|
||||
getDescription(): string {
|
||||
return 'Visualizing the task graph.';
|
||||
}
|
||||
|
||||
override async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const tasks = await this.service.listTasks();
|
||||
if (tasks.length === 0) {
|
||||
return {
|
||||
llmContent: 'No tasks to visualize.',
|
||||
returnDisplay: 'Empty tracker.',
|
||||
};
|
||||
}
|
||||
|
||||
const statusEmojis: Record<TaskStatus, string> = {
|
||||
open: '⭕',
|
||||
in_progress: '🚧',
|
||||
blocked: '🚫',
|
||||
closed: '✅',
|
||||
};
|
||||
|
||||
const typeLabels: Record<TaskType, string> = {
|
||||
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<string, never>,
|
||||
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<string, never>,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
return new TrackerVisualizeInvocation(
|
||||
this.config,
|
||||
params,
|
||||
messageBus,
|
||||
this.name,
|
||||
);
|
||||
}
|
||||
override getSchema(modelId?: string) {
|
||||
return resolveToolDeclaration(TRACKER_VISUALIZE_DEFINITION, modelId);
|
||||
}
|
||||
}
|
||||
101
plans/task-tracker-implementation.md
Normal file
101
plans/task-tracker-implementation.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user