@jjlabsio/claude-crew 0.1.15 → 0.1.17

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.
@@ -11,7 +11,7 @@
11
11
  "name": "claude-crew",
12
12
  "source": "./",
13
13
  "description": "오케스트레이터 + PM, 플래너, 개발, QA, 마케팅 에이전트 팀으로 단일 제품의 개발과 마케팅을 통합 관리",
14
- "version": "0.1.15",
14
+ "version": "0.1.17",
15
15
  "author": {
16
16
  "name": "Jaejin Song",
17
17
  "email": "wowlxx28@gmail.com"
@@ -28,5 +28,5 @@
28
28
  "category": "workflow"
29
29
  }
30
30
  ],
31
- "version": "0.1.15"
31
+ "version": "0.1.17"
32
32
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-crew",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": {
6
6
  "name": "Jaejin Song",
package/hud/index.mjs CHANGED
@@ -198,10 +198,32 @@ function getRateLimits(stdin) {
198
198
  const n = typeof v === 'number' ? v : parseFloat(v);
199
199
  return isNaN(n) ? null : Math.round(Math.min(Math.max(n, 0), 100));
200
200
  };
201
+ const parseResetAt = (v) => {
202
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return null;
203
+ return new Date(v * 1000);
204
+ };
201
205
  const fiveHour = parse(rl.five_hour?.used_percentage);
202
206
  const sevenDay = parse(rl.seven_day?.used_percentage);
207
+ const fiveHourResetAt = parseResetAt(rl.five_hour?.resets_at);
208
+ const sevenDayResetAt = parseResetAt(rl.seven_day?.resets_at);
203
209
  if (fiveHour == null && sevenDay == null) return null;
204
- return { fiveHour, sevenDay };
210
+ return { fiveHour, sevenDay, fiveHourResetAt, sevenDayResetAt };
211
+ }
212
+
213
+ function formatResetTime(resetAt) {
214
+ if (!resetAt) return '';
215
+ const diffMs = resetAt.getTime() - Date.now();
216
+ if (diffMs <= 0) return '';
217
+ const diffMins = Math.ceil(diffMs / 60000);
218
+ if (diffMins < 60) return `${diffMins}m`;
219
+ const hours = Math.floor(diffMins / 60);
220
+ const mins = diffMins % 60;
221
+ if (hours >= 24) {
222
+ const days = Math.floor(hours / 24);
223
+ const remHours = hours % 24;
224
+ return remHours > 0 ? `${days}d ${remHours}h` : `${days}d`;
225
+ }
226
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
205
227
  }
206
228
 
207
229
  function colorizeRateLimits(limits) {
@@ -210,9 +232,14 @@ function colorizeRateLimits(limits) {
210
232
  const color = pct >= 85 ? red : pct >= 70 ? yellow : green;
211
233
  return color(`${pct}%`);
212
234
  };
235
+ const formatWindow = (label, pct, resetAt) => {
236
+ const reset = formatResetTime(resetAt);
237
+ const resetStr = reset ? ` ${dim(`(${reset})`)}` : '';
238
+ return `${label}:${colorize(pct)}${resetStr}`;
239
+ };
213
240
  const parts = [];
214
- if (limits.fiveHour != null) parts.push(`5h:${colorize(limits.fiveHour)}`);
215
- if (limits.sevenDay != null) parts.push(`weekly:${colorize(limits.sevenDay)}`);
241
+ if (limits.fiveHour != null) parts.push(formatWindow('5h', limits.fiveHour, limits.fiveHourResetAt));
242
+ if (limits.sevenDay != null) parts.push(formatWindow('weekly', limits.sevenDay, limits.sevenDayResetAt));
216
243
  return parts.join(' ');
217
244
  }
218
245
 
