@jjlabsio/claude-crew 0.1.3
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/.claude-plugin/marketplace.json +32 -0
- package/.claude-plugin/plugin.json +29 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/agents/code-reviewer.md +54 -0
- package/agents/dev.md +53 -0
- package/agents/explorer.md +21 -0
- package/agents/plan-evaluator.md +67 -0
- package/agents/planner.md +73 -0
- package/agents/pm.md +54 -0
- package/agents/qa.md +63 -0
- package/agents/researcher.md +22 -0
- package/agents/techlead.md +67 -0
- package/hooks/hooks.json +17 -0
- package/hud/index.mjs +375 -0
- package/package.json +35 -0
- package/scripts/setup-hud.mjs +89 -0
- package/skills/crew/SKILL.md +187 -0
- package/skills/crew-dev/SKILL.md +405 -0
- package/skills/crew-plan/SKILL.md +396 -0
- package/skills/crew-setup/SKILL.md +29 -0
package/hud/index.mjs
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CREW HUD - Statusline for claude-crew plugin
|
|
4
|
+
*
|
|
5
|
+
* Displays:
|
|
6
|
+
* Top: repo:<name> | branch:<branch> | model:<model>
|
|
7
|
+
* Middle: [CREW#x.y.z] ctx:<pct>% | agents:<n> | skill:<name> | session:<duration>
|
|
8
|
+
*
|
|
9
|
+
* Receives JSON on stdin from Claude Code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname, basename } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// ANSI helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const RESET = '\x1b[0m';
|
|
21
|
+
const bold = (s) => `\x1b[1m${s}\x1b[22m`;
|
|
22
|
+
const dim = (s) => `\x1b[2m${s}\x1b[22m`;
|
|
23
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[39m`;
|
|
24
|
+
const red = (s) => `\x1b[31m${s}\x1b[39m`;
|
|
25
|
+
const green = (s) => `\x1b[32m${s}\x1b[39m`;
|
|
26
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[39m`;
|
|
27
|
+
const magenta = (s) => `\x1b[35m${s}\x1b[39m`;
|
|
28
|
+
|
|
29
|
+
const SEPARATOR = dim(' | ');
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Read stdin (with timeout)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
async function readStdin(timeoutMs = 1000) {
|
|
35
|
+
if (process.stdin.isTTY) return null;
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let data = '';
|
|
38
|
+
const timer = setTimeout(() => resolve(data || null), timeoutMs);
|
|
39
|
+
process.stdin.setEncoding('utf-8');
|
|
40
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
41
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(data || null); });
|
|
42
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Version
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
function getVersion() {
|
|
50
|
+
try {
|
|
51
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
53
|
+
if (existsSync(pkgPath)) {
|
|
54
|
+
return JSON.parse(readFileSync(pkgPath, 'utf-8')).version || '0.0.0';
|
|
55
|
+
}
|
|
56
|
+
} catch { /* ignore */ }
|
|
57
|
+
return '0.0.0';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Git helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function gitExec(cmd, cwd) {
|
|
64
|
+
try {
|
|
65
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getGitRepo(cwd) {
|
|
70
|
+
// In worktrees, --show-toplevel returns the worktree path.
|
|
71
|
+
// Use the remote origin URL to get the real repo name.
|
|
72
|
+
const remoteUrl = gitExec('git remote get-url origin', cwd);
|
|
73
|
+
if (remoteUrl) {
|
|
74
|
+
// Handle both https and ssh formats
|
|
75
|
+
const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/);
|
|
76
|
+
if (match) return match[1];
|
|
77
|
+
}
|
|
78
|
+
// Fallback: use the main worktree (common dir) basename
|
|
79
|
+
const commonDir = gitExec('git rev-parse --git-common-dir', cwd);
|
|
80
|
+
if (commonDir) {
|
|
81
|
+
// commonDir is like /path/to/repo/.git
|
|
82
|
+
const repoDir = dirname(commonDir.replace(/\/.git$/, '') || commonDir);
|
|
83
|
+
return basename(repoDir === '.' ? commonDir : commonDir.replace(/\/.git$/, ''));
|
|
84
|
+
}
|
|
85
|
+
const topLevel = gitExec('git rev-parse --show-toplevel', cwd);
|
|
86
|
+
return topLevel ? basename(topLevel) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getGitBranch(cwd) {
|
|
90
|
+
return gitExec('git rev-parse --abbrev-ref HEAD', cwd);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hasUncommittedChanges(cwd) {
|
|
94
|
+
const status = gitExec('git status --porcelain', cwd);
|
|
95
|
+
return status ? status.length > 0 : false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasUnpushedCommits(cwd) {
|
|
99
|
+
const count = gitExec('git rev-list --count @{u}..HEAD', cwd);
|
|
100
|
+
return count ? parseInt(count, 10) > 0 : false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Model name
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function formatModel(stdin) {
|
|
107
|
+
if (!stdin?.model) return null;
|
|
108
|
+
const display = stdin.model.display_name || '';
|
|
109
|
+
if (display) return display;
|
|
110
|
+
// Fallback: parse from model id
|
|
111
|
+
const id = stdin.model.id || '';
|
|
112
|
+
if (id.includes('opus')) return 'Opus';
|
|
113
|
+
if (id.includes('sonnet')) return 'Sonnet';
|
|
114
|
+
if (id.includes('haiku')) return 'Haiku';
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Context percentage
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
function getContextPercent(stdin) {
|
|
122
|
+
if (!stdin?.context_window) return 0;
|
|
123
|
+
if (stdin.context_window.used_percentage != null) {
|
|
124
|
+
return Math.round(stdin.context_window.used_percentage);
|
|
125
|
+
}
|
|
126
|
+
if (stdin.context_window.current_usage && stdin.context_window.context_window_size) {
|
|
127
|
+
const used = stdin.context_window.current_usage.input_tokens +
|
|
128
|
+
(stdin.context_window.current_usage.cache_creation_input_tokens || 0) +
|
|
129
|
+
(stdin.context_window.current_usage.cache_read_input_tokens || 0);
|
|
130
|
+
return Math.round((used / stdin.context_window.context_window_size) * 100);
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function colorizeContext(pct) {
|
|
136
|
+
const color = pct >= 85 ? red : pct >= 70 ? yellow : green;
|
|
137
|
+
return `ctx:${color(`${pct}%`)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Transcript parsing (agents + skills)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
function parseTranscript(transcriptPath) {
|
|
144
|
+
const result = { agents: [], lastSkill: null, sessionStart: null };
|
|
145
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return result;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const content = readFileSync(transcriptPath, 'utf-8');
|
|
149
|
+
const lines = content.split('\n').filter(Boolean);
|
|
150
|
+
|
|
151
|
+
// Map of tool_use_id -> agent info
|
|
152
|
+
const agentMap = new Map();
|
|
153
|
+
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
let entry;
|
|
156
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
157
|
+
|
|
158
|
+
// Session start time
|
|
159
|
+
if (!result.sessionStart && entry.timestamp) {
|
|
160
|
+
result.sessionStart = new Date(entry.timestamp);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Track agents
|
|
164
|
+
if (entry.type === 'tool_use' || entry.type === 'assistant') {
|
|
165
|
+
const content = entry.message?.content;
|
|
166
|
+
if (Array.isArray(content)) {
|
|
167
|
+
for (const block of content) {
|
|
168
|
+
if (block.type === 'tool_use') {
|
|
169
|
+
// Agent start
|
|
170
|
+
if (block.name === 'Agent' || block.name === 'proxy_Agent') {
|
|
171
|
+
const id = block.id;
|
|
172
|
+
if (id) {
|
|
173
|
+
const input = block.input || {};
|
|
174
|
+
const agentType = input.subagent_type || input.type || 'general';
|
|
175
|
+
const model = input.model || null;
|
|
176
|
+
const description = input.description || input.prompt?.slice(0, 50) || '';
|
|
177
|
+
agentMap.set(id, {
|
|
178
|
+
id,
|
|
179
|
+
type: agentType,
|
|
180
|
+
model,
|
|
181
|
+
description,
|
|
182
|
+
startTime: entry.timestamp ? new Date(entry.timestamp) : new Date(),
|
|
183
|
+
status: 'running',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Skill invocation
|
|
188
|
+
if (block.name === 'Skill' || block.name === 'proxy_Skill') {
|
|
189
|
+
const skillName = block.input?.skill || block.input?.name;
|
|
190
|
+
if (skillName) {
|
|
191
|
+
result.lastSkill = skillName;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Agent completion — tool_result can appear as a top-level entry
|
|
200
|
+
// or inside a "user" message content array
|
|
201
|
+
if (entry.type === 'tool_result') {
|
|
202
|
+
const toolUseId = entry.tool_use_id;
|
|
203
|
+
if (toolUseId && agentMap.has(toolUseId)) {
|
|
204
|
+
agentMap.get(toolUseId).status = 'completed';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (entry.type === 'user') {
|
|
208
|
+
const content = entry.message?.content;
|
|
209
|
+
if (Array.isArray(content)) {
|
|
210
|
+
for (const block of content) {
|
|
211
|
+
if (block.type === 'tool_result') {
|
|
212
|
+
const toolUseId = block.tool_use_id;
|
|
213
|
+
if (toolUseId && agentMap.has(toolUseId)) {
|
|
214
|
+
agentMap.get(toolUseId).status = 'completed';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result.agents = [...agentMap.values()].filter(a => a.status === 'running');
|
|
223
|
+
} catch { /* ignore parse errors */ }
|
|
224
|
+
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
// Agent model name (short)
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
function shortModelName(model) {
|
|
232
|
+
if (!model) return '?';
|
|
233
|
+
const m = model.toLowerCase();
|
|
234
|
+
if (m.includes('opus')) return 'Opus';
|
|
235
|
+
if (m.includes('sonnet')) return 'Sonnet';
|
|
236
|
+
if (m.includes('haiku')) return 'Haiku';
|
|
237
|
+
return model;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Agent duration formatting
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
function formatAgentDuration(startTime) {
|
|
244
|
+
const ms = Date.now() - startTime.getTime();
|
|
245
|
+
const seconds = Math.floor(ms / 1000);
|
|
246
|
+
const minutes = Math.floor(seconds / 60);
|
|
247
|
+
if (seconds < 60) return `${seconds}s`;
|
|
248
|
+
return `${minutes}m`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Agent multiline rendering
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
function renderAgentsMultiLine(agents, maxLines = 5) {
|
|
255
|
+
if (agents.length === 0) return { headerPart: null, detailLines: [] };
|
|
256
|
+
|
|
257
|
+
const headerPart = `agents:${cyan(String(agents.length))}`;
|
|
258
|
+
|
|
259
|
+
// Sort by newest first
|
|
260
|
+
const sorted = [...agents].sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
|
|
261
|
+
const displayCount = Math.min(sorted.length, maxLines);
|
|
262
|
+
const detailLines = [];
|
|
263
|
+
|
|
264
|
+
sorted.slice(0, maxLines).forEach((a, index) => {
|
|
265
|
+
const isLast = index === displayCount - 1 && sorted.length <= maxLines;
|
|
266
|
+
const prefix = isLast ? '\u2514\u2500' : '\u251c\u2500';
|
|
267
|
+
|
|
268
|
+
const name = a.type.padEnd(12);
|
|
269
|
+
const model = shortModelName(a.model).padEnd(8);
|
|
270
|
+
const duration = formatAgentDuration(a.startTime).padStart(4);
|
|
271
|
+
const desc = a.description.length > 40 ? a.description.slice(0, 37) + '...' : a.description;
|
|
272
|
+
|
|
273
|
+
detailLines.push(
|
|
274
|
+
`${dim(prefix)} ${cyan(name)}${model}${dim(duration)} ${desc}`
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (sorted.length > maxLines) {
|
|
279
|
+
const remaining = sorted.length - maxLines;
|
|
280
|
+
detailLines.push(`${dim(`\u2514\u2500 +${remaining} more...`)}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { headerPart, detailLines };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Session duration
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
function formatDuration(startDate) {
|
|
290
|
+
if (!startDate) return null;
|
|
291
|
+
const ms = Date.now() - startDate.getTime();
|
|
292
|
+
const minutes = Math.floor(ms / 60000);
|
|
293
|
+
if (minutes < 60) return `${minutes}m`;
|
|
294
|
+
const hours = Math.floor(minutes / 60);
|
|
295
|
+
const remainMinutes = minutes % 60;
|
|
296
|
+
return `${hours}h${remainMinutes}m`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function colorizeSession(startDate) {
|
|
300
|
+
if (!startDate) return `session:${green('0m')}`;
|
|
301
|
+
const ms = Date.now() - startDate.getTime();
|
|
302
|
+
const minutes = Math.floor(ms / 60000);
|
|
303
|
+
const formatted = formatDuration(startDate);
|
|
304
|
+
const color = minutes > 120 ? red : minutes > 60 ? yellow : green;
|
|
305
|
+
return `session:${color(formatted)}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Main
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
async function main() {
|
|
312
|
+
const raw = await readStdin();
|
|
313
|
+
if (!raw) {
|
|
314
|
+
console.log('[CREW] no stdin');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let stdin;
|
|
319
|
+
try { stdin = JSON.parse(raw); } catch {
|
|
320
|
+
console.log('[CREW] invalid stdin');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const cwd = stdin.cwd || process.cwd();
|
|
325
|
+
const version = getVersion();
|
|
326
|
+
|
|
327
|
+
// --- Top line ---
|
|
328
|
+
const topElements = [];
|
|
329
|
+
|
|
330
|
+
const repo = getGitRepo(cwd);
|
|
331
|
+
if (repo) topElements.push(`repo:${cyan(repo)}`);
|
|
332
|
+
|
|
333
|
+
const branch = getGitBranch(cwd);
|
|
334
|
+
if (branch) {
|
|
335
|
+
const dirty = hasUncommittedChanges(cwd);
|
|
336
|
+
const unpushed = hasUnpushedCommits(cwd);
|
|
337
|
+
const branchDisplay = dirty ? `${branch}*` : branch;
|
|
338
|
+
const branchColor = unpushed ? yellow : cyan;
|
|
339
|
+
topElements.push(`branch:${branchColor(branchDisplay)}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const model = formatModel(stdin);
|
|
343
|
+
if (model) topElements.push(`model:${cyan(model)}`);
|
|
344
|
+
|
|
345
|
+
// --- Middle line ---
|
|
346
|
+
const midElements = [];
|
|
347
|
+
|
|
348
|
+
midElements.push(bold(`[CREW#${version}]`));
|
|
349
|
+
|
|
350
|
+
const ctxPct = getContextPercent(stdin);
|
|
351
|
+
midElements.push(colorizeContext(ctxPct));
|
|
352
|
+
|
|
353
|
+
const transcript = parseTranscript(stdin.transcript_path);
|
|
354
|
+
|
|
355
|
+
const { headerPart, detailLines } = renderAgentsMultiLine(transcript.agents);
|
|
356
|
+
if (headerPart) {
|
|
357
|
+
midElements.push(headerPart);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (transcript.lastSkill) {
|
|
361
|
+
midElements.push(`skill:${magenta(transcript.lastSkill)}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
midElements.push(colorizeSession(transcript.sessionStart));
|
|
365
|
+
|
|
366
|
+
// --- Output ---
|
|
367
|
+
const outputLines = [];
|
|
368
|
+
outputLines.push(topElements.join(SEPARATOR));
|
|
369
|
+
outputLines.push(midElements.join(SEPARATOR));
|
|
370
|
+
outputLines.push(...detailLines);
|
|
371
|
+
|
|
372
|
+
console.log(outputLines.filter(Boolean).join('\n'));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jjlabsio/claude-crew",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
|
|
5
|
+
"author": "Jaejin Song <wowlxx28@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/jjlabsio/claude-crew.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude-code",
|
|
13
|
+
"plugin",
|
|
14
|
+
"multi-agent",
|
|
15
|
+
"orchestration",
|
|
16
|
+
"solo-developer"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
".claude-plugin/",
|
|
20
|
+
"agents/",
|
|
21
|
+
"skills/",
|
|
22
|
+
"hooks/",
|
|
23
|
+
"hud/",
|
|
24
|
+
"scripts/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"changelogen": "^0.6.2"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CREW Session Start Hook
|
|
4
|
+
*
|
|
5
|
+
* Checks if statusLine is configured for CREW HUD.
|
|
6
|
+
* If not, automatically sets it up.
|
|
7
|
+
* Reads stdin JSON from Claude Code (SessionStart hook input).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Read stdin (with timeout)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
async function readStdin(timeoutMs = 3000) {
|
|
18
|
+
if (process.stdin.isTTY) return null;
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
let data = '';
|
|
21
|
+
const timer = setTimeout(() => resolve(data || null), timeoutMs);
|
|
22
|
+
process.stdin.setEncoding('utf-8');
|
|
23
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
24
|
+
process.stdin.on('end', () => { clearTimeout(timer); resolve(data || null); });
|
|
25
|
+
process.stdin.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Main
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
async function main() {
|
|
33
|
+
// Consume stdin (required by hook protocol)
|
|
34
|
+
await readStdin();
|
|
35
|
+
|
|
36
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
37
|
+
const settingsPath = join(configDir, 'settings.json');
|
|
38
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
39
|
+
|
|
40
|
+
if (!pluginRoot) {
|
|
41
|
+
// Not running as a plugin — skip
|
|
42
|
+
console.log(JSON.stringify({ continue: true }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hudCommand = `node "${pluginRoot}/hud/index.mjs"`;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
let settings = {};
|
|
50
|
+
if (existsSync(settingsPath)) {
|
|
51
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if statusLine is already set to crew HUD
|
|
55
|
+
const currentCommand = settings.statusLine?.command || '';
|
|
56
|
+
if (currentCommand.includes('crew') && currentCommand.includes('hud/index.mjs')) {
|
|
57
|
+
// Already configured
|
|
58
|
+
console.log(JSON.stringify({ continue: true }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Set statusLine to crew HUD
|
|
63
|
+
settings.statusLine = {
|
|
64
|
+
type: 'command',
|
|
65
|
+
command: hudCommand,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
69
|
+
|
|
70
|
+
console.log(JSON.stringify({
|
|
71
|
+
continue: true,
|
|
72
|
+
hookSpecificOutput: {
|
|
73
|
+
hookEventName: 'SessionStart',
|
|
74
|
+
additionalContext: 'CREW HUD가 자동 설정되었습니다. 다음 세션부터 statusline에 표시됩니다.',
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Non-fatal — don't block session start
|
|
79
|
+
console.log(JSON.stringify({
|
|
80
|
+
continue: true,
|
|
81
|
+
hookSpecificOutput: {
|
|
82
|
+
hookEventName: 'SessionStart',
|
|
83
|
+
additionalContext: `CREW HUD 자동 설정 실패: ${e.message}. /crew-setup을 수동 실행해주세요.`,
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crew
|
|
3
|
+
description: crew-plan과 crew-dev를 연결하는 오케스트레이터 — 유저 요청을 받아 PR 생성까지
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 역할
|
|
7
|
+
|
|
8
|
+
유저 요청을 받아 crew-plan(계획)과 crew-dev(구현)를 순차 실행하여 PR을 생성한다.
|
|
9
|
+
코드를 작성하지 않는다. 에이전트에게 위임한다.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 절대 금지
|
|
14
|
+
|
|
15
|
+
- 코드를 직접 작성, 수정, 검토하지 않는다.
|
|
16
|
+
- 기획, 계획, 검증을 직접 수행하지 않는다. 해당 에이전트에게 위임한다.
|
|
17
|
+
- 에이전트가 FAIL을 냈을 때 합리화하여 통과시키지 않는다.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 실행 순서
|
|
22
|
+
|
|
23
|
+
### Step 1 — 초기 셋업
|
|
24
|
+
|
|
25
|
+
`.crew/` 폴더가 없으면 생성한다.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
mkdir -p .crew/plans
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Step 2 — 의도 분류
|
|
32
|
+
|
|
33
|
+
유저 요청을 2가지로 분류한다:
|
|
34
|
+
|
|
35
|
+
| 유형 | 기준 | PM 관여 | 예시 |
|
|
36
|
+
|------|------|---------|------|
|
|
37
|
+
| **유저 가치** | 유저가 변화를 인지함 | O | 기능 추가, UI 변경, 플로우 수정 |
|
|
38
|
+
| **엔지니어링** | 유저가 변화를 인지하지 못함 | X | 리팩토링, 마이그레이션, 버그 수정, 인프라, 성능, 테스트 |
|
|
39
|
+
|
|
40
|
+
분류 기준: **"이 작업의 결과를 유저(사용자)가 인지하는가?"**
|
|
41
|
+
|
|
42
|
+
애매하면 유저에게 물어본다.
|
|
43
|
+
|
|
44
|
+
### Step 3 — task-id 생성
|
|
45
|
+
|
|
46
|
+
task-id를 생성한다. 형식: `{간결한-영문-슬러그}` (예: `add-search-filter`, `fix-auth-timeout`)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
mkdir -p .crew/plans/{task-id}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Step 4 — crew-plan 실행
|
|
53
|
+
|
|
54
|
+
crew-plan 파이프라인을 실행한다.
|
|
55
|
+
|
|
56
|
+
오케스트레이터가 crew-plan에 전달할 정보:
|
|
57
|
+
- task-id
|
|
58
|
+
- 의도 유형 (유저 가치 / 엔지니어링)
|
|
59
|
+
- 유저 요청 원문 (brief.md 작성용)
|
|
60
|
+
|
|
61
|
+
crew-plan의 반환을 확인한다:
|
|
62
|
+
- **COMPLETE** → contract.md 경로를 확인하고 Step 5로 진행
|
|
63
|
+
- **ESCALATE** → 유저에게 에스컬레이션 내용을 전달하고, 유저 응답에 따라 재시도 또는 보류
|
|
64
|
+
|
|
65
|
+
### Step 5 — crew-dev 실행
|
|
66
|
+
|
|
67
|
+
crew-dev 파이프라인을 실행한다.
|
|
68
|
+
|
|
69
|
+
오케스트레이터가 crew-dev에 전달할 정보:
|
|
70
|
+
- task-id
|
|
71
|
+
|
|
72
|
+
crew-dev의 반환을 확인한다:
|
|
73
|
+
- **COMPLETE** → PR URL을 유저에게 전달
|
|
74
|
+
- **ESCALATE** → 유저에게 에스컬레이션 내용을 전달하고, 유저 응답에 따라 재시도 또는 보류
|
|
75
|
+
|
|
76
|
+
### Step 6 — 완료 보고
|
|
77
|
+
|
|
78
|
+
유저에게 최종 결과를 보고한다:
|
|
79
|
+
- task-id
|
|
80
|
+
- PR URL
|
|
81
|
+
- 주요 변경 사항 요약
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 에스컬레이션 처리
|
|
86
|
+
|
|
87
|
+
에스컬레이션은 유저에게 선택지를 제시하고 응답을 기다린다.
|
|
88
|
+
유저 응답에 따라:
|
|
89
|
+
|
|
90
|
+
- **재시도**: 해당 파이프라인의 실패 지점부터 재실행
|
|
91
|
+
- **수정**: 유저가 직접 파일을 수정한 후 재실행
|
|
92
|
+
- **보류**: 상태를 BLOCKED으로 갱신하고 종료
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 산출물 파일 구조
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
.crew/plans/{task-id}/
|
|
100
|
+
# crew-plan 산출물
|
|
101
|
+
brief.md # 오케스트레이터: 유저 원본 요청
|
|
102
|
+
spec.md # PM: 수용 기준, 스코프 (유저 가치 유형만)
|
|
103
|
+
analysis.md # TechLead: 사전 분석 결과
|
|
104
|
+
plan.md # Planner: 구현 계획
|
|
105
|
+
review.md # PlanEvaluator: 검증 결과 (최신)
|
|
106
|
+
plan-{n}.md # 실패한 계획 아카이브
|
|
107
|
+
review-{n}.md # 실패한 리뷰 아카이브
|
|
108
|
+
contract.md # 최종 계약 (PASS 시만 생성)
|
|
109
|
+
.loop_count # 계획 루프 카운터
|
|
110
|
+
|
|
111
|
+
# crew-dev 산출물
|
|
112
|
+
dev-log.md # Dev: 구현 진행 로그
|
|
113
|
+
review-report.md # CodeReviewer: 코드 리뷰 결과 (최신)
|
|
114
|
+
qa-report.md # QA: 실행 검증 결과 (최신)
|
|
115
|
+
review-report-{n}.md # FAIL 시 아카이브
|
|
116
|
+
qa-report-{n}.md # FAIL 시 아카이브
|
|
117
|
+
.dev_loop_count # 개발 루프 카운터
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## contract.md 구조
|
|
123
|
+
|
|
124
|
+
PlanEvaluator PASS 후 crew-plan이 생성한다.
|
|
125
|
+
|
|
126
|
+
```markdown
|
|
127
|
+
# 스프린트 계약: {task-id}
|
|
128
|
+
|
|
129
|
+
생성일: {date}
|
|
130
|
+
유형: {유저 가치 | 엔지니어링}
|
|
131
|
+
|
|
132
|
+
## 목표
|
|
133
|
+
{한 문장}
|
|
134
|
+
|
|
135
|
+
## 수용 기준
|
|
136
|
+
- [ ] {testable 기준 1}
|
|
137
|
+
- [ ] {testable 기준 2}
|
|
138
|
+
|
|
139
|
+
## 가드레일
|
|
140
|
+
### Must
|
|
141
|
+
- {TechLead가 정의한 필수 사항}
|
|
142
|
+
|
|
143
|
+
### Must NOT
|
|
144
|
+
- {TechLead가 정의한 금지 사항}
|
|
145
|
+
|
|
146
|
+
## 검증 시나리오
|
|
147
|
+
|
|
148
|
+
### {시나리오 1 제목}
|
|
149
|
+
- 조건: {사전 상태}
|
|
150
|
+
- 행위: {실행할 것}
|
|
151
|
+
- 기대 결과: {검증할 것}
|
|
152
|
+
|
|
153
|
+
## 참조 문서
|
|
154
|
+
- 사전 분석: .crew/plans/{task-id}/analysis.md
|
|
155
|
+
- 구현 계획: .crew/plans/{task-id}/plan.md
|
|
156
|
+
|
|
157
|
+
## 검증 이력
|
|
158
|
+
PlanEvaluator PASS — review.md 참조
|
|
159
|
+
|
|
160
|
+
## 상태
|
|
161
|
+
ACTIVE
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 에이전트 라인업
|
|
167
|
+
|
|
168
|
+
### crew-plan
|
|
169
|
+
| 에이전트 | 모델 | 역할 |
|
|
170
|
+
|----------|------|------|
|
|
171
|
+
| PM | Opus | 유저 인터뷰, spec.md 작성 (유저 가치만) |
|
|
172
|
+
| TechLead | Opus | 사전 분석, 아키텍처 방향, 가드레일 |
|
|
173
|
+
| Planner | Opus | 계획 문서 작성 |
|
|
174
|
+
| PlanEvaluator | Sonnet | E1-E4 하드 임계값 검증 |
|
|
175
|
+
|
|
176
|
+
### crew-dev
|
|
177
|
+
| 에이전트 | 모델 | 역할 |
|
|
178
|
+
|----------|------|------|
|
|
179
|
+
| Dev | Opus | 코드 구현 + 자체 검증 |
|
|
180
|
+
| CodeReviewer | Opus | 코드 품질 + 가드레일 위반 판정 |
|
|
181
|
+
| QA | Sonnet | 실행 검증 (빌드/테스트/E2E) |
|
|
182
|
+
|
|
183
|
+
### 공유 서브에이전트
|
|
184
|
+
| 에이전트 | 모델 | 역할 |
|
|
185
|
+
|----------|------|------|
|
|
186
|
+
| Explorer | Haiku | 코드베이스 탐색 (병렬, Read-only) |
|
|
187
|
+
| Researcher | Sonnet | 외부 정보 조사 (필요시만, Read-only) |
|