UNPKG

@gonzui/claude-task-manager

Version:

Task management extension for Claude Code with archiving and history

426 lines 18.5 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.TaskManager = void 0; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const date_fns_1 = require("date-fns"); const types_1 = require("../types"); const i18n_1 = require("./i18n"); const ConfigManager_1 = require("./ConfigManager"); const TaskFileManager_1 = require("./TaskFileManager"); const ClaudeExecutor_1 = require("./ClaudeExecutor"); const HistoryManager_1 = require("./HistoryManager"); const CustomCommandGenerator_1 = require("./CustomCommandGenerator"); const ProgressTracker_1 = require("./ProgressTracker"); const TaskSplitter_1 = require("./TaskSplitter"); const TaskStore_1 = require("./TaskStore"); class TaskManager { constructor(workingDir) { const baseDir = workingDir || this.findProjectRoot() || process.cwd(); this.config = { workingDir: baseDir, taskFile: path.join(baseDir, 'task.md'), archiveDir: path.join(baseDir, 'archive'), configDir: path.join(baseDir, '.claude-tasks'), configFile: path.join(baseDir, '.claude-tasks', 'config.json') }; this.i18n = i18n_1.I18n.getInstance(); // Initialize managers this.configManager = new ConfigManager_1.ConfigManager(this.config.configFile, this.i18n); this.taskFileManager = new TaskFileManager_1.TaskFileManager(this.config.taskFile, this.config.archiveDir, this.i18n); this.claudeExecutor = new ClaudeExecutor_1.ClaudeExecutor(this.config.taskFile); this.historyManager = new HistoryManager_1.HistoryManager(this.config.taskFile, this.config.archiveDir, this.i18n); this.customCommandGenerator = new CustomCommandGenerator_1.CustomCommandGenerator(this.config.workingDir, this.i18n); this.progressTracker = new ProgressTracker_1.ProgressTracker(this.config.taskFile, this.i18n); this.taskSplitter = new TaskSplitter_1.TaskSplitter(this.config.taskFile, this.i18n); this.taskStore = new TaskStore_1.TaskStore(path.join(this.config.configDir, 'tasks'), this.config.taskFile, this.i18n); if (!this.i18n.isInitialized()) { // Synchronous on purpose: a fire-and-forget async init() here can // resolve after a later init() for the configured language and // overwrite its messages (flaky-English race on slow CI). try { this.i18n.initSync('en'); } catch { // Ignore initialization errors in constructor } } } /** * Find the project root by looking for .claude-tasks directory */ findProjectRoot() { let currentDir = process.cwd(); const root = path.parse(currentDir).root; while (currentDir !== root) { const configDir = path.join(currentDir, '.claude-tasks'); try { if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) { return currentDir; } } catch { // Ignore errors and continue searching } currentDir = path.dirname(currentDir); } const rootConfigDir = path.join(root, '.claude-tasks'); try { if (fs.existsSync(rootConfigDir) && fs.statSync(rootConfigDir).isDirectory()) { return root; } } catch { // Ignore errors } return null; } async init(options = {}) { try { await fs.ensureDir(this.config.archiveDir); await fs.ensureDir(this.config.configDir); if (!await this.configManager.configExists()) { const initialLang = this.configManager.detectInitialLanguage(); await this.i18n.init(initialLang); await this.configManager.createInitialConfig(initialLang, () => this.taskFileManager.getDefaultTaskTemplate()); } else { const config = await this.configManager.getConfig(); await this.i18n.init(config.language || 'en'); } if (!await this.taskFileManager.taskFileExists()) { await this.createTaskFile('Initial Task', 'Welcome to Claude Task Manager!'); } await this.customCommandGenerator.createClaudeCustomCommand(); await this.customCommandGenerator.createClaudeSkill(); await this.customCommandGenerator.createMcpConfig(); if (options.hooks) { await this.customCommandGenerator.createHooksConfig(); } await this.updateGitignore(); } catch (error) { throw new types_1.FileSystemError(this.i18n.t('errors.initFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), this.config.workingDir, 'init'); } } async createNewTask(options = {}) { try { if (!this.i18n.isInitialized()) { try { const config = await this.configManager.getConfig(); await this.i18n.init(config.language || 'en'); } catch { await this.i18n.init('en'); } } const config = await this.configManager.getConfig(); const title = options.title || config.defaultTaskTitle || `Task ${(0, date_fns_1.format)(new Date(), 'yyyy-MM-dd HH:mm')}`; const description = options.description || ''; if (options.name) { // Named creation never archives: the previous task keeps living under // its own name and is only written back to its snapshot. const name = this.taskStore.sanitizeName(options.name); const previous = await this.enableMultiTaskMode(); if (previous !== name && await this.taskStore.exists(name)) { throw new types_1.TaskManagerError(this.i18n.t('errors.taskExists', { name }), 'TASK_EXISTS'); } if (previous && previous !== name) { await this.taskStore.syncActive(previous); } await this.createTaskFile(title, description, options); await this.taskStore.syncActive(name); await this.setActiveTaskName(name); return this.config.taskFile; } // Unnamed creation keeps the legacy behavior: archive the current task // and replace it. In multi-task mode the active slot is reused, so the // name stays valid for `switch` and `list`. if (await this.taskFileManager.taskFileExists()) { await this.taskFileManager.archiveCurrentTask(); } await this.createTaskFile(title, description, options); if (await this.taskStore.isEnabled()) { const active = await this.getActiveTaskName() || await this.taskStore.deriveName(title); await this.taskStore.syncActive(active); await this.setActiveTaskName(active); } return this.config.taskFile; } catch (error) { // Keep specific, actionable failures (e.g. a name clash) intact instead // of flattening them into a generic create error. if (error instanceof types_1.TaskManagerError) { throw error; } throw new types_1.TaskManagerError(this.i18n.t('errors.createTaskFailed', { error: error instanceof Error ? error.message : 'Unknown error' }), 'CREATE_TASK_ERROR'); } } async createTaskFile(title, description, options = {}) { const config = await this.configManager.getConfig(); const template = await this.taskFileManager.getTaskTemplate(config); await this.taskFileManager.createTaskFile(title, description, options, template, config); } async archiveCurrentTask() { const archivedPath = await this.taskFileManager.archiveCurrentTask(); if (archivedPath && await this.taskStore.isEnabled()) { const active = await this.getActiveTaskName(); if (active) { await this.taskStore.remove(active); await this.setActiveTaskName(null); } } return archivedPath; } /** Name of the active task, or null in single-task (legacy) mode. */ async getActiveTaskName() { if (!await this.taskStore.isEnabled()) { return null; } const config = await this.configManager.getConfig(); return config.activeTask || null; } async isMultiTaskMode() { return await this.taskStore.isEnabled(); } /** * Turn on multi-task mode, migrating a legacy project transparently: the * existing task.md becomes a stored task named after its title. Returns the * active task name (null only when there is no task at all yet). */ async enableMultiTaskMode() { if (await this.taskStore.isEnabled()) { return await this.getActiveTaskName(); } await this.taskStore.enable(); if (!await this.taskFileManager.taskFileExists()) { return null; } const progress = await this.progressTracker.getProgress(); const name = await this.taskStore.deriveName(progress.title); await this.taskStore.syncActive(name); await this.setActiveTaskName(name); return name; } async setActiveTaskName(name) { await this.configManager.updateConfig({ activeTask: name === null ? undefined : name }, () => this.taskFileManager.getDefaultTaskTemplate()); } /** * Make `name` the active task: the current task.md is written back to its own * snapshot first, so no in-progress state is lost. With `create`, an unknown * name is created from the task template instead of failing. */ async switchTask(name, options = {}) { const target = this.taskStore.sanitizeName(name); const previous = await this.enableMultiTaskMode(); if (previous === target) { await this.taskStore.syncActive(target); return { name: target, previous, created: false }; } if (previous) { await this.taskStore.syncActive(previous); } let created = false; if (!await this.taskStore.exists(target)) { if (!options.create) { throw new types_1.TaskManagerError(this.i18n.t('errors.unknownTask', { name: target }), 'UNKNOWN_TASK'); } await this.createTaskFile(target, ''); await this.taskStore.syncActive(target); created = true; } else { await this.taskStore.activate(target); } await this.setActiveTaskName(target); return { name: target, previous, created }; } /** * All known tasks with their progress. In single-task mode this is the * current task.md alone, listed under the name it would migrate to. */ async listTasks() { if (await this.taskStore.isEnabled()) { return await this.taskStore.listTasks(await this.getActiveTaskName()); } if (!await this.taskFileManager.taskFileExists()) { return []; } const progress = await this.progressTracker.getProgress(); return [{ name: await this.taskStore.deriveName(progress.title), title: progress.title, active: true, completed: progress.completed, total: progress.total, percentage: progress.percentage }]; } async runTask(verbose = false, debug = false, editPermission = true) { const config = await this.configManager.getConfig(); return await this.claudeExecutor.runTask(config, verbose, debug, editPermission, async (result) => await this.historyManager.logExecution(result)); } async recordExecution(result) { await this.historyManager.logExecution(result); } async getHistory(limit = 10) { return await this.historyManager.getHistory(limit); } async getStatus() { const status = await this.historyManager.getStatus(); const activeTaskName = await this.getActiveTaskName(); return activeTaskName ? { ...status, activeTaskName } : status; } async getTaskContent() { return await this.taskFileManager.getTaskContent(); } async getLanguage() { const config = await this.configManager.getConfig(); return config.language || 'en'; } /** * Synchronous language lookup for early startup (e.g. localizing --help). * Falls back to the environment-detected language when no config is present. */ getLanguageSync() { try { if (fs.existsSync(this.config.configFile)) { const config = fs.readJsonSync(this.config.configFile); if (config.language) { return config.language; } } } catch { // Ignore and fall through to environment detection } return this.configManager.detectInitialLanguage(); } async setLanguage(lang) { await this.configManager.updateConfig({ language: lang }, () => this.taskFileManager.getDefaultTaskTemplate()); } async getProgress() { return await this.progressTracker.getProgress(); } /** * One-line status for embedding in a statusline (e.g. Claude Code's * `statusLine` setting): `<title> ▸ <pct>%`, title only when the task has * no subtasks, or a localized "no task" marker. In multi-task mode the * active task name is prefixed as `[<name>]`. */ async getShortStatus() { if (!await this.taskFileManager.taskFileExists()) { return this.i18n.t('commands.status.shortNoTask'); } const progress = await this.progressTracker.getProgress(); const activeTaskName = await this.getActiveTaskName(); const prefix = activeTaskName ? `[${activeTaskName}] ` : ''; if (progress.total === 0) { return `${prefix}${progress.title}`; } return `${prefix}${progress.title} ▸ ${progress.percentage}%`; } formatProgress(result) { return this.progressTracker.formatOutput(result); } async setTaskCompletion(indices, completed) { return await this.progressTracker.setCompletion(indices, completed); } async splitTask(count) { return await this.taskSplitter.splitTask(count); } async updateGitignore() { const gitignorePath = path.join(this.config.workingDir, '.gitignore'); const entriesToAdd = [ '# Claude Task Manager', 'task.md', 'archive/', '.claude-tasks/', '', '# Temporary task files', '*.tmp.md', 'task.*.md', '', '# Claude Code skill generated by claude-task init', '.claude/skills/task/', '', '# Claude Code custom commands (if you want to exclude them)', '# .claude/commands/task.md' ]; try { let content = ''; let existingEntries = new Set(); if (await fs.pathExists(gitignorePath)) { content = await fs.readFile(gitignorePath, 'utf8'); existingEntries = new Set(content.split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#'))); } const newEntries = []; let needsUpdate = false; for (const entry of entriesToAdd) { if (entry.startsWith('#') || entry === '') { newEntries.push(entry); } else if (!existingEntries.has(entry)) { newEntries.push(entry); needsUpdate = true; } } if (needsUpdate) { if (content && !content.endsWith('\n')) { content += '\n'; } if (content) { content += '\n'; } content += newEntries.join('\n'); if (!content.endsWith('\n')) { content += '\n'; } await fs.writeFile(gitignorePath, content); console.log(this.i18n.t('commands.init.gitignoreUpdated') || ' Updated .gitignore with task-related entries'); } } catch (error) { console.warn('Warning: Could not update .gitignore:', error); } } } exports.TaskManager = TaskManager; //# sourceMappingURL=TaskManager.js.map