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 +1 -1
- package/src/cli.ts +17 -0
- package/src/lib/ai/client.ts +22 -2
- package/src/lib/watcher.ts +190 -0
package/package.json
CHANGED
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)')
|
package/src/lib/ai/client.ts
CHANGED
|
@@ -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
|
+
}
|