idea-manager 0.5.0 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
package/src/cli.ts CHANGED
@@ -38,6 +38,23 @@ program
38
38
  await startMcpServer(ctx);
39
39
  });
40
40
 
41
+ program
42
+ .command('watch')
43
+ .description('Watch for submitted tasks and auto-execute via Claude CLI')
44
+ .option('--project <id>', 'Watch a specific project (default: all)')
45
+ .option('--interval <seconds>', 'Polling interval in seconds', '10')
46
+ .option('--timeout <minutes>', 'Per-task timeout in minutes', '10')
47
+ .option('--dry-run', 'Show what would be executed without running')
48
+ .action(async (opts) => {
49
+ const { startWatcher } = await import('@/lib/watcher');
50
+ await startWatcher({
51
+ projectId: opts.project,
52
+ intervalMs: parseInt(opts.interval) * 1000,
53
+ timeoutMs: parseInt(opts.timeout) * 60000,
54
+ dryRun: !!opts.dryRun,
55
+ });
56
+ });
57
+
41
58
  program
42
59
  .command('start')
43
60
  .description('Start the web UI (Next.js dev server on port 3456)')
@@ -8,11 +8,16 @@ const MAX_TURNS = 80;
8
8
  export type OnTextChunk = (text: string) => void;
9
9
  export type OnRawEvent = (event: Record<string, unknown>) => void;
10
10
 
11
+ export interface RunClaudeOptions {
12
+ cwd?: string;
13
+ timeoutMs?: number;
14
+ }
15
+
11
16
  /**
12
17
  * Spawn Claude Code CLI and collect the result text.
13
18
  * Optional onText callback receives streaming text chunks as they arrive.
14
19
  */
