idea-manager 0.4.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/app/projects/[id]/page.tsx +0 -4
- package/src/cli.ts +17 -0
- package/src/components/brainstorm/Editor.tsx +1 -84
- package/src/lib/ai/client.ts +22 -125
- package/src/lib/db/schema.ts +0 -70
- package/src/lib/watcher.ts +190 -0
- package/src/types/index.ts +0 -90
- package/src/app/api/projects/[id]/cleanup/route.ts +0 -32
- package/src/app/api/projects/[id]/conversations/route.ts +0 -50
- package/src/app/api/projects/[id]/items/[itemId]/prompt/route.ts +0 -51
- package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +0 -36
- package/src/app/api/projects/[id]/items/[itemId]/route.ts +0 -95
- package/src/app/api/projects/[id]/items/route.ts +0 -67
- package/src/app/api/projects/[id]/memos/route.ts +0 -18
- package/src/app/api/projects/[id]/scan/route.ts +0 -73
- package/src/app/api/projects/[id]/scan/stream/route.ts +0 -112
- package/src/app/api/projects/[id]/structure/route.ts +0 -59
- package/src/app/api/projects/[id]/structure/stream/route.ts +0 -157
- package/src/components/ScanPanel.tsx +0 -743
- package/src/components/brainstorm/MemoPin.tsx +0 -117
- package/src/components/tree/CardView.tsx +0 -206
- package/src/components/tree/ItemDetail.tsx +0 -196
- package/src/components/tree/LockToggle.tsx +0 -23
- package/src/components/tree/RefinePopover.tsx +0 -157
- package/src/components/tree/StatusBadge.tsx +0 -32
- package/src/components/tree/TreeNode.tsx +0 -227
- package/src/components/tree/TreeView.tsx +0 -304
- package/src/lib/ai/chat-responder.ts +0 -71
- package/src/lib/ai/cleanup.ts +0 -87
- package/src/lib/ai/prompter.ts +0 -78
- package/src/lib/ai/refiner.ts +0 -128
- package/src/lib/ai/structurer.ts +0 -403
- package/src/lib/db/queries/context.ts +0 -76
- package/src/lib/db/queries/conversations.ts +0 -46
- package/src/lib/db/queries/items.ts +0 -268
- package/src/lib/db/queries/memos.ts +0 -66
- package/src/lib/db/queries/prompts.ts +0 -68
- package/src/lib/scanner.ts +0 -573
- package/src/lib/task-store.ts +0 -97
package/package.json
CHANGED
|
@@ -334,10 +334,6 @@ function WorkspaceInner({ id }: { id: string }) {
|
|
|
334
334
|
<div style={{ width: leftWidth }} className="border-r border-border flex flex-col flex-shrink-0">
|
|
335
335
|
<Editor
|
|
336
336
|
projectId={id}
|
|
337
|
-
onContentChange={() => {}}
|
|
338
|
-
onSendMessage={() => {}}
|
|
339
|
-
memos={[]}
|
|
340
|
-
chatLoading={false}
|
|
341
337
|
onCollapse={() => setShowBrainstorm(false)}
|
|
342
338
|
/>
|
|
343
339
|
</div>
|
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)')
|
|
@@ -1,39 +1,18 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
-
import MemoPin from './MemoPin';
|
|
5
|
-
|
|
6
|
-
interface Memo {
|
|
7
|
-
id: string;
|
|
8
|
-
anchor_text: string;
|
|
9
|
-
question: string;
|
|
10
|
-
is_resolved: boolean;
|
|
11
|
-
}
|
|
12
4
|
|
|
13
5
|
interface EditorProps {
|
|
14
6
|
projectId: string;
|
|
15
|
-
onContentChange: (content: string) => void;
|
|
16
|
-
onSendMessage: (message: string) => void;
|
|
17
|
-
memos?: Memo[];
|
|
18
|
-
chatLoading?: boolean;
|
|
19
7
|
onCollapse?: () => void;
|
|
20
8
|
}
|
|
21
9
|
|
|
22
|
-
|
|
23
|
-
memo: Memo;
|
|
24
|
-
top: number;
|
|
25
|
-
left: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export default function Editor({ projectId, onContentChange, onSendMessage, memos = [], chatLoading, onCollapse }: EditorProps) {
|
|
10
|
+
export default function Editor({ projectId, onCollapse }: EditorProps) {
|
|
29
11
|
const [content, setContent] = useState('');
|
|
30
12
|
const [saving, setSaving] = useState(false);
|
|
31
13
|
const [loaded, setLoaded] = useState(false);
|
|
32
|
-
const [pinPositions, setPinPositions] = useState<PinPosition[]>([]);
|
|
33
14
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
-
const structureTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
15
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
36
|
-
const overlayRef = useRef<HTMLDivElement>(null);
|
|
37
16
|
|
|
38
17
|
// Load brainstorm content
|
|
39
18
|
useEffect(() => {
|
|
@@ -46,37 +25,6 @@ export default function Editor({ projectId, onContentChange, onSendMessage, memo
|
|
|
46
25
|
load();
|
|
47
26
|
}, [projectId]);
|
|
48
27
|
|
|
49
|
-
// Calculate pin positions when memos or content change
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
if (!textareaRef.current || !content) {
|
|
52
|
-
setPinPositions([]);
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const textarea = textareaRef.current;
|
|
57
|
-
const unresolvedMemos = memos.filter(m => !m.is_resolved);
|
|
58
|
-
const positions: PinPosition[] = [];
|
|
59
|
-
|
|
60
|
-
for (const memo of unresolvedMemos) {
|
|
61
|
-
const idx = content.indexOf(memo.anchor_text);
|
|
62
|
-
if (idx === -1) continue;
|
|
63
|
-
|
|
64
|
-
// Calculate approximate position based on character index
|
|
65
|
-
const textBefore = content.substring(0, idx);
|
|
66
|
-
const lines = textBefore.split('\n');
|
|
67
|
-
const lineNumber = lines.length - 1;
|
|
68
|
-
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 22;
|
|
69
|
-
const paddingTop = parseFloat(getComputedStyle(textarea).paddingTop) || 16;
|
|
70
|
-
|
|
71
|
-
const top = paddingTop + lineNumber * lineHeight;
|
|
72
|
-
const left = textarea.clientWidth - 28;
|
|
73
|
-
|
|
74
|
-
positions.push({ memo, top, left });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
setPinPositions(positions);
|
|
78
|
-
}, [memos, content]);
|
|
79
|
-
|
|
80
28
|
const saveContent = useCallback(async (text: string) => {
|
|
81
29
|
setSaving(true);
|
|
82
30
|
await fetch(`/api/projects/${projectId}/brainstorm`, {
|
|
@@ -96,21 +44,6 @@ export default function Editor({ projectId, onContentChange, onSendMessage, memo
|
|
|
96
44
|
saveTimerRef.current = setTimeout(() => {
|
|
97
45
|
saveContent(newContent);
|
|
98
46
|
}, 1000);
|
|
99
|
-
|
|
100
|
-
// Trigger AI structuring with 3s debounce
|
|
101
|
-
if (structureTimerRef.current) clearTimeout(structureTimerRef.current);
|
|
102
|
-
if (newContent.trim()) {
|
|
103
|
-
structureTimerRef.current = setTimeout(() => {
|
|
104
|
-
onContentChange(newContent);
|
|
105
|
-
}, 3000);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// Sync scroll between textarea and overlay
|
|
110
|
-
const handleScroll = () => {
|
|
111
|
-
if (textareaRef.current && overlayRef.current) {
|
|
112
|
-
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
113
|
-
}
|
|
114
47
|
};
|
|
115
48
|
|
|
116
49
|
if (!loaded) {
|
|
@@ -145,7 +78,6 @@ export default function Editor({ projectId, onContentChange, onSendMessage, memo
|
|
|
145
78
|
ref={textareaRef}
|
|
146
79
|
value={content}
|
|
147
80
|
onChange={handleChange}
|
|
148
|
-
onScroll={handleScroll}
|
|
149
81
|
placeholder={`자유롭게 아이디어를 적어보세요...
|
|
150
82
|
|
|
151
83
|
예시:
|
|
@@ -158,21 +90,6 @@ export default function Editor({ projectId, onContentChange, onSendMessage, memo
|
|
|
158
90
|
placeholder:text-muted-foreground/40 font-mono text-sm leading-relaxed"
|
|
159
91
|
spellCheck={false}
|
|
160
92
|
/>
|
|
161
|
-
{pinPositions.length > 0 && (
|
|
162
|
-
<div ref={overlayRef} className="memo-overlay">
|
|
163
|
-
{pinPositions.map((pin) => (
|
|
164
|
-
<MemoPin
|
|
165
|
-
key={pin.memo.id}
|
|
166
|
-
question={pin.memo.question}
|
|
167
|
-
anchorText={pin.memo.anchor_text}
|
|
168
|
-
top={pin.top}
|
|
169
|
-
left={pin.left}
|
|
170
|
-
loading={chatLoading}
|
|
171
|
-
onSendMessage={onSendMessage}
|
|
172
|
-
/>
|
|
173
|
-
))}
|
|
174
|
-
</div>
|
|
175
|
-
)}
|
|
176
93
|
</div>
|
|
177
94
|
</div>
|
|
178
95
|
);
|
package/src/lib/ai/client.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import type { IStructureWithQuestions } from '@/types';
|
|
3
|
-
|
|
4
|
-
export interface IStructuredItem {
|
|
5
|
-
title: string;
|
|
6
|
-
description: string;
|
|
7
|
-
item_type: 'feature' | 'task' | 'bug' | 'idea' | 'note';
|
|
8
|
-
priority: 'high' | 'medium' | 'low';
|
|
9
|
-
status?: 'pending' | 'in_progress' | 'done';
|
|
10
|
-
children?: IStructuredItem[];
|
|
11
|
-
}
|
|
12
2
|
|
|
13
3
|
const CLI_PATH = 'claude';
|
|
14
4
|
const DEFAULT_ARGS = ['--dangerously-skip-permissions'];
|
|
@@ -18,11 +8,16 @@ const MAX_TURNS = 80;
|
|
|
18
8
|
export type OnTextChunk = (text: string) => void;
|
|
19
9
|
export type OnRawEvent = (event: Record<string, unknown>) => void;
|
|
20
10
|
|
|
11
|
+
export interface RunClaudeOptions {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
21
16
|
/**
|
|
22
17
|
* Spawn Claude Code CLI and collect the result text.
|
|
23
18
|
* Optional onText callback receives streaming text chunks as they arrive.
|
|
24
19
|
*/
|
|
25
|
-
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> {
|
|
26
21
|
return new Promise((resolve, reject) => {
|
|
27
22
|
const useStreamJson = !!(onText || onRawEvent);
|
|
28
23
|
const args = [
|
|
@@ -45,11 +40,21 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
|
|
|
45
40
|
}
|
|
46
41
|
|
|
47
42
|
const proc = spawn(CLI_PATH, args, {
|
|
48
|
-
cwd: process.cwd(),
|
|
43
|
+
cwd: options?.cwd || process.cwd(),
|
|
49
44
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
45
|
env: { ...cleanEnv, FORCE_COLOR: '0' },
|
|
51
46
|
});
|
|
52
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
|
+
|
|
53
58
|
// Write prompt to stdin and close it
|
|
54
59
|
proc.stdin?.write(prompt);
|
|
55
60
|
proc.stdin?.end();
|
|
@@ -109,10 +114,15 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
|
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
proc.on('exit', (code, signal) => {
|
|
117
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
112
118
|
// Clean up known CLI noise from text output
|
|
113
119
|
if (!useStreamJson) {
|
|
114
120
|
resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
|
|
115
121
|
}
|
|
122
|
+
if (timedOut) {
|
|
123
|
+
reject(new Error(`Claude CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
116
126
|
if (code !== 0 && !resultText) {
|
|
117
127
|
const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
|
|
118
128
|
reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
|
|
@@ -122,116 +132,3 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
|
|
|
122
132
|
});
|
|
123
133
|
});
|
|
124
134
|
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Run Claude for free-form markdown analysis (not JSON).
|
|
128
|
-
* Used for building the hub document in multi-agent analysis.
|
|
129
|
-
*/
|
|
130
|
-
export function runAnalysis(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<string> {
|
|
131
|
-
return runClaude(prompt, onText, onRawEvent);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function extractJson(text: string, type: 'array' | 'object'): string {
|
|
135
|
-
// Strip markdown fences
|
|
136
|
-
text = text.replace(/```(?:json)?\s*/g, '').replace(/```\s*/g, '').trim();
|
|
137
|
-
const pattern = type === 'array' ? /\[[\s\S]*\]/ : /\{[\s\S]*\}/;
|
|
138
|
-
const match = text.match(pattern);
|
|
139
|
-
if (!match) {
|
|
140
|
-
throw new Error(`AI did not return valid JSON ${type}`);
|
|
141
|
-
}
|
|
142
|
-
return match[0];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function runStructure(brainstormContent: string, projectContext?: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<IStructuredItem[]> {
|
|
146
|
-
const systemPrompt = `You are a JSON-only structuring machine. You NEVER respond with text, explanations, or conversation.
|
|
147
|
-
You ALWAYS output ONLY a raw JSON array, nothing else.
|
|
148
|
-
|
|
149
|
-
Your job: convert ANY input text into a structured JSON array of items.
|
|
150
|
-
Even if the input seems like a greeting or conversation, extract the implicit intent and structure it.
|
|
151
|
-
|
|
152
|
-
Schema per item:
|
|
153
|
-
{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same schema] }
|
|
154
|
-
|
|
155
|
-
Rules:
|
|
156
|
-
- Output MUST start with [ and end with ]
|
|
157
|
-
- No markdown fences, no explanation, no text before or after the JSON
|
|
158
|
-
- Keep titles concise (under 50 chars)
|
|
159
|
-
- Group related ideas under parent items
|
|
160
|
-
- If input is vague, interpret it as best you can and create at least 1 item
|
|
161
|
-
- IMPORTANT: When project source code context is provided, judge the status based on the actual code:
|
|
162
|
-
- "done": feature/task is fully implemented in the source code
|
|
163
|
-
- "in_progress": partially implemented or has TODOs
|
|
164
|
-
- "pending": not yet started or only planned
|
|
165
|
-
- If no source code context is provided, default status to "pending"`;
|
|
166
|
-
|
|
167
|
-
const ctxBlock = projectContext ? `\n\n프로젝트 문서 컨텍스트:\n${projectContext}` : '';
|
|
168
|
-
const prompt = `${systemPrompt}\n\nAnalyze this brainstorming content and structure it into a JSON tree:\n\n${brainstormContent}${ctxBlock}`;
|
|
169
|
-
|
|
170
|
-
const resultText = await runClaude(prompt, onText, onRawEvent);
|
|
171
|
-
const json = extractJson(resultText, 'array');
|
|
172
|
-
return JSON.parse(json) as IStructuredItem[];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function runStructureWithQuestions(
|
|
176
|
-
brainstormContent: string,
|
|
177
|
-
conversationHistory: { role: 'assistant' | 'user'; content: string }[],
|
|
178
|
-
projectContext?: string,
|
|
179
|
-
onText?: OnTextChunk,
|
|
180
|
-
onRawEvent?: OnRawEvent,
|
|
181
|
-
existingStructure?: string,
|
|
182
|
-
): Promise<IStructureWithQuestions> {
|
|
183
|
-
const systemPrompt = `You are an AI assistant that structures brainstorming content AND identifies ambiguous areas.
|
|
184
|
-
You ALWAYS output ONLY a raw JSON object (not an array), nothing else.
|
|
185
|
-
|
|
186
|
-
Your job:
|
|
187
|
-
1. Convert the brainstorming text into a structured JSON tree of items
|
|
188
|
-
2. Identify 0-5 areas where the brainstorming is ambiguous or could benefit from clarification
|
|
189
|
-
3. Consider the conversation history to avoid repeating questions already answered
|
|
190
|
-
4. If existing structured items are provided, UPDATE them rather than creating duplicates
|
|
191
|
-
|
|
192
|
-
Output schema:
|
|
193
|
-
{
|
|
194
|
-
"items": [{ "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same] }],
|
|
195
|
-
"questions": [{ "anchor_text": string, "question": string }]
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
Rules:
|
|
199
|
-
- Output MUST be a JSON object with "items" and "questions" keys
|
|
200
|
-
- No markdown fences, no explanation, no text before or after the JSON
|
|
201
|
-
- Keep titles concise (under 50 chars)
|
|
202
|
-
- Group related ideas under parent items
|
|
203
|
-
- NEVER create duplicate items. If the existing structure already has an item for a concept, update it instead of adding a new one
|
|
204
|
-
- Merge similar items together. The output should be a clean, deduplicated structure
|
|
205
|
-
- Preserve the status of existing items unless the brainstorming text explicitly changes them
|
|
206
|
-
- questions[].anchor_text MUST be an exact substring from the brainstorming content (5-20 chars)
|
|
207
|
-
- questions[].question should be a helpful Korean question asking for clarification
|
|
208
|
-
- Generate 0-5 questions. Skip questions already answered in conversation history.
|
|
209
|
-
- If the brainstorming is clear enough, return an empty questions array
|
|
210
|
-
- All questions MUST be in Korean
|
|
211
|
-
- If project documentation context is provided, use it to make more informed structuring decisions (e.g., matching tech stack, conventions, existing patterns)
|
|
212
|
-
- IMPORTANT: When project source code context is provided, judge the status based on the actual code:
|
|
213
|
-
- "done": feature/task is fully implemented in the source code
|
|
214
|
-
- "in_progress": partially implemented or has TODOs
|
|
215
|
-
- "pending": not yet started or only planned
|
|
216
|
-
- If no source code context is provided, default status to "pending"`;
|
|
217
|
-
|
|
218
|
-
let historyContext = '';
|
|
219
|
-
if (conversationHistory.length > 0) {
|
|
220
|
-
historyContext = '\n\n이전 대화:\n' + conversationHistory
|
|
221
|
-
.map(m => `${m.role === 'user' ? '사용자' : 'AI'}: ${m.content}`)
|
|
222
|
-
.join('\n');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const ctxBlock = projectContext ? `\n\n프로젝트 문서 컨텍스트:\n${projectContext}` : '';
|
|
226
|
-
const existingBlock = existingStructure ? `\n\n현재 구조화된 항목 (중복 생성하지 말고 업데이트하세요):\n${existingStructure}` : '';
|
|
227
|
-
const prompt = `${systemPrompt}\n\n다음 브레인스토밍 내용을 분석하고 구조화하세요:\n\n${brainstormContent}${historyContext}${existingBlock}${ctxBlock}`;
|
|
228
|
-
|
|
229
|
-
const resultText = await runClaude(prompt, onText, onRawEvent);
|
|
230
|
-
const json = extractJson(resultText, 'object');
|
|
231
|
-
const parsed = JSON.parse(json);
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
items: parsed.items || [],
|
|
235
|
-
questions: parsed.questions || [],
|
|
236
|
-
} as IStructureWithQuestions;
|
|
237
|
-
}
|
package/src/lib/db/schema.ts
CHANGED
|
@@ -21,78 +21,8 @@ export function initSchema(db: Database.Database): void {
|
|
|
21
21
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
22
22
|
);
|
|
23
23
|
|
|
24
|
-
CREATE TABLE IF NOT EXISTS items (
|
|
25
|
-
id TEXT PRIMARY KEY,
|
|
26
|
-
project_id TEXT NOT NULL,
|
|
27
|
-
brainstorm_id TEXT,
|
|
28
|
-
parent_id TEXT,
|
|
29
|
-
title TEXT NOT NULL,
|
|
30
|
-
description TEXT NOT NULL DEFAULT '',
|
|
31
|
-
item_type TEXT NOT NULL DEFAULT 'feature',
|
|
32
|
-
priority TEXT NOT NULL DEFAULT 'medium',
|
|
33
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
34
|
-
is_locked INTEGER NOT NULL DEFAULT 1,
|
|
35
|
-
is_pinned INTEGER NOT NULL DEFAULT 1,
|
|
36
|
-
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
37
|
-
metadata TEXT,
|
|
38
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
39
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
40
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
|
41
|
-
FOREIGN KEY (brainstorm_id) REFERENCES brainstorms(id),
|
|
42
|
-
FOREIGN KEY (parent_id) REFERENCES items(id)
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
CREATE TABLE IF NOT EXISTS conversations (
|
|
46
|
-
id TEXT PRIMARY KEY,
|
|
47
|
-
project_id TEXT NOT NULL,
|
|
48
|
-
role TEXT NOT NULL CHECK(role IN ('assistant', 'user')),
|
|
49
|
-
content TEXT NOT NULL,
|
|
50
|
-
metadata TEXT,
|
|
51
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
52
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
CREATE TABLE IF NOT EXISTS memos (
|
|
56
|
-
id TEXT PRIMARY KEY,
|
|
57
|
-
project_id TEXT NOT NULL,
|
|
58
|
-
conversation_id TEXT,
|
|
59
|
-
anchor_text TEXT NOT NULL,
|
|
60
|
-
question TEXT NOT NULL,
|
|
61
|
-
is_resolved INTEGER NOT NULL DEFAULT 0,
|
|
62
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
63
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
64
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
|
65
|
-
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
CREATE TABLE IF NOT EXISTS prompts (
|
|
69
|
-
id TEXT PRIMARY KEY,
|
|
70
|
-
project_id TEXT NOT NULL,
|
|
71
|
-
item_id TEXT NOT NULL,
|
|
72
|
-
content TEXT NOT NULL,
|
|
73
|
-
prompt_type TEXT NOT NULL DEFAULT 'auto',
|
|
74
|
-
version INTEGER NOT NULL DEFAULT 1,
|
|
75
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
|
77
|
-
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
CREATE TABLE IF NOT EXISTS project_context (
|
|
81
|
-
id TEXT PRIMARY KEY,
|
|
82
|
-
project_id TEXT NOT NULL,
|
|
83
|
-
file_path TEXT NOT NULL,
|
|
84
|
-
content TEXT NOT NULL,
|
|
85
|
-
scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
86
|
-
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
87
|
-
);
|
|
88
24
|
`);
|
|
89
25
|
|
|
90
|
-
// Migrations for existing DBs
|
|
91
|
-
const itemCols = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
|
|
92
|
-
if (!itemCols.some(c => c.name === 'is_pinned')) {
|
|
93
|
-
db.exec("ALTER TABLE items ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 1");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
26
|
const projCols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
|
|
97
27
|
if (!projCols.some(c => c.name === 'project_path')) {
|
|
98
28
|
db.exec("ALTER TABLE projects ADD COLUMN project_path TEXT");
|
|
@@ -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
|
+
}
|