@@ -220,7 +247,7 @@ function colorizeRateLimits(limits) {
220
247
  // Transcript parsing (agents + skills)
221
248
  // ---------------------------------------------------------------------------
222
249
  function parseTranscript(transcriptPath) {
223
- const result = { agents: [], lastSkill: null, sessionStart: null };
250
+ const result = { agents: [], todos: [], lastSkill: null, sessionStart: null };
224
251
  if (!transcriptPath || !existsSync(transcriptPath)) return result;
225
252
 
226
253
  const agentModels = loadAgentModels();
@@ -229,55 +256,120 @@ function parseTranscript(transcriptPath) {
229
256
  const content = readFileSync(transcriptPath, 'utf-8');
230
257
  const lines = content.split('\n').filter(Boolean);
231
258
 
232
- // Map of tool_use_id -> agent info
233
259
  const agentMap = new Map();
260
+ const latestTodos = [];
261
+ const taskIdToIndex = new Map();
234
262
  let lastTimestamp = null;
235
263
 
236
264
  for (const line of lines) {
237
265
  let entry;
238
266
  try { entry = JSON.parse(line); } catch { continue; }
239
267
 
240
- // Track last known timestamp
241
268
  if (entry.timestamp) {
242
269
  lastTimestamp = entry.timestamp;
243
270
  }
244
271
 
245
- // Session start time
246
272
  if (!result.sessionStart && entry.timestamp) {
247
273
  result.sessionStart = new Date(entry.timestamp);
248
274
  }
249
275
 
250
- // Track agents
276
+ // Process tool_use blocks from assistant messages
251
277
  if (entry.type === 'tool_use' || entry.type === 'assistant') {
252
- const content = entry.message?.content;
253
- if (Array.isArray(content)) {
254
- for (const block of content) {
255
- if (block.type === 'tool_use') {
256
- // Agent start
257
- if (block.name === 'Agent' || block.name === 'proxy_Agent') {
258
- const id = block.id;
259
- if (id) {
260
- const input = block.input || {};
261
- const agentType = input.subagent_type || input.type || 'general';
262
- const rawType = agentType.replace(/^claude-crew:/, '');
263
- const model = input.model || agentModels[rawType] || null;
264
- const description = input.description || input.prompt?.slice(0, 50) || '';
265
- const ts = entry.timestamp || lastTimestamp;
266
- agentMap.set(id, {
267
- id,
268
- type: agentType,
269
- model,
270
- description,
271
- startTime: ts ? new Date(ts) : null,
272
- status: 'running',
273
- });
278
+ const blocks = entry.message?.content;
279
+ if (Array.isArray(blocks)) {
280
+ for (const block of blocks) {
281
+ if (block.type !== 'tool_use') continue;
282
+
283
+ // Agent start
284
+ if (block.name === 'Agent' || block.name === 'proxy_Agent') {
285
+ const id = block.id;
286
+ if (id) {
287
+ const input = block.input || {};
288
+ const agentType = input.subagent_type || input.type || 'general';
289
+ const rawType = agentType.replace(/^claude-crew:/, '');
290
+ const model = input.model || agentModels[rawType] || null;
291
+ const description = input.description || input.prompt?.slice(0, 50) || '';
292
+ const ts = entry.timestamp || lastTimestamp;
293
+ agentMap.set(id, {
294
+ id,
295
+ type: agentType,
296
+ model,
297
+ description,
298
+ startTime: ts ? new Date(ts) : null,
299
+ status: 'running',
300
+ });
301
+ }
302
+ }
303
+
304
+ // Skill invocation
305
+ if (block.name === 'Skill' || block.name === 'proxy_Skill') {
306
+ const skillName = block.input?.skill || block.input?.name;
307
+ if (skillName) {
308
+ result.lastSkill = skillName;
309
+ }
310
+ }
311
+
312
+ // TodoWrite — full replacement of todo list
313
+ if (block.name === 'TodoWrite') {
314
+ const input = block.input || {};
315
+ if (input.todos && Array.isArray(input.todos)) {
316
+ const contentToTaskIds = new Map();
317
+ for (const [taskId, idx] of taskIdToIndex) {
318
+ if (idx < latestTodos.length) {
319
+ const c = latestTodos[idx].content;
320
+ const ids = contentToTaskIds.get(c) ?? [];
321
+ ids.push(taskId);
322
+ contentToTaskIds.set(c, ids);
323
+ }
274
324
  }
325
+
326
+ latestTodos.length = 0;
327
+ taskIdToIndex.clear();
328
+ latestTodos.push(...input.todos);
329
+
330
+ for (let i = 0; i < latestTodos.length; i++) {
331
+ const ids = contentToTaskIds.get(latestTodos[i].content);
332
+ if (ids) {
333
+ for (const taskId of ids) {
334
+ taskIdToIndex.set(taskId, i);
335
+ }
336
+ contentToTaskIds.delete(latestTodos[i].content);
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ // TaskCreate — append a single task
343
+ if (block.name === 'TaskCreate') {
344
+ const input = block.input || {};
345
+ const subject = typeof input.subject === 'string' ? input.subject : '';
346
+ const description = typeof input.description === 'string' ? input.description : '';
347
+ const todoContent = subject || description || 'Untitled task';
348
+ const status = normalizeTaskStatus(input.status) ?? 'pending';
349
+ latestTodos.push({ content: todoContent, status });
350
+
351
+ const taskId = typeof input.taskId === 'string' || typeof input.taskId === 'number'
352
+ ? String(input.taskId)
353
+ : block.id;
354
+ if (taskId) {
355
+ taskIdToIndex.set(taskId, latestTodos.length - 1);
275
356
  }
276
- // Skill invocation
277
- if (block.name === 'Skill' || block.name === 'proxy_Skill') {
278
- const skillName = block.input?.skill || block.input?.name;
279
- if (skillName) {
280
- result.lastSkill = skillName;
357
+ }
358
+
359
+ // TaskUpdate update status/content of existing task
360
+ if (block.name === 'TaskUpdate') {
361
+ const input = block.input || {};
362
+ const index = resolveTaskIndex(input.taskId, taskIdToIndex, latestTodos);
363
+ if (index !== null) {
364
+ const status = normalizeTaskStatus(input.status);
365
+ if (status) {
366
+ latestTodos[index] = { ...latestTodos[index], status };
367
+ }
368
+ const subject = typeof input.subject === 'string' ? input.subject : '';
369
+ const description = typeof input.description === 'string' ? input.description : '';
370
+ const newContent = subject || description;
371
+ if (newContent) {
372
+ latestTodos[index] = { ...latestTodos[index], content: newContent };
281
373
  }
282
374
  }
283
375
  }
@@ -297,9 +389,9 @@ function parseTranscript(transcriptPath) {
297
389
  }
298
390
  }
299
391
  if (entry.type === 'user') {
300
- const content = entry.message?.content;
301
- if (Array.isArray(content)) {
302
- for (const block of content) {
392
+ const blocks = entry.message?.content;
393
+ if (Array.isArray(blocks)) {
394
+ for (const block of blocks) {
303
395
  if (block.type === 'tool_result') {
304
396
  const toolUseId = block.tool_use_id;
305
397
  if (toolUseId && agentMap.has(toolUseId)) {
@@ -307,6 +399,7 @@ function parseTranscript(transcriptPath) {
307
399
  agent.status = 'completed';
308
400
  const ts = entry.timestamp || lastTimestamp;
309
401
  if (ts) agent.endTime = new Date(ts);
402
+ }
310
403
  }
311
404
  }
312
405
  }
@@ -321,11 +414,46 @@ function parseTranscript(transcriptPath) {
321
414
  if (a.startTime && (now - a.startTime.getTime()) > STALE_THRESHOLD_MS) return false;
322
415
  return true;
323
416
  });
417
+ result.todos = [...latestTodos];
324
418
  } catch { /* ignore parse errors */ }
325
419
 
326
420
  return result;
327
421
  }
328
422
 
423
+ // ---------------------------------------------------------------------------
424
+ // Todo helpers
425
+ // ---------------------------------------------------------------------------
426
+ function normalizeTaskStatus(status) {
427
+ if (typeof status !== 'string') return null;
428
+ switch (status) {
429
+ case 'pending':
430
+ case 'not_started':
431
+ return 'pending';
432
+ case 'in_progress':
433
+ case 'running':
434
+ return 'in_progress';
435
+ case 'completed':
436
+ case 'complete':
437
+ case 'done':
438
+ return 'completed';
439
+ default:
440
+ return null;
441
+ }
442
+ }
443
+
444
+ function resolveTaskIndex(taskId, taskIdToIndex, latestTodos) {
445
+ if (typeof taskId === 'string' || typeof taskId === 'number') {
446
+ const key = String(taskId);
447
+ const mapped = taskIdToIndex.get(key);
448
+ if (typeof mapped === 'number') return mapped;
449
+ if (/^\d+$/.test(key)) {
450
+ const numericIndex = parseInt(key, 10) - 1;
451
+ if (numericIndex >= 0 && numericIndex < latestTodos.length) return numericIndex;
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+
329
457
  // ---------------------------------------------------------------------------
330
458
  // Agent model name (short)
331
459
  // ---------------------------------------------------------------------------
@@ -373,13 +501,13 @@ function renderAgentsMultiLine(agents, maxLines = 5) {
373
501
  const prefix = isLast ? '\u2514\u2500' : '\u251c\u2500';
374
502
 
375
503
  const rawType = a.type.includes(':') ? a.type.split(':').pop() : a.type;
376
- const name = rawType.padEnd(7);
377
- const model = shortModelName(a.model).padEnd(8);
378
- const duration = formatAgentDuration(a.startTime, a.endTime).padStart(6);
504
+ const name = rawType;
505
+ const model = `[${shortModelName(a.model)}]`;
506
+ const duration = formatAgentDuration(a.startTime, a.endTime);
379
507
  const desc = a.description.length > 40 ? a.description.slice(0, 37) + '...' : a.description;
380
508
 
381
509
  detailLines.push(
382
- `${dim(prefix)} ${cyan(name)} ${model}${dim(duration)} ${desc}`
510
+ `${dim(prefix)} ${cyan(name)} ${dim(model)} : ${desc} ${dim(`(${duration})`)}`
383
511
  );
384
512
  });
385
513
 
@@ -391,6 +519,29 @@ function renderAgentsMultiLine(agents, maxLines = 5) {
391
519
  return { headerPart, detailLines };
392
520
  }
393
521
 
522
+ // ---------------------------------------------------------------------------
523
+ // Todo progress rendering
524
+ // ---------------------------------------------------------------------------
525
+ function renderTodosLine(todos) {
526
+ if (!todos || todos.length === 0) return null;
527
+
528
+ const inProgress = todos.find(t => t.status === 'in_progress');
529
+ const completed = todos.filter(t => t.status === 'completed').length;
530
+ const total = todos.length;
531
+
532
+ if (!inProgress) {
533
+ if (completed === total && total > 0) {
534
+ return `${green('\u2713')} all done ${dim(`(${completed}/${total})`)}`;
535
+ }
536
+ return null;
537
+ }
538
+
539
+ const content = inProgress.content.length > 50
540
+ ? inProgress.content.slice(0, 47) + '...'
541
+ : inProgress.content;
542
+ return `${yellow('\u25b8')} ${content} ${dim(`(${completed}/${total})`)}`;
543
+ }
544
+
394
545
  // ---------------------------------------------------------------------------
395
546
  // Session duration
396
547
  // ---------------------------------------------------------------------------
@@ -483,11 +634,15 @@ async function main() {
483
634
  const rateLimitsStr = colorizeRateLimits(rateLimits);
484
635
  if (rateLimitsStr) midElements.push(rateLimitsStr);
485
636
 
637
+ // --- Todos line ---
638
+ const todosLine = renderTodosLine(transcript.todos);
639
+
486
640
  // --- Output ---
487
641
  const outputLines = [];
488
642
  outputLines.push(topElements.join(SEPARATOR));
489
643
  outputLines.push(midElements.join(SEPARATOR));
490
644
  outputLines.push(...detailLines);
645
+ if (todosLine) outputLines.push(todosLine);
491
646
 
492
647
  console.log(outputLines.filter(Boolean).join('\n'));
493
648
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlabsio/claude-crew",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "1인 SaaS 개발자를 위한 멀티 에이전트 오케스트레이션 — 개발, 마케팅, 일정을 한 대화에서 통합 관리",
5
5
  "author": "Jaejin Song <wowlxx28@gmail.com>",
6
6
  "license": "MIT",
@@ -69,18 +69,6 @@ async function main() {
69
69
  writeFileSync(localSettingsPath, JSON.stringify(localSettings, null, 2));
70
70
  }
71
71
 
72
- // --- Remove legacy global statusLine from ~/.claude/settings.json ---
73
- const globalSettingsPath = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
74
- if (existsSync(globalSettingsPath)) {
75
- try {
76
- const globalSettings = JSON.parse(readFileSync(globalSettingsPath, 'utf-8'));
77
- if (globalSettings.statusLine) {
78
- delete globalSettings.statusLine;
79
- writeFileSync(globalSettingsPath, JSON.stringify(globalSettings, null, 2));
80
- }
81
- } catch { /* ignore */ }
82
- }
83
-
84
72
  console.log(JSON.stringify({ continue: true }));
85
73
  } catch (e) {
86
74
  console.log(JSON.stringify({
@@ -229,7 +229,7 @@ contract.md, plan.md, brief.md, spec.md, dev-log.md는 읽지 않는다.
229
229
  6. 에러 처리 적절성
230
230
 
231
231
  ## 출력
232
- .crew/plans/{task-id}/review-report.md 작성하라.
232
+ 아래 형식으로 리뷰 결과를 텍스트로 반환하라. 파일을 직접 작성하지 않는다.
233
233
 
234
234
  ## 판정 규칙
235
235
  - 가드레일 위반 → critical
@@ -269,7 +269,7 @@ contract.md, brief.md, spec.md는 읽지 않는다.
269
269
  6. E2E / 통합 검증 — plan.md의 테스트 시나리오 기반
270
270
 
271
271
  ## 출력
272
- .crew/plans/{task-id}/qa-report.md 작성하라.
272
+ 아래 형식으로 검증 결과를 텍스트로 반환하라. 파일을 직접 작성하지 않는다.
273
273
 
274
274
  ## 판정 규칙
275
275
  - 항목 1-6 중 하나라도 FAIL → 전체 FAIL
@@ -281,6 +281,13 @@ contract.md, brief.md, spec.md는 읽지 않는다.
281
281
  - 코드를 수정하지 않는다. 검증만 한다.
282
282
  ```
283
283
 
284
+ **Phase 3 결과 저장 (오케스트레이터 직접)**:
285
+
286
+ CodeReviewer와 QA 에이전트는 read-only이므로 파일을 직접 작성하지 않는다.
287
+ 오케스트레이터가 각 에이전트의 반환 텍스트를 파일로 저장한다:
288
+ - CodeReviewer 결과 → `.crew/plans/{task-id}/review-report.md`
289
+ - QA 결과 → `.crew/plans/{task-id}/qa-report.md`
290
+
284
291
  **Phase 3 병렬 실행 방법**:
285
292
 
286
293
  오케스트레이터는 한 번의 메시지에서 두 개의 Agent tool 호출을 동시에 수행한다:
@@ -132,15 +132,20 @@ Agent(subagent_type="techlead", description="TechLead: {task-id} 사전 분석",
132
132
  Agent(subagent_type="researcher", description="외부 리서치: {리서치 대상}", prompt="...")
133
133
 
134
134
  ## 출력
135
- .crew/plans/{task-id}/analysis.md 작성하라.
135
+ 아래 필수 섹션을 포함한 분석 결과를 텍스트로 반환하라. 파일을 직접 작성하지 않는다.
136
136
 
137
- analysis.md 필수 섹션: 요구사항 보완, 코드베이스 맥락(관련 파일/기존 패턴/테스트 구조), 아키텍처 방향(권장+대안), 엣지 케이스/리스크, 가드레일(Must/Must NOT), 테스트 인프라(프레임워크/패턴/유무), 외부 리서치(해당 시).
137
+ 필수 섹션: 요구사항 보완, 코드베이스 맥락(관련 파일/기존 패턴/테스트 구조), 아키텍처 방향(권장+대안), 엣지 케이스/리스크, 가드레일(Must/Must NOT), 테스트 인프라(프레임워크/패턴/유무), 외부 리서치(해당 시).
138
138
 
139
139
  ## 규칙
140
140
  - 요구사항에 빈틈이 있으면 AskUserQuestion으로 유저에게 직접 질문하라.
141
141
  - 탐색(양)은 서브에이전트에게, 판단(질)은 직접.
142
142
  ```
143
143
 
144
+ **Step 3 결과 저장 (오케스트레이터 직접)**:
145
+
146
+ TechLead 에이전트는 read-only이므로 파일을 직접 작성하지 않는다.
147
+ 오케스트레이터가 TechLead의 반환 텍스트를 `.crew/plans/{task-id}/analysis.md`로 저장한다.
148
+
144
149
  **실패 조건**: analysis.md가 없거나 가드레일 섹션이 비어 있으면 즉시 에스컬레이션.
145
150
 
146
151
  ---
@@ -314,10 +319,15 @@ Explorer 서브에이전트를 호출하여 plan.md에서 참조하는 파일/
314
319
  Agent(subagent_type="explorer", description="코드 참조 확인: {파일 목록 요약}", prompt="plan.md에서 참조하는 다음 파일/모듈이 존재하는지 확인하라: {파일 목록}")
315
320
 
316
321
  ## 출력
317
- .crew/plans/{task-id}/review.md 작성하라.
318
- review.md 형식: 판정(PASS/FAIL), 항목별 결과(E1-E4 YES/NO + 근거), FAIL 상세(NO 항목의 문제+수정 방향), 근본 원인 분류(FAIL 시).
322
+ 아래 형식으로 검증 결과를 텍스트로 반환하라. 파일을 직접 작성하지 않는다.
323
+ 형식: 판정(PASS/FAIL), 항목별 결과(E1-E5 YES/NO + 근거), FAIL 상세(NO 항목의 문제+수정 방향), 근본 원인 분류(FAIL 시).
319
324
  ```
320
325
 
326
+ **Step 5 결과 저장 (오케스트레이터 직접)**:
327
+
328
+ PlanEvaluator 에이전트는 read-only이므로 파일을 직접 작성하지 않는다.
329
+ 오케스트레이터가 PlanEvaluator의 반환 텍스트를 `.crew/plans/{task-id}/review.md`로 저장한다.
330
+
321
331
  ---
322
332
 
323
333
  ### Step 6 — PASS 처리