diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md index 5bd698c..166576c 100644 --- a/skills/worklog/SKILL.md +++ b/skills/worklog/SKILL.md @@ -150,6 +150,36 @@ ingest sync files --- +## 登録済み情報の修正 + +タスクやトピックの内容(タイトル・重要度・ステータスなど)を後から変更したい場合は `ingest edit` を使用する。 + +```bash +# タスクのタイトルを変更 +ingest edit task "旧タイトル" --title "新タイトル" + +# タスクの重要度を変更(0〜10) +ingest edit task --importance 8.5 + +# タスクのステータスを変更(active | paused | blocked | closed) +ingest edit task --status paused + +# 複数フィールドを一度に変更 +ingest edit task "タスク名" --title "新タイトル" --importance 7.0 + +# トピックの名前を変更 +ingest edit topic "旧名前" --name "新名前" + +# トピックの基本重要度を変更 +ingest edit topic "トピック名" --importance 6.0 +``` + +- タスクは ID またはタイトルのどちらでも指定できる +- importance の変更は `importance_reassessed` イベントとして監査ログに記録される +- タイトル揺れを正規化したい場合は `match suggest` / `match apply` を優先する + +--- + ## タイトル揺れへの対処 タスクタイトルの表記揺れ(例: 「機能A設計」と「機能Aの設計」)は完全一致で判定しません。 diff --git a/skills/worklog/commands.md b/skills/worklog/commands.md index 1de0f26..c674fea 100644 --- a/skills/worklog/commands.md +++ b/skills/worklog/commands.md @@ -109,6 +109,63 @@ ingest close --- +## 編集コマンド + +### `ingest edit task ` + +登録済みタスクの情報を変更します。 + +```bash +# タイトルを変更 +ingest edit task "旧タイトル" --title "新タイトル" + +# 重要度を変更(0〜10) +ingest edit task --importance 8.5 + +# ステータスを変更 +ingest edit task --status paused + +# 複数フィールドを一度に変更 +ingest edit task "タスク名" --title "新タイトル" --importance 7.0 --status active +``` + +**オプション** + +| オプション | 説明 | +|---|---| +| `--title ` | タスクのタイトルを変更 | +| `--importance <0-10>` | 重要度スコアを変更(変更は `importance_reassessed` イベントとして記録) | +| `--status ` | ステータスを変更(`active` / `paused` / `blocked` / `closed`) | + +- タスクは ID またはタイトルのどちらで指定しても検索されます +- 引数を何も指定しないとエラーになります + +--- + +### `ingest edit topic ` + +登録済みトピックの情報を変更します。 + +```bash +# 名前を変更 +ingest edit topic "旧名前" --name "新名前" + +# 基本重要度を変更(0〜10) +ingest edit topic "トピック名" --importance 6.0 + +# 両方を一度に変更 +ingest edit topic --name "新名前" --importance 6.0 +``` + +**オプション** + +| オプション | 説明 | +|---|---| +| `--name ` | トピック名を変更 | +| `--importance <0-10>` | 基本重要度スコアを変更 | + +--- + ## ingest コマンド(外部イベントの取り込み) ### `ingest ingest calendar-start --title --at <datetime>` diff --git a/skills/worklog/examples.md b/skills/worklog/examples.md index f22378f..c998849 100644 --- a/skills/worklog/examples.md +++ b/skills/worklog/examples.md @@ -176,7 +176,55 @@ ingest decision "〇〇については今週中に方針を決定する" --- -## 6. タイトル揺れを正規化する +## 6. 登録済みタスク・トピックを修正する + +タスクのタイトルを入力ミスした、重要度を見直したいといったケースです。 + +```bash +# タスク一覧で ID とタイトルを確認 +ingest tasks + +# タイトルを修正(タイトルで指定) +ingest edit task "認章機能の実装" --title "認証機能の実装" +``` + +出力例: +``` +Task updated: xK9mP2 + title: 認章機能の実装 → 認証機能の実装 +``` + +```bash +# 重要度を上げる(ID で指定) +ingest edit task xK9mP2 --importance 8.5 +``` + +出力例: +``` +Task updated: xK9mP2 + importance: 5 → 8.5 +``` + +```bash +# ステータスを一時停止に変更 +ingest edit task xK9mP2 --status paused +``` + +```bash +# トピックの名前と重要度を一度に変更 +ingest edit topic "auth" --name "認証・認可" --importance 7.0 +``` + +出力例: +``` +Topic updated: tY3pR1 + name: auth → 認証・認可 + base_importance: 5 → 7 +``` + +--- + +## 7. タイトル揺れを正規化する 複数セッションにわたって類似タスクが別々に作られてしまった場合のケースです。 diff --git a/src/cli/commands/edit.ts b/src/cli/commands/edit.ts new file mode 100644 index 0000000..5d0e975 --- /dev/null +++ b/src/cli/commands/edit.ts @@ -0,0 +1,126 @@ +import { Command } from 'commander'; +import { getTask, findTaskByTitle, updateTask } from '../../core/taskService.js'; +import { getTopic, findTopicByName, updateTopic } from '../../core/topicService.js'; +import { createEvent } from '../../core/eventService.js'; +import { TaskStatus } from '../../types/task.js'; + +const VALID_STATUSES: TaskStatus[] = ['active', 'paused', 'blocked', 'closed']; + +export function registerEdit(program: Command): void { + const edit = program + .command('edit') + .description('Edit a registered task or topic'); + + // --- edit task --- + edit + .command('task <id-or-title>') + .description('Edit a task (title, importance, status)') + .option('--title <text>', 'New title') + .option('--importance <number>', 'New importance (0–10)', parseFloat) + .option('--status <status>', `New status (${VALID_STATUSES.join(' | ')})`) + .action(async (idOrTitle: string, options) => { + try { + const task = getTask(idOrTitle) ?? findTaskByTitle(idOrTitle); + if (!task) { + console.error(`Task not found: ${idOrTitle}`); + process.exit(1); + } + + const fields: { title?: string; importance?: number; status?: TaskStatus } = {}; + + if (options.title !== undefined) { + fields.title = options.title; + } + + if (options.importance !== undefined) { + const imp = options.importance; + if (isNaN(imp) || imp < 0 || imp > 10) { + console.error('--importance must be a number between 0 and 10'); + process.exit(1); + } + fields.importance = imp; + } + + if (options.status !== undefined) { + if (!VALID_STATUSES.includes(options.status as TaskStatus)) { + console.error(`--status must be one of: ${VALID_STATUSES.join(', ')}`); + process.exit(1); + } + fields.status = options.status as TaskStatus; + } + + if (Object.keys(fields).length === 0) { + console.error('Nothing to update. Specify at least one of --title, --importance, --status.'); + process.exit(1); + } + + updateTask(task.id, fields); + + // Record importance change as an auditable event + if (fields.importance !== undefined) { + createEvent({ + event_type: 'importance_reassessed', + task_id: task.id, + project_id: task.project_id ?? undefined, + actor: 'human', + origin: 'manual', + summary: `Importance updated: ${task.importance} → ${fields.importance}`, + importance: fields.importance, + }); + } + + console.log(`Task updated: ${task.id}`); + if (fields.title) console.log(` title: ${task.title} → ${fields.title}`); + if (fields.importance !== undefined) console.log(` importance: ${task.importance} → ${fields.importance}`); + if (fields.status) console.log(` status: ${task.status} → ${fields.status}`); + } catch (err) { + console.error('Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + + // --- edit topic --- + edit + .command('topic <id-or-name>') + .description('Edit a topic (name, base_importance)') + .option('--name <text>', 'New name') + .option('--importance <number>', 'New base importance (0–10)', parseFloat) + .action(async (idOrName: string, options) => { + try { + const topic = getTopic(idOrName) ?? findTopicByName(idOrName); + if (!topic) { + console.error(`Topic not found: ${idOrName}`); + process.exit(1); + } + + const fields: { name?: string; base_importance?: number } = {}; + + if (options.name !== undefined) { + fields.name = options.name; + } + + if (options.importance !== undefined) { + const imp = options.importance; + if (isNaN(imp) || imp < 0 || imp > 10) { + console.error('--importance must be a number between 0 and 10'); + process.exit(1); + } + fields.base_importance = imp; + } + + if (Object.keys(fields).length === 0) { + console.error('Nothing to update. Specify at least one of --name, --importance.'); + process.exit(1); + } + + updateTopic(topic.id, fields); + + console.log(`Topic updated: ${topic.id}`); + if (fields.name) console.log(` name: ${topic.name} → ${fields.name}`); + if (fields.base_importance !== undefined) console.log(` base_importance: ${topic.base_importance} → ${fields.base_importance}`); + } catch (err) { + console.error('Error:', err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 3a76de7..2926c01 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ import { registerTopics } from './commands/topics.js'; import { registerTasks } from './commands/tasks.js'; import { registerShow } from './commands/show.js'; import { registerPromote } from './commands/promote.js'; +import { registerEdit } from './commands/edit.js'; // Ensure ~/.worklog directory and DB exist on startup const worklogDir = join(process.env.HOME ?? '.', '.worklog'); @@ -52,5 +53,6 @@ registerTopics(program); registerTasks(program); registerShow(program); registerPromote(program); +registerEdit(program); program.parse(); diff --git a/src/core/__tests__/taskService.test.ts b/src/core/__tests__/taskService.test.ts new file mode 100644 index 0000000..13df1db --- /dev/null +++ b/src/core/__tests__/taskService.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { initDb, closeDb } from '../../db/client.js'; +import { + findOrCreateTask, + getTask, + findTaskByTitle, + updateTask, + updateTaskStatus, + getActiveTasks, +} from '../taskService.js'; + +let tmpDir: string; +let dbPath: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'worklog-test-task-')); + dbPath = join(tmpDir, 'test.db'); + process.env.WORKLOG_DB_PATH = dbPath; + initDb(dbPath); +}); + +afterAll(() => { + closeDb(); + delete process.env.WORKLOG_DB_PATH; + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('findOrCreateTask', () => { + it('creates a new task with default values', () => { + const task = findOrCreateTask('新しいタスク'); + expect(task.title).toBe('新しいタスク'); + expect(task.status).toBe('active'); + expect(task.importance).toBe(5.0); + expect(task.project_id).toBeNull(); + expect(task.id).toBeTruthy(); + expect(task.created_at).toBeTruthy(); + expect(task.updated_at).toBeTruthy(); + }); + + it('returns existing task when title matches', () => { + const first = findOrCreateTask('重複タスク'); + const second = findOrCreateTask('重複タスク'); + expect(second.id).toBe(first.id); + }); +}); + +describe('getTask', () => { + it('returns task by ID', () => { + const created = findOrCreateTask('IDで取得するタスク'); + const found = getTask(created.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(created.id); + expect(found!.title).toBe('IDで取得するタスク'); + }); + + it('returns null for unknown ID', () => { + expect(getTask('nonexistent-id')).toBeNull(); + }); +}); + +describe('findTaskByTitle', () => { + it('returns task by title', () => { + findOrCreateTask('タイトル検索タスク'); + const found = findTaskByTitle('タイトル検索タスク'); + expect(found).not.toBeNull(); + expect(found!.title).toBe('タイトル検索タスク'); + }); + + it('returns null for unknown title', () => { + expect(findTaskByTitle('存在しないタイトル')).toBeNull(); + }); +}); + +describe('updateTaskStatus', () => { + it('updates task status to closed', () => { + const task = findOrCreateTask('ステータス変更タスク'); + updateTaskStatus(task.id, 'closed'); + const updated = getTask(task.id)!; + expect(updated.status).toBe('closed'); + }); + + it('updates task status to paused', () => { + const task = findOrCreateTask('一時停止タスク'); + updateTaskStatus(task.id, 'paused'); + const updated = getTask(task.id)!; + expect(updated.status).toBe('paused'); + }); + + it('updates updated_at timestamp', () => { + const task = findOrCreateTask('タイムスタンプ確認タスク'); + const before = task.updated_at; + updateTaskStatus(task.id, 'blocked'); + const updated = getTask(task.id)!; + expect(updated.updated_at >= before).toBe(true); + }); +}); + +describe('updateTask', () => { + it('updates title', () => { + const task = findOrCreateTask('変更前タイトル'); + updateTask(task.id, { title: '変更後タイトル' }); + const updated = getTask(task.id)!; + expect(updated.title).toBe('変更後タイトル'); + }); + + it('updates importance', () => { + const task = findOrCreateTask('重要度変更タスク'); + expect(task.importance).toBe(5.0); + updateTask(task.id, { importance: 9.0 }); + const updated = getTask(task.id)!; + expect(updated.importance).toBe(9.0); + }); + + it('updates status', () => { + const task = findOrCreateTask('ステータス一括変更タスク'); + updateTask(task.id, { status: 'paused' }); + const updated = getTask(task.id)!; + expect(updated.status).toBe('paused'); + }); + + it('updates multiple fields at once', () => { + const task = findOrCreateTask('複数フィールド変更タスク'); + updateTask(task.id, { title: '変更済みタスク', importance: 7.5, status: 'blocked' }); + const updated = getTask(task.id)!; + expect(updated.title).toBe('変更済みタスク'); + expect(updated.importance).toBe(7.5); + expect(updated.status).toBe('blocked'); + }); + + it('updates updated_at timestamp', () => { + const task = findOrCreateTask('updated_at確認タスク'); + const before = task.updated_at; + updateTask(task.id, { importance: 3.0 }); + const updated = getTask(task.id)!; + expect(updated.updated_at >= before).toBe(true); + }); + + it('does nothing when no fields provided', () => { + const task = findOrCreateTask('変更なしタスク'); + const before = task.updated_at; + updateTask(task.id, {}); + const after = getTask(task.id)!; + expect(after.title).toBe(task.title); + expect(after.importance).toBe(task.importance); + expect(after.updated_at).toBe(before); + }); +}); + +describe('getActiveTasks', () => { + it('returns only active tasks', () => { + const active = findOrCreateTask('アクティブタスク確認'); + const tasks = getActiveTasks(); + const ids = tasks.map(t => t.id); + expect(ids).toContain(active.id); + expect(tasks.every(t => t.status === 'active')).toBe(true); + }); +}); diff --git a/src/core/__tests__/topicService.test.ts b/src/core/__tests__/topicService.test.ts new file mode 100644 index 0000000..2cb35f6 --- /dev/null +++ b/src/core/__tests__/topicService.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { initDb, closeDb } from '../../db/client.js'; +import { + findOrCreateTopic, + getTopic, + findTopicByName, + updateTopic, +} from '../topicService.js'; + +let tmpDir: string; +let dbPath: string; + +beforeAll(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'worklog-test-topic-')); + dbPath = join(tmpDir, 'test.db'); + process.env.WORKLOG_DB_PATH = dbPath; + initDb(dbPath); +}); + +afterAll(() => { + closeDb(); + delete process.env.WORKLOG_DB_PATH; + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('findOrCreateTopic', () => { + it('creates a new topic with default values', () => { + const topic = findOrCreateTopic('新しいトピック'); + expect(topic.name).toBe('新しいトピック'); + expect(topic.base_importance).toBe(5.0); + expect(topic.project_id).toBeNull(); + expect(topic.id).toBeTruthy(); + expect(topic.created_at).toBeTruthy(); + expect(topic.updated_at).toBeTruthy(); + }); + + it('returns existing topic when name matches', () => { + const first = findOrCreateTopic('重複トピック'); + const second = findOrCreateTopic('重複トピック'); + expect(second.id).toBe(first.id); + }); +}); + +describe('getTopic', () => { + it('returns topic by ID', () => { + const created = findOrCreateTopic('IDで取得するトピック'); + const found = getTopic(created.id); + expect(found).not.toBeNull(); + expect(found!.id).toBe(created.id); + expect(found!.name).toBe('IDで取得するトピック'); + }); + + it('returns null for unknown ID', () => { + expect(getTopic('nonexistent-id')).toBeNull(); + }); +}); + +describe('findTopicByName', () => { + it('returns topic by name', () => { + findOrCreateTopic('名前検索トピック'); + const found = findTopicByName('名前検索トピック'); + expect(found).not.toBeNull(); + expect(found!.name).toBe('名前検索トピック'); + }); + + it('returns null for unknown name', () => { + expect(findTopicByName('存在しない名前')).toBeNull(); + }); +}); + +describe('updateTopic', () => { + it('updates name', () => { + const topic = findOrCreateTopic('変更前トピック名'); + updateTopic(topic.id, { name: '変更後トピック名' }); + const updated = getTopic(topic.id)!; + expect(updated.name).toBe('変更後トピック名'); + }); + + it('updates base_importance', () => { + const topic = findOrCreateTopic('重要度変更トピック'); + expect(topic.base_importance).toBe(5.0); + updateTopic(topic.id, { base_importance: 8.0 }); + const updated = getTopic(topic.id)!; + expect(updated.base_importance).toBe(8.0); + }); + + it('updates both name and base_importance at once', () => { + const topic = findOrCreateTopic('複数フィールド変更トピック'); + updateTopic(topic.id, { name: '変更済みトピック', base_importance: 3.5 }); + const updated = getTopic(topic.id)!; + expect(updated.name).toBe('変更済みトピック'); + expect(updated.base_importance).toBe(3.5); + }); + + it('updates updated_at timestamp', () => { + const topic = findOrCreateTopic('updated_at確認トピック'); + const before = topic.updated_at; + updateTopic(topic.id, { base_importance: 7.0 }); + const updated = getTopic(topic.id)!; + expect(updated.updated_at >= before).toBe(true); + }); + + it('does nothing when no fields provided', () => { + const topic = findOrCreateTopic('変更なしトピック'); + const before = topic.updated_at; + updateTopic(topic.id, {}); + const after = getTopic(topic.id)!; + expect(after.name).toBe(topic.name); + expect(after.base_importance).toBe(topic.base_importance); + expect(after.updated_at).toBe(before); + }); +}); diff --git a/src/core/taskService.ts b/src/core/taskService.ts index d3ab5e3..158a3fd 100644 --- a/src/core/taskService.ts +++ b/src/core/taskService.ts @@ -50,6 +50,23 @@ export function updateTaskStatus(id: string, status: TaskStatus): void { db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id); } +export function updateTask( + id: string, + fields: { title?: string; importance?: number; status?: TaskStatus } +): void { + const db = getDb(); + const now = nowISO(); + const sets: string[] = []; + const values: unknown[] = []; + if (fields.title !== undefined) { sets.push('title = ?'); values.push(fields.title); } + if (fields.importance !== undefined) { sets.push('importance = ?'); values.push(fields.importance); } + if (fields.status !== undefined) { sets.push('status = ?'); values.push(fields.status); } + if (sets.length === 0) return; + sets.push('updated_at = ?'); + values.push(now, id); + db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...values); +} + export function getActiveTasks(): Task[] { const db = getDb(); return db.prepare( diff --git a/src/core/topicService.ts b/src/core/topicService.ts index fc8b2f4..d69800d 100644 --- a/src/core/topicService.ts +++ b/src/core/topicService.ts @@ -34,6 +34,19 @@ export function getTopic(id: string): Topic | null { return (db.prepare('SELECT * FROM topics WHERE id = ?').get(id) as Topic) ?? null; } +export function updateTopic(id: string, fields: { name?: string; base_importance?: number }): void { + const db = getDb(); + const now = nowISO(); + const sets: string[] = []; + const values: unknown[] = []; + if (fields.name !== undefined) { sets.push('name = ?'); values.push(fields.name); } + if (fields.base_importance !== undefined) { sets.push('base_importance = ?'); values.push(fields.base_importance); } + if (sets.length === 0) return; + sets.push('updated_at = ?'); + values.push(now, id); + db.prepare(`UPDATE topics SET ${sets.join(', ')} WHERE id = ?`).run(...values); +} + export function findTopicByName(name: string): Topic | null { const db = getDb(); return (db.prepare('SELECT * FROM topics WHERE name = ? LIMIT 1').get(name) as Topic) ?? null;