refactor: v2.2.195-201 — slashRouter god-file 해체 (–95%) + 인프라 5개 추출
아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈.
기능 변경 없음, 순수 구조 정리.
**slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)**
**agent.ts: 1,617 → 1,551줄 (–66, –4%)**
v2.2.195: eventSourcedStore + SystemPromptBlock registry
- createEventStore<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
- _turnCtx 5 named string field → 1 Map<string, string> (새 verification block 추가 25곳→1곳)
- buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join
v2.2.196: trackers cluster split
- src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES)
- src/features/teamops/handlers/trackers.ts (runway/customers/hire)
- src/features/teamops/handlers/index.ts (barrel)
- extension.ts 에 side-effect import (순환 import 회피)
v2.2.197: mtimeFileCache + PostAnswerHook registry
- src/lib/mtimeFileCache.ts — createMtimeFileCache<T>(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화)
- src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop
- agent.ts –66줄
v2.2.198: dashboards cluster split
- src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly)
v2.2.199: coordination + communication clusters split
- src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup)
- src/features/teamops/handlers/communication.ts (draft/feedback)
- callLmSynthesis export 노출 (communication 이 사용)
- 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용)
v2.2.200: system cluster split
- src/features/system/handlers.ts (memory/glossary/help)
v2.2.201: datacollect cluster split + LLM 인프라 추출
- src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet)
- src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy)
- slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy
**최종 slashRouter (201줄):**
- REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand
- handleSlashCommand (dispatcher + 에러 처리)
- Webview interface + chunk helper
- getRecentSlashCommands ring buffer (actionability scoring 용)
**미래 부담 감소 metrics:**
- 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call
- 새 verification block: 5곳 편집 → 1 set call
- 새 event store: 60줄 boilerplate → createEventStore 한 줄
- 새 post-answer hook: 3 step → 1 push
- 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬).
|
||||
*
|
||||
* v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers +
|
||||
* hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공.
|
||||
*
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek,
|
||||
* _morningActions 등) 는 이 파일 안에.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||||
import {
|
||||
fmtKrw as _fmtKrw, daysUntil as _daysUntil,
|
||||
parseTaskOwner, stageEmoji as _stageEmoji,
|
||||
STAGE_ORDER, TERMINAL_STAGES,
|
||||
} from './_shared';
|
||||
import { listTasks } from '../../calendar';
|
||||
import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
|
||||
import {
|
||||
computeRunwayStatus, readRunway, type RunwayEntry,
|
||||
} from '../../runway/runwayStore';
|
||||
import {
|
||||
computeCustomerStates, readEvents as readCustomerEvents,
|
||||
type CustomerEvent, type CustomerState,
|
||||
} from '../../customers/customersStore';
|
||||
import {
|
||||
computeCandidateStates, readHireEvents,
|
||||
type HireEvent, type CandidateState,
|
||||
} from '../../hire/hireStore';
|
||||
|
||||
// ─── /morning — 매일 아침 통합 대시보드 ──────────────────────────────────
|
||||
|
||||
async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase();
|
||||
const brief = mode === 'brief' || mode === 'short';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`);
|
||||
|
||||
const runway = computeRunwayStatus();
|
||||
const customerStates = computeCustomerStates();
|
||||
const customers = Array.from(customerStates.values());
|
||||
const candidateStates = computeCandidateStates();
|
||||
const candidates = Array.from(candidateStates.values());
|
||||
|
||||
let tasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) tasks = res.tasks;
|
||||
else tasksError = res.error;
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const urgent: string[] = [];
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
|
||||
urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`);
|
||||
} else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) {
|
||||
urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`);
|
||||
}
|
||||
const atRiskCustomers = customers.filter((c) => c.status === 'at-risk');
|
||||
const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0);
|
||||
if (atRiskCustomers.length > 0) {
|
||||
urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`);
|
||||
}
|
||||
const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
|
||||
if (upcomingRenewals.length > 0) urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`);
|
||||
const overdueTasks = tasks.filter((t) => t.due && t.due < today);
|
||||
if (overdueTasks.length > 0) urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`);
|
||||
const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||||
const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
|
||||
if (stalled.length > 0) urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`);
|
||||
|
||||
if (urgent.length === 0) chunk(view, '\n## ✅ 긴급 알림 없음\n');
|
||||
else {
|
||||
chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`);
|
||||
for (const u of urgent) chunk(view, `- ${u}\n`);
|
||||
}
|
||||
|
||||
if (brief) {
|
||||
chunk(view, '\n## 📋 오늘의 액션 (top 3)\n');
|
||||
const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
|
||||
for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, '\n## 💰 재무\n');
|
||||
if (runway.latestCash === null) {
|
||||
chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n');
|
||||
} else {
|
||||
chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`);
|
||||
if (runway.effectiveBurn !== null) {
|
||||
chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`);
|
||||
}
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) {
|
||||
const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢';
|
||||
chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`);
|
||||
} else if (runway.runwayMonths !== null) {
|
||||
chunk(view, '- 런웨이 ♾️ 흑자 운영\n');
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 📒 고객\n');
|
||||
if (customers.length === 0) {
|
||||
chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n');
|
||||
} else {
|
||||
const active = customers.filter((c) => c.status === 'active');
|
||||
const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0);
|
||||
chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`);
|
||||
chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`);
|
||||
if (upcomingRenewals.length > 0) {
|
||||
for (const { c, days } of upcomingRenewals.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 3 ? '🔴' : '🟡';
|
||||
chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 👥 팀\n');
|
||||
if (tasksError) {
|
||||
chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
} else if (tasks.length === 0) {
|
||||
chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n');
|
||||
} else {
|
||||
const memberOverdue = new Map<string, number>();
|
||||
const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
let weekCount = 0;
|
||||
for (const t of tasks) {
|
||||
const { owner } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1);
|
||||
if (t.due && t.due >= today && t.due <= weekLater) weekCount++;
|
||||
}
|
||||
chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`);
|
||||
if (memberOverdue.size > 0) {
|
||||
const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
|
||||
for (const [member, n] of ranked) chunk(view, ` - **@${member}** 지연 ${n}건\n`);
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 🎯 채용\n');
|
||||
if (candidates.length === 0) {
|
||||
chunk(view, '- _데이터 없음_\n');
|
||||
} else {
|
||||
const hired = candidates.filter((c) => c.stage === 'hired').length;
|
||||
chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`);
|
||||
const stageCount = new Map<string, number>();
|
||||
for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1);
|
||||
const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50));
|
||||
if (stages.length > 0) {
|
||||
const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`);
|
||||
chunk(view, ` - ${parts.join(' · ')}\n`);
|
||||
}
|
||||
if (stalled.length > 0) {
|
||||
chunk(view, `- ⏰ 정체 ${stalled.length}명:\n`);
|
||||
for (const c of stalled.slice(0, 3)) {
|
||||
const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, ` - ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 📋 오늘의 액션\n');
|
||||
const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
|
||||
if (actions.length === 0) {
|
||||
chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n');
|
||||
} else {
|
||||
for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _morningActions(
|
||||
runway: ReturnType<typeof computeRunwayStatus>,
|
||||
customers: CustomerState[],
|
||||
upcomingRenewals: Array<{ c: CustomerState; days: number | null }>,
|
||||
overdueTasks: any[],
|
||||
stalled: CandidateState[],
|
||||
candidates: CandidateState[],
|
||||
): string[] {
|
||||
const actions: string[] = [];
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
|
||||
actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`);
|
||||
}
|
||||
const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr);
|
||||
if (atRisk.length > 0) {
|
||||
const top = atRisk[0];
|
||||
actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`);
|
||||
}
|
||||
if (upcomingRenewals.length > 0) {
|
||||
const next = upcomingRenewals[0];
|
||||
actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`);
|
||||
}
|
||||
if (overdueTasks.length >= 5) {
|
||||
actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`);
|
||||
} else if (overdueTasks.length > 0) {
|
||||
actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`);
|
||||
}
|
||||
if (stalled.length > 0) {
|
||||
const next = stalled[0];
|
||||
const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`);
|
||||
}
|
||||
const inboxCount = candidates.filter((c) => c.stage === 'inbox').length;
|
||||
if (inboxCount >= 5) actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`);
|
||||
return actions;
|
||||
}
|
||||
|
||||
// ─── /evening — 하루 마무리 카드 ─────────────────────────────────────────
|
||||
|
||||
async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const dayStartMs = Date.parse(today + 'T00:00:00');
|
||||
|
||||
chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`);
|
||||
|
||||
let completedTasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: true, maxResults: 300 });
|
||||
if (res.ok) {
|
||||
completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs);
|
||||
} else { tasksError = res.error; }
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
|
||||
chunk(view, '\n## ✅ 오늘의 진척\n');
|
||||
const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0;
|
||||
if (progressEmpty) {
|
||||
chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n');
|
||||
if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
} else {
|
||||
if (completedTasks.length > 0) {
|
||||
chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`);
|
||||
const byOwner = new Map<string, any[]>();
|
||||
for (const t of completedTasks) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
if (!byOwner.has(k)) byOwner.set(k, []);
|
||||
byOwner.get(k)!.push({ title: displayTitle });
|
||||
}
|
||||
const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length);
|
||||
for (const [owner, list] of ranked) {
|
||||
chunk(view, `- **@${owner}** (${list.length}건)\n`);
|
||||
for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`);
|
||||
if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`);
|
||||
}
|
||||
}
|
||||
if (customerEvents.length > 0) {
|
||||
chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`);
|
||||
for (const e of customerEvents.slice(0, 10)) {
|
||||
const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝';
|
||||
const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update'
|
||||
? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '')
|
||||
: (e.memo ? ` — ${e.memo.slice(0, 60)}` : '');
|
||||
chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`);
|
||||
}
|
||||
}
|
||||
if (hireEvents.length > 0) {
|
||||
chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`);
|
||||
for (const e of hireEvents.slice(0, 10)) {
|
||||
const stageNote = e.stage ? ` → ${e.stage}` : '';
|
||||
const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : '';
|
||||
chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`);
|
||||
}
|
||||
}
|
||||
if (runwayToday.length > 0) {
|
||||
chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`);
|
||||
for (const e of runwayToday) {
|
||||
const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
|
||||
chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 🌅 내일 준비\n');
|
||||
let tomorrowTasks: any[] = [];
|
||||
if (context && !tasksError) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (tomorrowTasks.length > 0) {
|
||||
chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`);
|
||||
for (const t of tomorrowTasks.slice(0, 8)) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const customers = Array.from(computeCustomerStates().values());
|
||||
const upcomingRenewals = customers.filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
|
||||
if (upcomingRenewals.length > 0) {
|
||||
chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`);
|
||||
for (const { c, days } of upcomingRenewals.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 3 ? '🔴' : '🟡';
|
||||
chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const stalled = Array.from(computeCandidateStates().values())
|
||||
.filter((c) => !TERMINAL_STAGES.has(c.stage))
|
||||
.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
|
||||
if (stalled.length > 0) {
|
||||
chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
|
||||
for (const c of stalled.slice(0, 3)) {
|
||||
const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) {
|
||||
chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n');
|
||||
}
|
||||
|
||||
const reflections = [
|
||||
'오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?',
|
||||
'내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.',
|
||||
'오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?',
|
||||
'시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?',
|
||||
'오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?',
|
||||
'에너지가 가장 좋았던 30분은 무엇을 하던 때였나?',
|
||||
'오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?',
|
||||
'리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?',
|
||||
];
|
||||
const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length;
|
||||
chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`);
|
||||
chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /cohort — MoM 추세 분석 ─────────────────────────────────────────────
|
||||
|
||||
interface MonthlyBucket {
|
||||
yearMonth: string;
|
||||
newCustomers: number;
|
||||
churnedCustomers: number;
|
||||
renewals: number;
|
||||
mrrDelta: number;
|
||||
expenseTotal: number;
|
||||
revenueTotal: number;
|
||||
cashSnapshots: number[];
|
||||
}
|
||||
|
||||
function _yearMonth(iso: string): string {
|
||||
return (iso || '').slice(0, 7);
|
||||
}
|
||||
|
||||
function _buildMonthlyBuckets(monthsBack: number): Map<string, MonthlyBucket> {
|
||||
const map = new Map<string, MonthlyBucket>();
|
||||
const now = new Date();
|
||||
for (let i = monthsBack - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const ym = d.toISOString().slice(0, 7);
|
||||
map.set(ym, {
|
||||
yearMonth: ym, newCustomers: 0, churnedCustomers: 0, renewals: 0,
|
||||
mrrDelta: 0, expenseTotal: 0, revenueTotal: 0, cashSnapshots: [],
|
||||
});
|
||||
}
|
||||
for (const e of readCustomerEvents()) {
|
||||
const ym = _yearMonth(e.timestamp);
|
||||
const b = map.get(ym);
|
||||
if (!b) continue;
|
||||
if (e.type === 'add') {
|
||||
b.newCustomers++;
|
||||
if (e.mrr) b.mrrDelta += e.mrr;
|
||||
} else if (e.type === 'churn') b.churnedCustomers++;
|
||||
else if (e.type === 'renew') b.renewals++;
|
||||
}
|
||||
for (const e of readRunway()) {
|
||||
const ym = _yearMonth(e.timestamp);
|
||||
const b = map.get(ym);
|
||||
if (!b) continue;
|
||||
if (e.type === 'expense') b.expenseTotal += e.amount;
|
||||
else if (e.type === 'revenue') b.revenueTotal += e.amount;
|
||||
else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function _cohortDashboard(view: any, monthsBack: number): void {
|
||||
const buckets = _buildMonthlyBuckets(monthsBack);
|
||||
if (buckets.size === 0) {
|
||||
chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n');
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(buckets.values());
|
||||
chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`);
|
||||
|
||||
chunk(view, '\n## 고객 & MRR 추이\n');
|
||||
chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n');
|
||||
chunk(view, '|---|---:|---:|---:|---:|\n');
|
||||
for (const r of rows) {
|
||||
chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`);
|
||||
}
|
||||
const totNew = rows.reduce((s, r) => s + r.newCustomers, 0);
|
||||
const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0);
|
||||
const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0);
|
||||
chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`);
|
||||
chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`);
|
||||
const avgNew = totNew / monthsBack;
|
||||
const avgChurn = totChurn / monthsBack;
|
||||
if (avgNew > 0 || avgChurn > 0) {
|
||||
chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`);
|
||||
if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`);
|
||||
else chunk(view, '\n');
|
||||
}
|
||||
|
||||
chunk(view, '\n## 재무 추이\n');
|
||||
chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n');
|
||||
chunk(view, '|---|---:|---:|---:|---:|\n');
|
||||
for (const r of rows) {
|
||||
const netBurn = r.expenseTotal - r.revenueTotal;
|
||||
const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null;
|
||||
const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-';
|
||||
chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`);
|
||||
}
|
||||
const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0);
|
||||
const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0);
|
||||
const totBurn = totExp - totRev;
|
||||
const avgBurn = totBurn / monthsBack;
|
||||
chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`);
|
||||
chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`);
|
||||
|
||||
chunk(view, '\n## 💡 인사이트\n');
|
||||
const insights: string[] = [];
|
||||
if (monthsBack >= 6) {
|
||||
const recent3 = rows.slice(-3);
|
||||
const prior3 = rows.slice(-6, -3);
|
||||
const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0);
|
||||
const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0);
|
||||
if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)');
|
||||
else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)');
|
||||
const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
|
||||
const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
|
||||
if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장');
|
||||
}
|
||||
if (avgBurn > 0 && totRev > 0) {
|
||||
const coverage = totRev / totExp;
|
||||
if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`);
|
||||
else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`);
|
||||
}
|
||||
if (insights.length === 0) chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n');
|
||||
else for (const i of insights) chunk(view, `- ${i}\n`);
|
||||
|
||||
chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n');
|
||||
}
|
||||
|
||||
async function runCohort(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
if (trimmed === 'help' || trimmed === '?') {
|
||||
chunk(view, [
|
||||
'\n📈 **/cohort — MoM 추세 분석**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/cohort` — 최근 6개월 추세 (기본)',
|
||||
' `/cohort yearly` — 최근 12개월',
|
||||
' `/cohort <N>` — 최근 N개월 (1~24)',
|
||||
'',
|
||||
'데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.',
|
||||
'표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
let monthsBack = 6;
|
||||
if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12;
|
||||
else if (/^\d+$/.test(trimmed)) monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10)));
|
||||
_cohortDashboard(view, monthsBack);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ────────────────────────────
|
||||
|
||||
function _isoWeek(d: Date): { year: number; week: number; label: string } {
|
||||
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = (target.getUTCDay() + 6) % 7;
|
||||
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
||||
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
||||
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||
const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000));
|
||||
return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` };
|
||||
}
|
||||
|
||||
interface WeeklyWindow {
|
||||
startIso: string;
|
||||
endIso: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function _thisWeekWindow(now: Date = new Date()): WeeklyWindow {
|
||||
const dayOfWeek = now.getDay();
|
||||
const daysFromMonday = (dayOfWeek + 6) % 7;
|
||||
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday);
|
||||
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59);
|
||||
const { label: yw } = _isoWeek(monday);
|
||||
const startIso = monday.toISOString().slice(0, 10);
|
||||
const endIso = sunday.toISOString().slice(0, 10);
|
||||
const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`;
|
||||
const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`;
|
||||
return { startIso, endIso, startMs: monday.getTime(), endMs: sunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
|
||||
}
|
||||
|
||||
function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow {
|
||||
const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000);
|
||||
const priorSunday = new Date(thisWeek.startMs - 1000);
|
||||
const { label: yw } = _isoWeek(priorMonday);
|
||||
const startIso = priorMonday.toISOString().slice(0, 10);
|
||||
const endIso = priorSunday.toISOString().slice(0, 10);
|
||||
const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`;
|
||||
const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`;
|
||||
return { startIso, endIso, startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
|
||||
}
|
||||
|
||||
interface WeeklyAggregate {
|
||||
taskCompleted: number;
|
||||
taskByOwner: Map<string, number>;
|
||||
customerEvents: number;
|
||||
customerNewCount: number;
|
||||
customerRenewCount: number;
|
||||
customerRiskCount: number;
|
||||
customerChurnCount: number;
|
||||
customerNewMrr: number;
|
||||
hireEvents: number;
|
||||
hireMoved: number;
|
||||
hireAdded: number;
|
||||
hireHired: number;
|
||||
runwayExpense: number;
|
||||
runwayRevenue: number;
|
||||
runwayLastCash: number | null;
|
||||
runwayFirstCash: number | null;
|
||||
adrCount: number;
|
||||
}
|
||||
|
||||
function _aggregateWeek(
|
||||
win: WeeklyWindow,
|
||||
completedTasks: any[],
|
||||
cevs: CustomerEvent[],
|
||||
hevs: HireEvent[],
|
||||
rs: RunwayEntry[],
|
||||
adrs: { date: string; title: string }[],
|
||||
): WeeklyAggregate {
|
||||
const agg: WeeklyAggregate = {
|
||||
taskCompleted: 0, taskByOwner: new Map(),
|
||||
customerEvents: 0, customerNewCount: 0, customerRenewCount: 0,
|
||||
customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0,
|
||||
hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0,
|
||||
runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null,
|
||||
adrCount: 0,
|
||||
};
|
||||
for (const t of completedTasks) {
|
||||
if (!t.completed) continue;
|
||||
const ms = Date.parse(t.completed);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.taskCompleted++;
|
||||
const { owner } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1);
|
||||
}
|
||||
for (const e of cevs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.customerEvents++;
|
||||
if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; }
|
||||
else if (e.type === 'renew') agg.customerRenewCount++;
|
||||
else if (e.type === 'risk') agg.customerRiskCount++;
|
||||
else if (e.type === 'churn') agg.customerChurnCount++;
|
||||
}
|
||||
for (const e of hevs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.hireEvents++;
|
||||
if (e.type === 'add') agg.hireAdded++;
|
||||
else if (e.type === 'stage') agg.hireMoved++;
|
||||
else if (e.type === 'hire') agg.hireHired++;
|
||||
}
|
||||
const cashInWin = rs
|
||||
.filter((r) => r.type === 'snapshot')
|
||||
.filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; })
|
||||
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
if (cashInWin.length > 0) {
|
||||
agg.runwayFirstCash = cashInWin[0].amount;
|
||||
agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount;
|
||||
}
|
||||
for (const e of rs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
if (e.type === 'expense') agg.runwayExpense += e.amount;
|
||||
else if (e.type === 'revenue') agg.runwayRevenue += e.amount;
|
||||
}
|
||||
for (const a of adrs) {
|
||||
if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++;
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
function _deltaSymbol(now: number, prev: number): string {
|
||||
if (prev === 0 && now === 0) return '→';
|
||||
if (prev === 0) return `↑${now}`;
|
||||
const diff = now - prev;
|
||||
if (diff > 0) return `↑${diff}`;
|
||||
if (diff < 0) return `↓${Math.abs(diff)}`;
|
||||
return '→';
|
||||
}
|
||||
|
||||
async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
if (trimmed === 'help' || trimmed === '?') {
|
||||
chunk(view, [
|
||||
'\n📅 **/weekly — 주간 리뷰 카드 (대표용)**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비',
|
||||
'',
|
||||
'`/standup weekly` 와 차이:',
|
||||
'- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)',
|
||||
'- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)',
|
||||
'',
|
||||
'데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
const thisWeek = _thisWeekWindow();
|
||||
const priorWeek = _priorWeekWindow(thisWeek);
|
||||
chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`);
|
||||
|
||||
let completedTasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: true, maxResults: 500 });
|
||||
if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed');
|
||||
else tasksError = res.error;
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const cevs = readCustomerEvents();
|
||||
const hevs = readHireEvents();
|
||||
const rs = readRunway();
|
||||
|
||||
const adrs: { date: string; title: string }[] = [];
|
||||
try {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (folders && folders.length > 0 && context) {
|
||||
const cs = new ChronicleProjectStore(context);
|
||||
const projects = cs.getAll();
|
||||
for (const p of projects) {
|
||||
const decisionsDir = path.join(p.recordRoot, 'decisions');
|
||||
if (!fs.existsSync(decisionsDir)) continue;
|
||||
const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md'));
|
||||
for (const f of files) {
|
||||
try {
|
||||
const full = path.join(decisionsDir, f);
|
||||
const stat = fs.statSync(full);
|
||||
const d = new Date(stat.mtimeMs).toISOString().slice(0, 10);
|
||||
const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' ');
|
||||
adrs.push({ date: d, title });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs);
|
||||
const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs);
|
||||
|
||||
chunk(view, '\n## ✅ 이번 주 진척\n');
|
||||
if (tasksError) chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
else if (aggNow.taskCompleted === 0) chunk(view, '- _완료된 작업 없음._\n');
|
||||
else {
|
||||
chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`);
|
||||
const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [owner, n] of ranked) {
|
||||
const prev = aggPrev.taskByOwner.get(owner) || 0;
|
||||
chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (aggNow.customerEvents > 0) {
|
||||
chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`);
|
||||
if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`);
|
||||
if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`);
|
||||
if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`);
|
||||
if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`);
|
||||
}
|
||||
|
||||
if (aggNow.hireEvents > 0) {
|
||||
chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`);
|
||||
if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`);
|
||||
if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`);
|
||||
if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`);
|
||||
}
|
||||
|
||||
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) {
|
||||
chunk(view, '\n### 💰 재무\n');
|
||||
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) {
|
||||
const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue;
|
||||
chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`);
|
||||
}
|
||||
if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) {
|
||||
const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash;
|
||||
chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`);
|
||||
} else if (aggNow.runwayLastCash !== null) {
|
||||
chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (aggNow.adrCount > 0) {
|
||||
chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`);
|
||||
const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5);
|
||||
for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`);
|
||||
}
|
||||
|
||||
chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`);
|
||||
chunk(view, `- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`);
|
||||
chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`);
|
||||
chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`);
|
||||
const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue;
|
||||
const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue;
|
||||
if (burnNow !== 0 || burnPrev !== 0) {
|
||||
const diff = burnNow - burnPrev;
|
||||
const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
|
||||
chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`);
|
||||
}
|
||||
chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`);
|
||||
|
||||
chunk(view, '\n## 🌅 다음 주 준비\n');
|
||||
const customerStates = computeCustomerStates();
|
||||
const upcoming = Array.from(customerStates.values())
|
||||
.filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 14)
|
||||
.sort((a, b) => (a.days as number) - (b.days as number));
|
||||
if (upcoming.length > 0) {
|
||||
chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`);
|
||||
for (const { c, days } of upcoming.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 7 ? '🔴' : '🟡';
|
||||
chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
|
||||
let nextWeekDue = 0;
|
||||
if (!tasksError && context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) {
|
||||
const startNext = new Date(thisWeek.endMs + 1000);
|
||||
const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000);
|
||||
const startIso = startNext.toISOString().slice(0, 10);
|
||||
const endIso = endNext.toISOString().slice(0, 10);
|
||||
nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`);
|
||||
|
||||
const stalled = Array.from(computeCandidateStates().values())
|
||||
.filter((c) => !TERMINAL_STAGES.has(c.stage))
|
||||
.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
|
||||
if (stalled.length > 0) {
|
||||
chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
|
||||
for (const c of stalled.slice(0, 3)) {
|
||||
const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const reflections = [
|
||||
'이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?',
|
||||
'이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?',
|
||||
'이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.',
|
||||
'이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?',
|
||||
'이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?',
|
||||
'이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?',
|
||||
];
|
||||
const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7));
|
||||
const idx = weekKey % reflections.length;
|
||||
chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`);
|
||||
chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning });
|
||||
registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening });
|
||||
registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });
|
||||
registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });
|
||||
Reference in New Issue
Block a user