takt: takt-list (#310)
This commit is contained in:
parent
80a79683ac
commit
43f6fa6ade
@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Unit tests for task naming utilities
|
||||
*
|
||||
* Tests nowIso, firstLine, and sanitizeTaskName functions.
|
||||
* Tests nowIso and firstLine functions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js';
|
||||
import { nowIso, firstLine } from '../infra/task/naming.js';
|
||||
|
||||
describe('nowIso', () => {
|
||||
afterEach(() => {
|
||||
@ -54,34 +54,3 @@ describe('firstLine', () => {
|
||||
expect(firstLine(' \n ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTaskName', () => {
|
||||
it('should lowercase the input', () => {
|
||||
expect(sanitizeTaskName('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should replace special characters with spaces then hyphens', () => {
|
||||
expect(sanitizeTaskName('task@name#123')).toBe('task-name-123');
|
||||
});
|
||||
|
||||
it('should collapse multiple hyphens', () => {
|
||||
expect(sanitizeTaskName('a---b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('should trim leading/trailing whitespace', () => {
|
||||
expect(sanitizeTaskName(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should handle typical task names', () => {
|
||||
expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42');
|
||||
});
|
||||
|
||||
it('should generate fallback name for empty result', () => {
|
||||
const result = sanitizeTaskName('!@#$%');
|
||||
expect(result).toMatch(/^task-\d+$/);
|
||||
});
|
||||
|
||||
it('should preserve numbers and lowercase letters', () => {
|
||||
expect(sanitizeTaskName('abc123def')).toBe('abc123def');
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,12 +37,13 @@ describe('generateReportDir', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should preserve Japanese characters in summary', () => {
|
||||
it('should strip CJK characters from summary', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z'));
|
||||
|
||||
const result = generateReportDir('タスク指示書の実装');
|
||||
expect(result).toContain('タスク指示書の実装');
|
||||
// CJK characters are removed by slugify, leaving empty → falls back to 'task'
|
||||
expect(result).toBe('20250601-120000-task');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@ -53,7 +54,7 @@ describe('generateReportDir', () => {
|
||||
|
||||
const result = generateReportDir('Fix: bug (#42)');
|
||||
const slug = result.replace(/^20250101-000000-/, '');
|
||||
expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/);
|
||||
expect(slug).not.toMatch(/[^a-z0-9-]/);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@ -66,6 +66,8 @@ describe('saveTaskFile', () => {
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.content).toBeUndefined();
|
||||
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
||||
expect(tasks[0]?.slug).toBeTypeOf('string');
|
||||
expect(tasks[0]?.summary).toBe('Implement feature X');
|
||||
const taskDir = path.join(testDir, String(tasks[0]?.task_dir));
|
||||
expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true);
|
||||
expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X');
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Unit tests for slugify utility
|
||||
*
|
||||
* Tests URL/filename-safe slug generation with CJK support.
|
||||
* Tests URL/filename-safe slug generation (a-z 0-9 hyphen, max 30 chars).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@ -25,17 +25,17 @@ describe('slugify', () => {
|
||||
expect(slugify(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should truncate to 50 characters', () => {
|
||||
it('should truncate to 30 characters', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
expect(slugify(long).length).toBeLessThanOrEqual(50);
|
||||
expect(slugify(long).length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('should preserve CJK characters', () => {
|
||||
expect(slugify('タスク指示書')).toBe('タスク指示書');
|
||||
it('should strip CJK characters', () => {
|
||||
expect(slugify('タスク指示書')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle mixed ASCII and CJK', () => {
|
||||
expect(slugify('Add タスク Feature')).toBe('add-タスク-feature');
|
||||
expect(slugify('Add タスク Feature')).toBe('add-feature');
|
||||
});
|
||||
|
||||
it('should handle numbers', () => {
|
||||
@ -43,11 +43,18 @@ describe('slugify', () => {
|
||||
});
|
||||
|
||||
it('should handle empty result after stripping', () => {
|
||||
// All special characters → becomes empty string
|
||||
expect(slugify('!@#$%')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle typical GitHub issue titles', () => {
|
||||
expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42');
|
||||
});
|
||||
|
||||
it('should strip trailing hyphen after truncation', () => {
|
||||
// 30 chars of slug that ends with a hyphen after slice
|
||||
const input = 'abcdefghijklmnopqrstuvwxyz-abc-xyz';
|
||||
const result = slugify(input);
|
||||
expect(result.length).toBeLessThanOrEqual(30);
|
||||
expect(result).not.toMatch(/-$/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,39 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js';
|
||||
import { formatTaskStatusLabel, formatShortDate } from '../features/tasks/list/taskStatusLabel.js';
|
||||
import type { TaskListItem } from '../infra/task/types.js';
|
||||
|
||||
function makeTask(overrides: Partial<TaskListItem>): TaskListItem {
|
||||
return {
|
||||
kind: 'pending',
|
||||
name: 'test-task',
|
||||
createdAt: '2026-02-11T00:00:00.000Z',
|
||||
filePath: '/tmp/task.md',
|
||||
content: 'content',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatTaskStatusLabel', () => {
|
||||
it("should format pending task as '[pending] name'", () => {
|
||||
// Given: pending タスク
|
||||
const task: TaskListItem = {
|
||||
kind: 'pending',
|
||||
name: 'implement test',
|
||||
createdAt: '2026-02-11T00:00:00.000Z',
|
||||
filePath: '/tmp/task.md',
|
||||
content: 'content',
|
||||
};
|
||||
|
||||
// When: ステータスラベルを生成する
|
||||
const result = formatTaskStatusLabel(task);
|
||||
|
||||
// Then: pending は pending 表示になる
|
||||
expect(result).toBe('[pending] implement test');
|
||||
const task = makeTask({ kind: 'pending', name: 'implement-test' });
|
||||
expect(formatTaskStatusLabel(task)).toBe('[pending] implement-test');
|
||||
});
|
||||
|
||||
it("should format failed task as '[failed] name'", () => {
|
||||
// Given: failed タスク
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'retry payment',
|
||||
createdAt: '2026-02-11T00:00:00.000Z',
|
||||
filePath: '/tmp/task.md',
|
||||
content: 'content',
|
||||
};
|
||||
const task = makeTask({ kind: 'failed', name: 'retry-payment' });
|
||||
expect(formatTaskStatusLabel(task)).toBe('[failed] retry-payment');
|
||||
});
|
||||
|
||||
// When: ステータスラベルを生成する
|
||||
const result = formatTaskStatusLabel(task);
|
||||
it('should include branch when present', () => {
|
||||
const task = makeTask({
|
||||
kind: 'completed',
|
||||
name: 'fix-login-bug',
|
||||
branch: 'takt/284/fix-login-bug',
|
||||
});
|
||||
expect(formatTaskStatusLabel(task)).toBe('[completed] fix-login-bug (takt/284/fix-login-bug)');
|
||||
});
|
||||
|
||||
// Then: failed は failed 表示になる
|
||||
expect(result).toBe('[failed] retry payment');
|
||||
it('should not include branch when absent', () => {
|
||||
const task = makeTask({ kind: 'running', name: 'my-task' });
|
||||
expect(formatTaskStatusLabel(task)).toBe('[running] my-task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortDate', () => {
|
||||
it('should format ISO string to MM/DD HH:mm', () => {
|
||||
expect(formatShortDate('2025-02-18T14:30:00.000Z')).toBe('02/18 14:30');
|
||||
});
|
||||
|
||||
it('should zero-pad single digit values', () => {
|
||||
expect(formatShortDate('2025-01-05T03:07:00.000Z')).toBe('01/05 03:07');
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,10 +8,11 @@ import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import { promptInput, confirm } from '../../../shared/prompt/index.js';
|
||||
import { success, info, error, withProgress } from '../../../shared/ui/index.js';
|
||||
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
|
||||
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js';
|
||||
import { determinePiece } from '../execute/selectAndExecute.js';
|
||||
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
|
||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
||||
import { firstLine } from '../../../infra/task/naming.js';
|
||||
|
||||
const log = createLogger('add-task');
|
||||
|
||||
@ -39,9 +40,11 @@ export async function saveTaskFile(
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
||||
): Promise<{ taskName: string; tasksFile: string }> {
|
||||
const runner = new TaskRunner(cwd);
|
||||
const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent));
|
||||
const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug);
|
||||
const taskDirRelative = `.takt/tasks/${taskSlug}`;
|
||||
const slug = await summarizeTaskName(taskContent, { cwd });
|
||||
const summary = firstLine(taskContent);
|
||||
const taskDirSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent));
|
||||
const taskDir = path.join(cwd, '.takt', 'tasks', taskDirSlug);
|
||||
const taskDirRelative = `.takt/tasks/${taskDirSlug}`;
|
||||
const orderPath = path.join(taskDir, 'order.md');
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(orderPath, taskContent, 'utf-8');
|
||||
@ -55,6 +58,8 @@ export async function saveTaskFile(
|
||||
const created = runner.addTask(taskContent, {
|
||||
...config,
|
||||
task_dir: taskDirRelative,
|
||||
slug,
|
||||
summary,
|
||||
});
|
||||
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
||||
log.info('Task created', { taskName: created.name, tasksFile, config });
|
||||
@ -69,8 +74,8 @@ export async function saveTaskFile(
|
||||
*/
|
||||
export function createIssueFromTask(task: string): number | undefined {
|
||||
info('Creating GitHub Issue...');
|
||||
const firstLine = task.split('\n')[0] || task;
|
||||
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
|
||||
const titleLine = task.split('\n')[0] || task;
|
||||
const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine;
|
||||
const issueResult = createIssue({ title, body: task });
|
||||
if (issueResult.success) {
|
||||
success(`Issue created: ${issueResult.url}`);
|
||||
|
||||
@ -104,7 +104,7 @@ export async function resolveTaskExecution(
|
||||
worktreePath = task.worktreePath;
|
||||
isWorktree = true;
|
||||
} else {
|
||||
const taskSlug = await withProgress(
|
||||
const taskSlug = task.slug ?? await withProgress(
|
||||
'Generating branch name...',
|
||||
(slug) => `Branch name generated: ${slug}`,
|
||||
() => summarizeTaskName(task.content, { cwd: defaultCwd }),
|
||||
|
||||
@ -72,7 +72,7 @@ export async function confirmAndCreateWorktree(
|
||||
}),
|
||||
);
|
||||
|
||||
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
|
||||
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,7 +92,7 @@ export async function selectAndExecuteTask(
|
||||
return;
|
||||
}
|
||||
|
||||
const { execCwd, isWorktree, branch, baseBranch } = await confirmAndCreateWorktree(
|
||||
const { execCwd, isWorktree, branch, baseBranch, taskSlug } = await confirmAndCreateWorktree(
|
||||
cwd,
|
||||
task,
|
||||
options?.createWorktree,
|
||||
@ -112,6 +112,7 @@ export async function selectAndExecuteTask(
|
||||
...(branch ? { branch } : {}),
|
||||
...(isWorktree ? { worktree_path: execCwd } : {}),
|
||||
auto_pr: shouldCreatePr,
|
||||
...(taskSlug ? { slug: taskSlug } : {}),
|
||||
});
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
|
||||
@ -122,6 +122,7 @@ export interface WorktreeConfirmationResult {
|
||||
isWorktree: boolean;
|
||||
branch?: string;
|
||||
baseBranch?: string;
|
||||
taskSlug?: string;
|
||||
}
|
||||
|
||||
export interface SelectAndExecuteOptions {
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js';
|
||||
import { retryFailedTask } from './taskRetryActions.js';
|
||||
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||
import { formatTaskStatusLabel } from './taskStatusLabel.js';
|
||||
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
||||
|
||||
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||
|
||||
@ -130,7 +130,7 @@ export async function listTasks(
|
||||
const menuOptions = tasks.map((task, idx) => ({
|
||||
label: formatTaskStatusLabel(task),
|
||||
value: `${task.kind}:${idx}`,
|
||||
description: `${task.content} | ${task.createdAt}`,
|
||||
description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`,
|
||||
}));
|
||||
|
||||
const selected = await selectOption<string>(
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
mergeBranch,
|
||||
deleteBranch,
|
||||
} from './taskActions.js';
|
||||
import { formatTaskStatusLabel } from './taskStatusLabel.js';
|
||||
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
||||
|
||||
export interface ListNonInteractiveOptions {
|
||||
enabled: boolean;
|
||||
@ -43,7 +43,7 @@ function printNonInteractiveList(tasks: TaskListItem[], format?: string): void {
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`);
|
||||
info(`${formatTaskStatusLabel(task)} - ${task.summary ?? task.content} (${formatShortDate(task.createdAt)})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,5 +8,18 @@ const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
|
||||
};
|
||||
|
||||
export function formatTaskStatusLabel(task: TaskListItem): string {
|
||||
return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
|
||||
const status = `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
|
||||
if (task.branch) {
|
||||
return `${status} (${task.branch})`;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function formatShortDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return `${month}/${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger, slugify } from '../../shared/utils/index.js';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { resolveConfigValue } from '../config/index.js';
|
||||
import type { WorktreeOptions, WorktreeResult } from './types.js';
|
||||
|
||||
@ -48,7 +48,7 @@ export class CloneManager {
|
||||
/** Resolve the clone path based on options and global config */
|
||||
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
const slug = slugify(options.taskSlug);
|
||||
const slug = options.taskSlug;
|
||||
|
||||
let dirName: string;
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
@ -74,7 +74,7 @@ export class CloneManager {
|
||||
return options.branch;
|
||||
}
|
||||
|
||||
const slug = slugify(options.taskSlug);
|
||||
const slug = options.taskSlug;
|
||||
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
return `takt/${options.issueNumber}/${slug}`;
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
|
||||
import { firstLine } from './naming.js';
|
||||
import type { TaskInfo, TaskListItem } from './types.js';
|
||||
|
||||
function firstLine(content: string): string {
|
||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||
}
|
||||
|
||||
function toDisplayPath(projectDir: string, targetPath: string): string {
|
||||
const relativePath = path.relative(projectDir, targetPath);
|
||||
if (!relativePath || relativePath.startsWith('..')) {
|
||||
@ -66,6 +63,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
|
||||
return {
|
||||
filePath: tasksFile,
|
||||
name: task.name,
|
||||
slug: task.slug,
|
||||
content,
|
||||
taskDir: task.task_dir,
|
||||
createdAt: task.created_at,
|
||||
@ -119,6 +117,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
|
||||
createdAt: task.created_at,
|
||||
filePath: tasksFile,
|
||||
content: firstLine(resolveTaskContent(projectDir, task)),
|
||||
summary: task.summary,
|
||||
branch: task.branch,
|
||||
worktreePath: task.worktree_path,
|
||||
startedAt: task.started_at ?? undefined,
|
||||
|
||||
@ -5,18 +5,3 @@ export function nowIso(): string {
|
||||
export function firstLine(content: string): string {
|
||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||
}
|
||||
|
||||
export function sanitizeTaskName(base: string): string {
|
||||
const normalized = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
if (!normalized) {
|
||||
return `task-${Date.now()}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@ -33,7 +33,13 @@ export class TaskRunner {
|
||||
|
||||
addTask(
|
||||
content: string,
|
||||
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
|
||||
options?: Omit<TaskFileData, 'task'> & {
|
||||
content_file?: string;
|
||||
task_dir?: string;
|
||||
worktree_path?: string;
|
||||
slug?: string;
|
||||
summary?: string;
|
||||
},
|
||||
): TaskInfo {
|
||||
return this.lifecycle.addTask(content, options);
|
||||
}
|
||||
|
||||
@ -41,6 +41,8 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
|
||||
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
||||
name: z.string().min(1),
|
||||
status: TaskStatusSchema,
|
||||
slug: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
worktree_path: z.string().optional(),
|
||||
content: z.string().min(1).optional(),
|
||||
content_file: z.string().min(1).optional(),
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
import * as wanakana from 'wanakana';
|
||||
import { resolveConfigValues } from '../config/index.js';
|
||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { createLogger, slugify } from '../../shared/utils/index.js';
|
||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||
import type { SummarizeOptions } from './types.js';
|
||||
|
||||
@ -15,27 +15,12 @@ export type { SummarizeOptions };
|
||||
|
||||
const log = createLogger('summarize');
|
||||
|
||||
/**
|
||||
* Sanitize a string for use as git branch name and directory name.
|
||||
* Allows only: a-z, 0-9, hyphen.
|
||||
*/
|
||||
function sanitizeSlug(input: string, maxLength = 30): string {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+/, '')
|
||||
.slice(0, maxLength)
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Japanese text to romaji slug.
|
||||
*/
|
||||
function toRomajiSlug(text: string): string {
|
||||
const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} });
|
||||
return sanitizeSlug(romaji);
|
||||
return slugify(romaji);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -77,7 +62,7 @@ export class TaskSummarizer {
|
||||
permissionMode: 'readonly',
|
||||
});
|
||||
|
||||
const slug = sanitizeSlug(response.content);
|
||||
const slug = slugify(response.content);
|
||||
log.info('Task name summarized', { original: taskName, slug });
|
||||
|
||||
return slug || 'task';
|
||||
|
||||
@ -3,7 +3,8 @@ import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure
|
||||
import type { TaskInfo, TaskResult } from './types.js';
|
||||
import { toTaskInfo } from './mapper.js';
|
||||
import { TaskStore } from './store.js';
|
||||
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
|
||||
import { firstLine, nowIso } from './naming.js';
|
||||
import { slugify } from '../../shared/utils/slug.js';
|
||||
import { isStaleRunningTask } from './process.js';
|
||||
import type { TaskStatus } from './schema.js';
|
||||
|
||||
@ -16,13 +17,22 @@ export class TaskLifecycleService {
|
||||
|
||||
addTask(
|
||||
content: string,
|
||||
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
|
||||
options?: Omit<TaskFileData, 'task'> & {
|
||||
content_file?: string;
|
||||
task_dir?: string;
|
||||
worktree_path?: string;
|
||||
slug?: string;
|
||||
summary?: string;
|
||||
},
|
||||
): TaskInfo {
|
||||
const state = this.store.update((current) => {
|
||||
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
|
||||
const slug = options?.slug ?? slugify(firstLine(content));
|
||||
const name = this.generateTaskName(slug, current.tasks.map((task) => task.name));
|
||||
const contentValue = options?.task_dir ? undefined : content;
|
||||
const record: TaskRecord = TaskRecordSchema.parse({
|
||||
name,
|
||||
slug,
|
||||
summary: options?.summary,
|
||||
status: 'pending',
|
||||
content: contentValue,
|
||||
created_at: nowIso(),
|
||||
@ -258,8 +268,8 @@ export class TaskLifecycleService {
|
||||
return isStaleRunningTask(task.owner_pid ?? undefined);
|
||||
}
|
||||
|
||||
private generateTaskName(content: string, existingNames: string[]): string {
|
||||
const base = sanitizeTaskName(firstLine(content));
|
||||
private generateTaskName(slug: string, existingNames: string[]): string {
|
||||
const base = slug || `task-${Date.now()}`;
|
||||
let candidate = base;
|
||||
let counter = 1;
|
||||
while (existingNames.includes(candidate)) {
|
||||
|
||||
@ -9,6 +9,7 @@ import type { TaskFailure, TaskStatus } from './schema.js';
|
||||
export interface TaskInfo {
|
||||
filePath: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
content: string;
|
||||
taskDir?: string;
|
||||
createdAt: string;
|
||||
@ -81,6 +82,7 @@ export interface TaskListItem {
|
||||
createdAt: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
data?: TaskFileData;
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
* Report directory name generation.
|
||||
*/
|
||||
|
||||
import { slugify } from './slug.js';
|
||||
|
||||
export function generateReportDir(task: string): string {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
@ -9,12 +11,7 @@ export function generateReportDir(task: string): string {
|
||||
.slice(0, 14)
|
||||
.replace(/(\d{8})(\d{6})/, '$1-$2');
|
||||
|
||||
const summary = task
|
||||
.slice(0, 30)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|| 'task';
|
||||
const summary = slugify(task.slice(0, 80)) || 'task';
|
||||
|
||||
return `${timestamp}-${summary}`;
|
||||
}
|
||||
|
||||
@ -2,17 +2,18 @@
|
||||
* Text slugification utility
|
||||
*
|
||||
* Converts text into URL/filename-safe slugs.
|
||||
* Supports ASCII alphanumerics and CJK characters.
|
||||
* Allowed characters: a-z, 0-9, hyphen. Max 30 characters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert text into a slug for use in filenames, paths, and branch names.
|
||||
* Preserves CJK characters (U+3000-9FFF, FF00-FFEF).
|
||||
* Allowed: a-z 0-9 hyphen. Max 30 characters.
|
||||
*/
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u3000-\u9fff\uff00-\uffef]+/g, '-')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
.slice(0, 30)
|
||||
.replace(/-+$/, '');
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
const TASK_SLUG_PATTERN =
|
||||
'[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?';
|
||||
'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
|
||||
const TASK_DIR_PREFIX = '.takt/tasks/';
|
||||
const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`);
|
||||
const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user