15
- export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<string> {
20
+ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent, options?: RunClaudeOptions): Promise<string> {
16
21
  return new Promise((resolve, reject) => {
17
22
  const useStreamJson = !!(onText || onRawEvent);
18
23
  const args = [
@@ -35,11 +40,21 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
35
40
  }
36
41
 
37
42
  const proc = spawn(CLI_PATH, args, {
38
- cwd: process.cwd(),
43
+ cwd: options?.cwd || process.cwd(),
39
44
  stdio: ['pipe', 'pipe', 'pipe'],
40
45
  env: { ...cleanEnv, FORCE_COLOR: '0' },
41
46
  });
42
47
 
48
+ // Timeout handling
49
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
50
+ let timedOut = false;
51
+ if (options?.timeoutMs) {
52
+ timeoutTimer = setTimeout(() => {
53
+ timedOut = true;
54
+ proc.kill('SIGTERM');
55
+ }, options.timeoutMs);
56
+ }
57
+
43
58
  // Write prompt to stdin and close it
44
59
  proc.stdin?.write(prompt);
45
60
  proc.stdin?.end();
@@ -99,10 +114,15 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
99
114
  });
100
115
 
101
116
  proc.on('exit', (code, signal) => {
117
+ if (timeoutTimer) clearTimeout(timeoutTimer);
102
118
  // Clean up known CLI noise from text output
103
119
  if (!useStreamJson) {
104
120
  resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
105
121
  }
122
+ if (timedOut) {
123
+ reject(new Error(`Claude CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
124
+ return;
125
+ }
106
126
  if (code !== 0 && !resultText) {
107
127
  const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
108
128
  reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
@@ -0,0 +1,190 @@
1
+ import fs from 'fs';
2
+ import { runClaude } from './ai/client';
3
+ import { listProjects, getProject } from './db/queries/projects';
4
+ import { getSubProject } from './db/queries/sub-projects';
5
+ import { getTasksByProject, getTask, updateTask } from './db/queries/tasks';
6
+ import { getTaskPrompt } from './db/queries/task-prompts';
7
+ import { addTaskConversation } from './db/queries/task-conversations';
8
+ import type { ITask, IProject } from '@/types';
9
+
10
+ export interface WatcherOptions {
11
+ projectId?: string;
12
+ intervalMs: number;
13
+ timeoutMs: number;
14
+ dryRun: boolean;
15
+ }
16
+
17
+ function timestamp(): string {
18
+ return new Date().toLocaleTimeString('ko-KR', { hour12: false });
19
+ }
20
+
21
+ function log(msg: string) {
22
+ console.log(`[IM Watch] ${msg}`);
23
+ }
24
+
25
+ function logTask(msg: string) {
26
+ console.log(` ${msg}`);
27
+ }
28
+
29
+ function formatDuration(ms: number): string {
30
+ const s = Math.floor(ms / 1000);
31
+ if (s < 60) return `${s}s`;
32
+ const m = Math.floor(s / 60);
33
+ const rem = s % 60;
34
+ return `${m}m ${rem}s`;
35
+ }
36
+
37
+ function resolveCwd(task: ITask, project: IProject): string | null {
38
+ // 1. sub_project.folder_path
39
+ const subProject = getSubProject(task.sub_project_id);
40
+ if (subProject?.folder_path && fs.existsSync(subProject.folder_path)) {
41
+ return subProject.folder_path;
42
+ }
43
+ // 2. project.project_path
44
+ if (project.project_path && fs.existsSync(project.project_path)) {
45
+ return project.project_path;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ async function executeTask(task: ITask, project: IProject, options: WatcherOptions): Promise<void> {
51
+ const cwd = resolveCwd(task, project);
52
+ if (!cwd) {
53
+ logTask(`⚠ Skip "${task.title}" — no folder_path or project_path set`);
54
+ return;
55
+ }
56
+
57
+ const prompt = getTaskPrompt(task.id);
58
+ if (!prompt?.content?.trim()) {
59
+ logTask(`⚠ Skip "${task.title}" — no prompt content`);
60
+ return;
61
+ }
62
+
63
+ // Re-check status (another watcher might have grabbed it)
64
+ const fresh = getTask(task.id);
65
+ if (!fresh || fresh.status !== 'submitted') {
66
+ logTask(`⚠ Skip "${task.title}" — status already changed`);
67
+ return;
68
+ }
69
+
70
+ const subProject = getSubProject(task.sub_project_id);
71
+ const subName = subProject?.name || 'unknown';
72
+
73
+ console.log(`[${timestamp()}] ▶ "${task.title}" (sub: ${subName}, cwd: ${cwd})`);
74
+
75
+ if (options.dryRun) {
76
+ logTask(`[DRY RUN] Would execute prompt (${prompt.content.length} chars)`);
77
+ return;
78
+ }
79
+
80
+ // Transition: submitted → testing
81
+ updateTask(task.id, { status: 'testing' });
82
+ logTask(`submitted → testing`);
83
+ addTaskConversation(task.id, 'user', `[watch] Execution started`);
84
+
85
+ const startTime = Date.now();
86
+
87
+ // Build prompt with AI policy context
88
+ let fullPrompt = prompt.content;
89
+ if (project.ai_context) {
90
+ fullPrompt = `Project AI Policy:\n${project.ai_context}\n\n---\n\n${fullPrompt}`;
91
+ }
92
+
93
+ try {
94
+ const result = await runClaude(fullPrompt, undefined, undefined, {
95
+ cwd,
96
+ timeoutMs: options.timeoutMs,
97
+ });
98
+
99
+ const duration = Date.now() - startTime;
100
+ updateTask(task.id, { status: 'done' });
101
+ addTaskConversation(task.id, 'assistant', result || '(no output)');
102
+ console.log(`[${timestamp()}] ✓ Done (${formatDuration(duration)})`);
103
+ } catch (err) {
104
+ const duration = Date.now() - startTime;
105
+ const errorMsg = err instanceof Error ? err.message : String(err);
106
+ updateTask(task.id, { status: 'problem' });
107
+ addTaskConversation(task.id, 'assistant', `[error] ${errorMsg}`);
108
+ console.log(`[${timestamp()}] ✗ Failed (${formatDuration(duration)}): ${errorMsg}`);
109
+ }
110
+ }
111
+
112
+ export async function startWatcher(options: WatcherOptions): Promise<void> {
113
+ // Validate project if specified
114
+ if (options.projectId) {
115
+ const project = getProject(options.projectId);
116
+ if (!project) {
117
+ console.error(`Error: Project "${options.projectId}" not found.`);
118
+ process.exit(1);
119
+ }
120
+ log(`Watching project: "${project.name}" (${project.id})`);
121
+ } else {
122
+ const projects = listProjects();
123
+ log(`Watching all projects (${projects.length})`);
124
+ }
125
+
126
+ log(`Polling every ${options.intervalMs / 1000}s | Timeout: ${options.timeoutMs / 60000}m${options.dryRun ? ' | DRY RUN' : ''}`);
127
+ log('─'.repeat(50));
128
+ log('Press Ctrl+C to stop\n');
129
+
130
+ let isProcessing = false;
131
+ let shuttingDown = false;
132
+
133
+ const poll = async () => {
134
+ if (isProcessing || shuttingDown) return;
135
+ isProcessing = true;
136
+
137
+ try {
138
+ // Collect submitted tasks
139
+ const projectIds = options.projectId
140
+ ? [options.projectId]
141
+ : listProjects().map(p => p.id);
142
+
143
+ const submittedTasks: { task: ITask; project: IProject }[] = [];
144
+
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');
149
+ for (const task of tasks) {
150
+ submittedTasks.push({ task, project });
151
+ }
152
+ }
153
+
154
+ if (submittedTasks.length > 0) {
155
+ console.log(`[${timestamp()}] Found ${submittedTasks.length} submitted task(s)`);
156
+ for (const { task, project } of submittedTasks) {
157
+ if (shuttingDown) break;
158
+ await executeTask(task, project, options);
159
+ }
160
+ }
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ console.error(`[${timestamp()}] Poll error: ${msg}`);
164
+ } finally {
165
+ isProcessing = false;
166
+ }
167
+ };
168
+
169
+ // Graceful shutdown
170
+ const shutdown = () => {
171
+ if (shuttingDown) return;
172
+ shuttingDown = true;
173
+ console.log(`\n[IM Watch] Shutting down...${isProcessing ? ' (waiting for current task)' : ''}`);
174
+ if (!isProcessing) process.exit(0);
175
+ // If processing, the poll loop will exit after the current task
176
+ const checkDone = setInterval(() => {
177
+ if (!isProcessing) {
178
+ clearInterval(checkDone);
179
+ process.exit(0);
180
+ }
181
+ }, 500);
182
+ };
183
+
184
+ process.on('SIGINT', shutdown);
185
+ process.on('SIGTERM', shutdown);
186
+
187
+ // Initial poll + recurring
188
+ await poll();
189
+ setInterval(poll, options.intervalMs);
190
+ }