@gonzui/claude-task-manager
Version:
Task management extension for Claude Code with archiving and history
426 lines • 18.5 kB
JavaScript
"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