idea-manager 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  여러 프로젝트를 동시에 진행하는 개발자를 위한 태스크 관리 도구입니다. 아이디어를 서브 프로젝트와 태스크로 조직화하고, 각 태스크별 프롬프트를 정제하여 Claude Code 등 AI 에이전트에게 전달할 수 있습니다. MCP Server를 내장하고 있어 AI 에이전트가 자율적으로 태스크를 가져가 실행할 수 있습니다.
6
6
 
7
+ ![IM Workspace](docs/screenshot.png)
8
+
7
9
  ## 핵심 워크플로우
8
10
 
9
11
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -16,6 +16,7 @@ interface IProject {
16
16
  description: string;
17
17
  project_path: string | null;
18
18
  ai_context: string;
19
+ watch_enabled: boolean;
19
20
  }
20
21
 
21
22
  function WorkspaceInner({ id }: { id: string }) {
@@ -189,9 +190,10 @@ function WorkspaceInner({ id }: { id: string }) {
189
190
  }
190
191
  };
191
192
 
192
- const handleTaskDelete = () => {
193
- if (!selectedTaskId) return;
194
- setConfirmAction({ type: 'delete-task', id: selectedTaskId });
193
+ const handleTaskDelete = (taskId?: string) => {
194
+ const id = taskId || selectedTaskId;
195
+ if (!id) return;
196
+ setConfirmAction({ type: 'delete-task', id });
195
197
  };
196
198
 
197
199
  const handleConfirmAction = async () => {
@@ -236,6 +238,18 @@ function WorkspaceInner({ id }: { id: string }) {
236
238
  }
237
239
  };
238
240
 
241
+ const handleToggleWatch = async () => {
242
+ if (!project) return;
243
+ const res = await fetch(`/api/projects/${id}`, {
244
+ method: 'PUT',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({ watch_enabled: !project.watch_enabled }),
247
+ });
248
+ if (res.ok) {
249
+ setProject(await res.json());
250
+ }
251
+ };
252
+
239
253
  // Keyboard shortcuts (use e.code for Korean IME compatibility)
240
254
  useEffect(() => {
241
255
  const handler = (e: KeyboardEvent) => {
@@ -304,6 +318,18 @@ function WorkspaceInner({ id }: { id: string }) {
304
318
  )}
305
319
  </div>
306
320
  <div className="flex items-center gap-2">
321
+ <button
322
+ onClick={handleToggleWatch}
323
+ className={`px-3 py-1.5 text-xs border rounded-md transition-colors flex items-center gap-1.5 ${
324
+ project.watch_enabled
325
+ ? 'bg-success/15 text-success border-success/30 hover:bg-success/25'
326
+ : 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
327
+ }`}
328
+ title={project.watch_enabled ? 'Watch ON — submitted 태스크 자동 실행' : 'Watch OFF'}
329
+ >
330
+ <span className={`inline-block w-2 h-2 rounded-full ${project.watch_enabled ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`} />
331
+ Watch
332
+ </button>
307
333
  <button
308
334
  onClick={() => setShowAiPolicy(true)}
309
335
  className={`px-3 py-1.5 text-xs border rounded-md transition-colors ${
@@ -391,6 +417,7 @@ function WorkspaceInner({ id }: { id: string }) {
391
417
  onCreateTask={handleCreateTask}
392
418
  onStatusChange={handleTaskStatusChange}
393
419
  onTodayToggle={handleTaskTodayToggle}
420
+ onDeleteTask={handleTaskDelete}
394
421
  />
395
422
  </div>
396
423
 
@@ -22,6 +22,7 @@ export default function ProjectTree({
22
22
  onCreateTask,
23
23
  onStatusChange,
24
24
  onTodayToggle,
25
+ onDeleteTask,
25
26
  }: {
26
27
  subProjects: ISubProjectWithStats[];
27
28
  tasks: ITask[];
@@ -34,6 +35,7 @@ export default function ProjectTree({
34
35
  onCreateTask: (title: string) => void;
35
36
  onStatusChange: (taskId: string, status: TaskStatus) => void;
36
37
  onTodayToggle: (taskId: string, isToday: boolean) => void;
38
+ onDeleteTask: (taskId: string) => void;
37
39
  }) {
38
40
  const [collapsedSubs, setCollapsedSubs] = useState<Set<string>>(new Set());
39
41
  const [addingTaskFor, setAddingTaskFor] = useState<string | null>(null);
@@ -130,7 +132,7 @@ export default function ProjectTree({
130
132
  <div
131
133
  key={task.id}
132
134
  onClick={() => onSelectTask(task.id)}
133
- className={`flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
135
+ className={`group/task flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
134
136
  selectedTaskId === task.id
135
137
  ? 'bg-card-hover border-l-primary'
136
138
  : 'border-l-transparent hover:bg-card-hover/50'
@@ -159,6 +161,17 @@ export default function ProjectTree({
159
161
  *
160
162
  </button>
161
163
  )}
164
+ <button
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ onDeleteTask(task.id);
168
+ }}
169
+ className="flex-shrink-0 text-muted-foreground/0 group-hover/task:text-muted-foreground
170
+ hover:!text-destructive transition-colors text-xs px-0.5"
171
+ title="Delete task"
172
+ >
173
+ ×
174
+ </button>
162
175
  </div>
163
176
  ))}
164
177
 
@@ -1,14 +1,18 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
- import type { ITaskConversation } from '@/types';
4
+ import type { ITaskConversation, TaskStatus } from '@/types';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
 
7
+ const POLL_INTERVAL = 3000; // Poll every 3s when task is testing
8
+
7
9
  export default function TaskChat({
8
10
  basePath,
11
+ taskStatus,
9
12
  onApplyToPrompt,
10
13
  }: {
11
14
  basePath: string;
15
+ taskStatus?: TaskStatus;
12
16
  onApplyToPrompt: (content: string) => void;
13
17
  }) {
14
18
  const [messages, setMessages] = useState<ITaskConversation[]>([]);
@@ -17,12 +21,26 @@ export default function TaskChat({
17
21
  const messagesEndRef = useRef<HTMLDivElement>(null);
18
22
  const inputRef = useRef<HTMLTextAreaElement>(null);
19
23
 
20
- useEffect(() => {
24
+ const fetchMessages = useCallback(() => {
21
25
  fetch(`${basePath}/chat`)
22
26
  .then(r => r.json())
23
- .then(data => setMessages(Array.isArray(data) ? data : []));
27
+ .then(data => {
28
+ if (Array.isArray(data)) setMessages(data);
29
+ });
24
30
  }, [basePath]);
25
31
 
32
+ // Initial load
33
+ useEffect(() => {
34
+ fetchMessages();
35
+ }, [fetchMessages]);
36
+
37
+ // Auto-poll when task is testing (watcher is running)
38
+ useEffect(() => {
39
+ if (taskStatus !== 'testing') return;
40
+ const interval = setInterval(fetchMessages, POLL_INTERVAL);
41
+ return () => clearInterval(interval);
42
+ }, [taskStatus, fetchMessages]);
43
+
26
44
  useEffect(() => {
27
45
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
28
46
  }, [messages]);
@@ -71,6 +89,12 @@ export default function TaskChat({
71
89
  <div className="flex flex-col h-full border-t border-border">
72
90
  <div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
73
91
  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Chat</span>
92
+ {taskStatus === 'testing' && (
93
+ <span className="flex items-center gap-1.5 text-xs text-warning">
94
+ <span className="inline-block w-2 h-2 rounded-full bg-warning animate-pulse" />
95
+ Executing...
96
+ </span>
97
+ )}
74
98
  </div>
75
99
 
76
100
  {/* Messages */}
@@ -28,11 +28,16 @@ export default function TaskDetail({
28
28
 
29
29
  const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
30
30
 
31
+ // Auto-show chat when task is being executed by watcher
32
+ useEffect(() => {
33
+ if (task.status === 'testing') setShowChat(true);
34
+ }, [task.status]);
35
+
31
36
  // Load prompt
32
37
  useEffect(() => {
33
38
  setTitle(task.title);
34
39
  setDescription(task.description);
35
- setShowChat(false);
40
+ setShowChat(task.status === 'testing');
36
41
  fetch(`${basePath}/prompt`)
37
42
  .then(r => r.json())
38
43
  .then(data => setPromptContent(data.content || ''));
@@ -196,6 +201,7 @@ export default function TaskDetail({
196
201
  <div className="h-[45%] flex-shrink-0">
197
202
  <TaskChat
198
203
  basePath={basePath}
204
+ taskStatus={task.status}
199
205
  onApplyToPrompt={handleApplyToPrompt}
200
206
  />
201
207
  </div>
@@ -17,6 +17,7 @@ export default function TaskList({
17
17
  onCreate,
18
18
  onStatusChange,
19
19
  onTodayToggle,
20
+ onDelete,
20
21
  }: {
21
22
  tasks: ITask[];
22
23
  selectedTaskId: string | null;
@@ -24,6 +25,7 @@ export default function TaskList({
24
25
  onCreate: (title: string) => void;
25
26
  onStatusChange: (taskId: string, status: TaskStatus) => void;
26
27
  onTodayToggle: (taskId: string, isToday: boolean) => void;
28
+ onDelete: (taskId: string) => void;
27
29
  }) {
28
30
  const [newTitle, setNewTitle] = useState('');
29
31
  const [adding, setAdding] = useState(false);
@@ -48,7 +50,7 @@ export default function TaskList({
48
50
  <div
49
51
  key={task.id}
50
52
  onClick={() => onSelect(task.id)}
51
- className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
53
+ className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
52
54
  selectedTaskId === task.id
53
55
  ? 'bg-card-hover border-l-primary'
54
56
  : 'border-l-transparent hover:bg-card-hover/50'
@@ -77,6 +79,17 @@ export default function TaskList({
77
79
  *
78
80
  </button>
79
81
  )}
82
+ <button
83
+ onClick={(e) => {
84
+ e.stopPropagation();
85
+ onDelete(task.id);
86
+ }}
87
+ className="flex-shrink-0 text-muted-foreground/0 group-hover:text-muted-foreground
88
+ hover:!text-destructive transition-colors text-xs px-0.5"
89
+ title="Delete task"
90
+ >
91
+ ×
92
+ </button>
80
93
  </div>
81
94
  ))}
82
95
  </div>
@@ -2,14 +2,31 @@ import { getDb } from '../index';
2
2
  import { generateId } from '../../utils/id';
3
3
  import type { IProject } from '@/types';
4
4
 
5
+ interface ProjectRow {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ project_path: string | null;
10
+ ai_context: string;
11
+ watch_enabled: number;
12
+ created_at: string;
13
+ updated_at: string;
14
+ }
15
+
16
+ function rowToProject(row: ProjectRow): IProject {
17
+ return { ...row, watch_enabled: row.watch_enabled === 1 };
18
+ }
19
+
5
20
  export function listProjects(): IProject[] {
6
21
  const db = getDb();
7
- return db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as IProject[];
22
+ const rows = db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as ProjectRow[];
23
+ return rows.map(rowToProject);
8
24
  }
9
25
 
10
26
  export function getProject(id: string): IProject | undefined {
11
27
  const db = getDb();
12
- return db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as IProject | undefined;
28
+ const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow | undefined;
29
+ return row ? rowToProject(row) : undefined;
13
30
  }
14
31
 
15
32
  export function createProject(name: string, description: string = '', projectPath?: string): IProject {
@@ -30,7 +47,7 @@ export function createProject(name: string, description: string = '', projectPat
30
47
  return getProject(id)!;
31
48
  }
32
49
 
33
- export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string }): IProject | undefined {
50
+ export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean }): IProject | undefined {
34
51
  const db = getDb();
35
52
  const project = getProject(id);
36
53
  if (!project) return undefined;
@@ -38,12 +55,13 @@ export function updateProject(id: string, data: { name?: string; description?: s
38
55
  const now = new Date().toISOString();
39
56
 
40
57
  db.prepare(
41
- 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, updated_at = ? WHERE id = ?'
58
+ 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, updated_at = ? WHERE id = ?'
42
59
  ).run(
43
60
  data.name ?? project.name,
44
61
  data.description ?? project.description,
45
62
  data.project_path !== undefined ? data.project_path : project.project_path,
46
63
  data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
64
+ data.watch_enabled !== undefined ? (data.watch_enabled ? 1 : 0) : (project.watch_enabled ? 1 : 0),
47
65
  now,
48
66
  id,
49
67
  );
@@ -30,6 +30,9 @@ export function initSchema(db: Database.Database): void {
30
30
  if (!projCols.some(c => c.name === 'ai_context')) {
31
31
  db.exec("ALTER TABLE projects ADD COLUMN ai_context TEXT NOT NULL DEFAULT ''");
32
32
  }
33
+ if (!projCols.some(c => c.name === 'watch_enabled')) {
34
+ db.exec("ALTER TABLE projects ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0");
35
+ }
33
36
 
34
37
  // v2 tables
35
38
  db.exec(`
@@ -14,6 +14,8 @@ export interface WatcherOptions {
14
14
  dryRun: boolean;
15
15
  }
16
16
 
17
+ const PROGRESS_SAVE_INTERVAL = 5000; // Save streaming output to DB every 5s
18
+
17
19
  function timestamp(): string {
18
20
  return new Date().toLocaleTimeString('ko-KR', { hour12: false });
19
21
  }
@@ -35,12 +37,10 @@ function formatDuration(ms: number): string {
35
37
  }
36
38
 
37
39
  function resolveCwd(task: ITask, project: IProject): string | null {
38
- // 1. sub_project.folder_path
39
40
  const subProject = getSubProject(task.sub_project_id);
40
41
  if (subProject?.folder_path && fs.existsSync(subProject.folder_path)) {
41
42
  return subProject.folder_path;
42
43
  }
43
- // 2. project.project_path
44
44
  if (project.project_path && fs.existsSync(project.project_path)) {
45
45
  return project.project_path;
46
46
  }
@@ -60,7 +60,6 @@ async function executeTask(task: ITask, project: IProject, options: WatcherOptio
60
60
  return;
61
61
  }
62
62
 
63
- // Re-check status (another watcher might have grabbed it)
64
63
  const fresh = getTask(task.id);
65
64
  if (!fresh || fresh.status !== 'submitted') {
66
65
  logTask(`⚠ Skip "${task.title}" — status already changed`);
@@ -80,37 +79,90 @@ async function executeTask(task: ITask, project: IProject, options: WatcherOptio
80
79
  // Transition: submitted → testing
81
80
  updateTask(task.id, { status: 'testing' });
82
81
  logTask(`submitted → testing`);
83
- addTaskConversation(task.id, 'user', `[watch] Execution started`);
82
+ addTaskConversation(task.id, 'user', `[watch] 실행 시작`);
84
83
 
85
84
  const startTime = Date.now();
86
85
 
87
- // Build prompt with AI policy context
88
86
  let fullPrompt = prompt.content;
89
87
  if (project.ai_context) {
90
88
  fullPrompt = `Project AI Policy:\n${project.ai_context}\n\n---\n\n${fullPrompt}`;
91
89
  }
92
90
 
91
+ logTask('────────────────────────────────');
92
+
93
+ // Accumulate streaming output and periodically save to DB
94
+ let accumulated = '';
95
+ let progressMsgId: string | null = null;
96
+ let lastSaveTime = Date.now();
97
+
98
+ const saveProgress = () => {
99
+ if (!accumulated.trim()) return;
100
+ const content = `[진행 중]\n${accumulated}`;
101
+ if (progressMsgId) {
102
+ // Update existing progress message
103
+ const { getDb } = require('./db/index');
104
+ const db = getDb();
105
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(content, progressMsgId);
106
+ } else {
107
+ const msg = addTaskConversation(task.id, 'assistant', content);
108
+ progressMsgId = msg.id;
109
+ }
110
+ };
111
+
93
112
  try {
94
- const result = await runClaude(fullPrompt, undefined, undefined, {
113
+ const onText = (chunk: string) => {
114
+ process.stdout.write(chunk);
115
+ accumulated += chunk;
116
+
117
+ // Save to DB periodically
118
+ if (Date.now() - lastSaveTime > PROGRESS_SAVE_INTERVAL) {
119
+ saveProgress();
120
+ lastSaveTime = Date.now();
121
+ }
122
+ };
123
+
124
+ const result = await runClaude(fullPrompt, onText, undefined, {
95
125
  cwd,
96
126
  timeoutMs: options.timeoutMs,
97
127
  });
98
128
 
129
+ process.stdout.write('\n');
130
+ logTask('────────────────────────────────');
131
+
99
132
  const duration = Date.now() - startTime;
100
133
  updateTask(task.id, { status: 'done' });
101
- addTaskConversation(task.id, 'assistant', result || '(no output)');
134
+
135
+ // Replace progress message with final result
136
+ if (progressMsgId) {
137
+ const { getDb } = require('./db/index');
138
+ const db = getDb();
139
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(result || '(no output)', progressMsgId);
140
+ } else {
141
+ addTaskConversation(task.id, 'assistant', result || '(no output)');
142
+ }
143
+
102
144
  console.log(`[${timestamp()}] ✓ Done (${formatDuration(duration)})`);
103
145
  } catch (err) {
104
146
  const duration = Date.now() - startTime;
147
+ process.stdout.write('\n');
148
+ logTask('────────────────────────────────');
105
149
  const errorMsg = err instanceof Error ? err.message : String(err);
106
150
  updateTask(task.id, { status: 'problem' });
107
- addTaskConversation(task.id, 'assistant', `[error] ${errorMsg}`);
151
+
152
+ // Replace progress message with error
153
+ if (progressMsgId) {
154
+ const { getDb } = require('./db/index');
155
+ const db = getDb();
156
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(`[error] ${errorMsg}`, progressMsgId);
157
+ } else {
158
+ addTaskConversation(task.id, 'assistant', `[error] ${errorMsg}`);
159
+ }
160
+
108
161
  console.log(`[${timestamp()}] ✗ Failed (${formatDuration(duration)}): ${errorMsg}`);
109
162
  }
110
163
  }
111
164
 
112
165
  export async function startWatcher(options: WatcherOptions): Promise<void> {
113
- // Validate project if specified
114
166
  if (options.projectId) {
115
167
  const project = getProject(options.projectId);
116
168
  if (!project) {
@@ -119,8 +171,7 @@ export async function startWatcher(options: WatcherOptions): Promise<void> {
119
171
  }
120
172
  log(`Watching project: "${project.name}" (${project.id})`);
121
173
  } else {
122
- const projects = listProjects();
123
- log(`Watching all projects (${projects.length})`);
174
+ log(`Watching all watch-enabled projects`);
124
175
  }
125
176
 
126
177
  log(`Polling every ${options.intervalMs / 1000}s | Timeout: ${options.timeoutMs / 60000}m${options.dryRun ? ' | DRY RUN' : ''}`);
@@ -135,17 +186,15 @@ export async function startWatcher(options: WatcherOptions): Promise<void> {
135
186
  isProcessing = true;
136
187
 
137
188
  try {
138
- // Collect submitted tasks
139
- const projectIds = options.projectId
140
- ? [options.projectId]
141
- : listProjects().map(p => p.id);
189
+ // Only process watch-enabled projects
190
+ const projects = options.projectId
191
+ ? [getProject(options.projectId)!].filter(p => p)
192
+ : listProjects().filter(p => p.watch_enabled);
142
193
 
143
194
  const submittedTasks: { task: ITask; project: IProject }[] = [];
144
195
 
145
- for (const pid of projectIds) {
146
- const project = getProject(pid);
147
- if (!project) continue;
148
- const tasks = getTasksByProject(pid).filter(t => t.status === 'submitted');
196
+ for (const project of projects) {
197
+ const tasks = getTasksByProject(project.id).filter(t => t.status === 'submitted');
149
198
  for (const task of tasks) {
150
199
  submittedTasks.push({ task, project });
151
200
  }
@@ -166,13 +215,11 @@ export async function startWatcher(options: WatcherOptions): Promise<void> {
166
215
  }
167
216
  };
168
217
 
169
- // Graceful shutdown
170
218
  const shutdown = () => {
171
219
  if (shuttingDown) return;
172
220
  shuttingDown = true;
173
221
  console.log(`\n[IM Watch] Shutting down...${isProcessing ? ' (waiting for current task)' : ''}`);
174
222
  if (!isProcessing) process.exit(0);
175
- // If processing, the poll loop will exit after the current task
176
223
  const checkDone = setInterval(() => {
177
224
  if (!isProcessing) {
178
225
  clearInterval(checkDone);
@@ -184,7 +231,6 @@ export async function startWatcher(options: WatcherOptions): Promise<void> {
184
231
  process.on('SIGINT', shutdown);
185
232
  process.on('SIGTERM', shutdown);
186
233
 
187
- // Initial poll + recurring
188
234
  await poll();
189
235
  setInterval(poll, options.intervalMs);
190
236
  }
@@ -4,6 +4,7 @@ export interface IProject {
4
4
  description: string;
5
5
  project_path: string | null;
6
6
  ai_context: string;
7
+ watch_enabled: boolean;
7
8
  created_at: string;
8
9
  updated_at: string;
9
10
  }