7bec20620a
아키텍처 감사 결과 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>
705 lines
35 KiB
TypeScript
705 lines
35 KiB
TypeScript
/**
|
||
* TeamOps Trackers — /runway · /customers · /hire (event-sourced 트래커 3종).
|
||
*
|
||
* v2.2.196 에서 slashRouter.ts 에서 분리. 모두 `.astra/*.jsonl` event log 를
|
||
* 읽고 (createEventStore via 각 store 모듈) 대시보드 / 수정 명령 제공.
|
||
*
|
||
* 공통 헬퍼 (fmtKrw / parseAmount / daysUntil / stageEmoji / STAGE_ORDER /
|
||
* TERMINAL_STAGES) 는 `./_shared.ts` 에서.
|
||
*/
|
||
|
||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||
import {
|
||
fmtKrw, parseAmount, daysUntil,
|
||
stageEmoji, STAGE_ORDER, TERMINAL_STAGES,
|
||
} from './_shared';
|
||
import {
|
||
appendRunway, readRunway, getRunwayFilePath, computeRunwayStatus,
|
||
type RunwayEntry, type RunwayEntryType,
|
||
} from '../../runway/runwayStore';
|
||
import {
|
||
appendEvent as appendCustomerEvent, readEvents as readCustomerEvents,
|
||
getCustomersFilePath, customerIdFromName, computeCustomerStates,
|
||
type CustomerEvent, type CustomerState,
|
||
} from '../../customers/customersStore';
|
||
import {
|
||
appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName,
|
||
computeCandidateStates, type HireEvent, type CandidateState,
|
||
} from '../../hire/hireStore';
|
||
|
||
// ─── /runway ──────────────────────────────────────────────────────────────
|
||
|
||
function _runwayShowStatus(view: any): void {
|
||
const s = computeRunwayStatus();
|
||
chunk(view, '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**\n');
|
||
if (s.latestCash === null) {
|
||
chunk(view, '\nℹ️ 잔고 기록 없음. 시작: `/runway cash 5000만` (현재 통장 잔고 입력)\n');
|
||
return;
|
||
}
|
||
const cashDate = (s.latestCashAt || '').slice(0, 10);
|
||
chunk(view, `\n## 현재 현금\n- **${fmtKrw(s.latestCash)}원** _(기준: ${cashDate})_\n`);
|
||
|
||
chunk(view, '\n## 월 소진율 (burn)\n');
|
||
if (s.explicitBurn !== null) {
|
||
chunk(view, `- **${fmtKrw(s.explicitBurn)}원/월** _(수동 설정)_\n`);
|
||
} else if (s.computedBurn !== null) {
|
||
const ann = s.last30Days < 30 ? ` _(${s.last30Days}일 데이터 → 30일 환산)_` : ' _(최근 30일 실적)_';
|
||
chunk(view, `- **${fmtKrw(s.computedBurn)}원/월**${ann}\n`);
|
||
chunk(view, ` · 지출 ${fmtKrw(s.last30Expense)}원 − 수입 ${fmtKrw(s.last30Revenue)}원\n`);
|
||
} else {
|
||
chunk(view, '- _데이터 부족_ — `/runway burn 1500만` 또는 `/runway expense 300만 급여` 로 기록\n');
|
||
}
|
||
|
||
chunk(view, '\n## 런웨이\n');
|
||
if (s.runwayMonths === null) {
|
||
chunk(view, '- _계산 불가_ (잔고 또는 burn 미정)\n');
|
||
} else if (!Number.isFinite(s.runwayMonths)) {
|
||
chunk(view, '- ♾️ **흑자 운영** (지출 ≤ 수입)\n');
|
||
} else {
|
||
const m = s.runwayMonths;
|
||
const emoji = m < 3 ? '🔴' : m < 6 ? '🟡' : '🟢';
|
||
const months = m.toFixed(1);
|
||
const exitDate = new Date(Date.now() + m * 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||
chunk(view, `- ${emoji} **${months}개월** _(예상 소진: ${exitDate})_\n`);
|
||
if (m < 3) chunk(view, ' · ⚠️ **3개월 미만** — 즉시 자금 조달 또는 비용 절감 필요\n');
|
||
else if (m < 6) chunk(view, ' · ⚠️ **6개월 미만** — 자금 계획 점검 권장\n');
|
||
}
|
||
|
||
chunk(view, `\n_누적 ${s.totalEntries}건 기록. \`/runway log\` 로 전체 보기, \`/runway path\` 로 파일 위치._\n`);
|
||
}
|
||
|
||
function _runwayLog(view: any, limit: number): void {
|
||
const all = readRunway().slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, limit);
|
||
if (all.length === 0) { chunk(view, '\nℹ️ 기록 없음. `/runway cash 5000만` 으로 시작.\n'); return; }
|
||
chunk(view, `\n📒 **최근 ${all.length}건** (최신순)\n\n`);
|
||
const emoji: Record<RunwayEntryType, string> = {
|
||
snapshot: '💰', expense: '💸', revenue: '💵', burn: '🔥',
|
||
};
|
||
for (const e of all) {
|
||
const date = (e.timestamp || '').slice(0, 10);
|
||
const cat = e.category ? ` [${e.category}]` : '';
|
||
const memo = e.memo ? ` — ${e.memo}` : '';
|
||
const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
|
||
chunk(view, `- ${emoji[e.type]} \`${date}\` ${typeLabel}: ${fmtKrw(e.amount)}원${cat}${memo}\n`);
|
||
}
|
||
}
|
||
|
||
async function runRunway(arg: string, view: any): Promise<boolean> {
|
||
const trimmed = arg.trim();
|
||
if (!trimmed) { _runwayShowStatus(view); return true; }
|
||
|
||
const parts = trimmed.split(/\s+/);
|
||
const sub = parts[0].toLowerCase();
|
||
|
||
if (sub === 'help' || sub === '?') {
|
||
chunk(view, [
|
||
'\n💰 **/runway — 현금 / 월 소진율 / 런웨이**',
|
||
'',
|
||
'사용법:',
|
||
' `/runway` — 현재 상태 카드 (현금 / burn / 남은 개월수)',
|
||
' `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 기록',
|
||
' `/runway expense <금액> [메모]` — 지출 기록 (월 burn 자동 계산에 반영)',
|
||
' `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄)',
|
||
' `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선)',
|
||
' `/runway log [N]` — 최근 N건 기록 (기본 20)',
|
||
' `/runway path` — .jsonl 파일 경로',
|
||
'',
|
||
'금액 단위: `5000만` / `1.5억` / `300000` 모두 OK. 소수점·콤마 허용.',
|
||
'저장 위치: `<workspace>/.astra/runway.jsonl` (로컬 only, 외부 안 보냄).\n',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'path') {
|
||
const p = getRunwayFilePath();
|
||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return true; }
|
||
const count = readRunway().length;
|
||
chunk(view, `\n📂 \`${p}\`\n · 누적 ${count}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능.\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'log') {
|
||
const n = parts[1] ? parseInt(parts[1], 10) : 20;
|
||
_runwayLog(view, Number.isFinite(n) && n > 0 ? n : 20);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'cash' || sub === 'expense' || sub === 'revenue' || sub === 'burn') {
|
||
const amount = parseAmount(parts[1] || '');
|
||
if (amount === null) {
|
||
chunk(view, `\n❌ 금액 파싱 실패: "${parts[1] || ''}". 예: \`5000만\` / \`1.5억\` / \`300000\`\n`);
|
||
return true;
|
||
}
|
||
const memo = parts.slice(2).join(' ').trim() || undefined;
|
||
const typeMap: Record<string, RunwayEntryType> = { cash: 'snapshot', expense: 'expense', revenue: 'revenue', burn: 'burn' };
|
||
const entry: RunwayEntry = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
type: typeMap[sub],
|
||
amount,
|
||
currency: 'KRW',
|
||
memo,
|
||
};
|
||
const res = appendRunway(entry);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
const labels: Record<string, string> = { cash: '잔고 스냅샷', expense: '지출', revenue: '수입', burn: '월 burn 설정' };
|
||
chunk(view, `\n✅ ${labels[sub]} 기록: **${fmtKrw(amount)}원**${memo ? ` — ${memo}` : ''}\n`);
|
||
_runwayShowStatus(view);
|
||
return true;
|
||
}
|
||
|
||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/runway help\` 참조.\n`);
|
||
return true;
|
||
}
|
||
|
||
// ─── /customers ───────────────────────────────────────────────────────────
|
||
|
||
function _customersDashboard(view: any): void {
|
||
const states = computeCustomerStates();
|
||
const all = Array.from(states.values());
|
||
if (all.length === 0) {
|
||
chunk(view, '\n📒 **/customers — 고객사 / MRR / 갱신**\n\nℹ️ 등록된 고객 없음. 시작: `/customers add <이름> <MRR> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
|
||
return;
|
||
}
|
||
|
||
const active = all.filter((c) => c.status === 'active');
|
||
const atRisk = all.filter((c) => c.status === 'at-risk');
|
||
const churned = all.filter((c) => c.status === 'churned');
|
||
const totalMrr = active.reduce((sum, c) => sum + c.mrr, 0) + atRisk.reduce((sum, c) => sum + c.mrr, 0);
|
||
const riskMrr = atRisk.reduce((sum, c) => sum + c.mrr, 0);
|
||
|
||
chunk(view, '\n📒 **/customers — 고객사 대시보드**\n');
|
||
chunk(view, '\n## 요약\n');
|
||
chunk(view, `- **MRR**: ${fmtKrw(totalMrr)}원/월 _(연 ${fmtKrw(totalMrr * 12)}원)_\n`);
|
||
chunk(view, `- 활성 ${active.length}곳 · 위험 ${atRisk.length}곳 · 이탈 ${churned.length}곳\n`);
|
||
if (riskMrr > 0) chunk(view, `- ⚠️ **위험 MRR**: ${fmtKrw(riskMrr)}원/월 _(전체의 ${((riskMrr / totalMrr) * 100).toFixed(0)}%)_\n`);
|
||
|
||
const upcoming = [...active, ...atRisk]
|
||
.map((c) => ({ c, days: daysUntil(c.renewalAt) }))
|
||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 30)
|
||
.sort((a, b) => (a.days as number) - (b.days as number));
|
||
if (upcoming.length > 0) {
|
||
chunk(view, '\n## 🔔 30일 내 갱신\n');
|
||
for (const { c, days } of upcoming) {
|
||
const emoji = c.status === 'at-risk' ? '⚠️' : (days as number) <= 7 ? '🔴' : (days as number) <= 14 ? '🟡' : '🟢';
|
||
chunk(view, `- ${emoji} **${c.customerName}** — ${c.renewalAt} _(D-${days})_ · ${fmtKrw(c.mrr)}원/월\n`);
|
||
}
|
||
}
|
||
|
||
if (atRisk.length > 0) {
|
||
chunk(view, '\n## ⚠️ 위험 고객\n');
|
||
for (const c of atRisk.sort((a, b) => b.mrr - a.mrr)) {
|
||
const lastRisk = c.notes.slice().reverse().find((n) => n.type === 'risk');
|
||
chunk(view, `- **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${lastRisk ? ` · ${lastRisk.memo.slice(0, 60)}` : ''}\n`);
|
||
}
|
||
}
|
||
|
||
if (active.length > 0) {
|
||
chunk(view, '\n## 활성 고객 (MRR 순)\n');
|
||
const top = active.slice().sort((a, b) => b.mrr - a.mrr).slice(0, 10);
|
||
for (const c of top) {
|
||
const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
|
||
chunk(view, `- ${c.customerName} — ${fmtKrw(c.mrr)}원/월${renewalNote}\n`);
|
||
}
|
||
if (active.length > 10) chunk(view, `- _…+${active.length - 10}곳_\n`);
|
||
}
|
||
|
||
chunk(view, `\n_누적 이벤트 ${readCustomerEvents().length}건. \`/customers help\` 로 명령어._\n`);
|
||
}
|
||
|
||
function _customersList(view: any, filter: string | undefined): void {
|
||
const states = computeCustomerStates();
|
||
let all = Array.from(states.values());
|
||
if (filter === 'active' || filter === 'at-risk' || filter === 'risk' || filter === 'churned') {
|
||
const target = filter === 'risk' ? 'at-risk' : filter;
|
||
all = all.filter((c) => c.status === target);
|
||
}
|
||
if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
|
||
chunk(view, `\n📋 **고객 목록 (${all.length}곳${filter ? `, ${filter}` : ''})**\n\n`);
|
||
const sorted = all.slice().sort((a, b) => b.mrr - a.mrr);
|
||
for (const c of sorted) {
|
||
const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
|
||
const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
|
||
const planNote = c.plan ? ` · ${c.plan}` : '';
|
||
chunk(view, `- ${emoji} **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${planNote}${renewalNote}\n`);
|
||
}
|
||
}
|
||
|
||
function _customersShow(view: any, name: string): void {
|
||
const states = computeCustomerStates();
|
||
const cid = customerIdFromName(name);
|
||
const c = states.get(cid);
|
||
if (!c) {
|
||
const candidates = Array.from(states.values()).filter((x) => x.customerName.toLowerCase().includes(name.toLowerCase()));
|
||
if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
|
||
if (candidates.length > 1) {
|
||
chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}곳:\n`);
|
||
for (const x of candidates) chunk(view, `- ${x.customerName}\n`);
|
||
return;
|
||
}
|
||
return _customersShow(view, candidates[0].customerName);
|
||
}
|
||
|
||
const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
|
||
chunk(view, `\n${emoji} **${c.customerName}** _(${c.status})_\n`);
|
||
chunk(view, `\n- MRR: **${fmtKrw(c.mrr)}원/월** _(연 ${fmtKrw(c.mrr * 12)}원)_\n`);
|
||
if (c.plan) chunk(view, `- 요금제: ${c.plan}\n`);
|
||
if (c.renewalAt) {
|
||
const d = daysUntil(c.renewalAt);
|
||
const dn = d !== null ? (d >= 0 ? `D-${d}` : `${-d}일 지남`) : '';
|
||
chunk(view, `- 갱신일: ${c.renewalAt} _(${dn})_\n`);
|
||
}
|
||
chunk(view, `- 시작: ${(c.startedAt || '').slice(0, 10)} · 누적 이벤트 ${c.eventCount}건\n`);
|
||
|
||
if (c.notes.length > 0) {
|
||
chunk(view, `\n## 메모·이벤트 (${c.notes.length}건, 최신순)\n`);
|
||
const recent = c.notes.slice().reverse().slice(0, 10);
|
||
for (const n of recent) {
|
||
const date = (n.timestamp || '').slice(0, 10);
|
||
const tagEmoji = n.type === 'risk' ? '⚠️' : n.type === 'churn' ? '💀' : '📝';
|
||
chunk(view, `- ${tagEmoji} \`${date}\` ${n.memo}\n`);
|
||
}
|
||
if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
|
||
}
|
||
}
|
||
|
||
async function runCustomers(arg: string, view: any): Promise<boolean> {
|
||
const trimmed = arg.trim();
|
||
if (!trimmed) { _customersDashboard(view); return true; }
|
||
|
||
const parts = trimmed.split(/\s+/);
|
||
const sub = parts[0].toLowerCase();
|
||
|
||
if (sub === 'help' || sub === '?') {
|
||
chunk(view, [
|
||
'\n📒 **/customers — 고객사 / MRR / 갱신 트래커**',
|
||
'',
|
||
'사용법:',
|
||
' `/customers` — 대시보드 (MRR, 위험, 갱신 임박)',
|
||
' `/customers add <이름> <MRR> [갱신일] [요금제]` — 신규 등록',
|
||
' `/customers update <이름> mrr=<금액> renewal=<날짜>`— 정보 수정',
|
||
' `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 로 복귀)',
|
||
' `/customers risk <이름> <사유>` — 위험 표시',
|
||
' `/customers churn <이름> <사유>` — 이탈 처리 (MRR=0)',
|
||
' `/customers note <이름> <텍스트>` — 자유 메모',
|
||
' `/customers show <이름>` — 상세 (부분 매치 OK)',
|
||
' `/customers list [active/risk/churned]` — 필터 목록',
|
||
' `/customers path` — .jsonl 파일 경로',
|
||
'',
|
||
'MRR 금액 단위: `200만` / `1.5억` / `300000` 모두 OK.',
|
||
'갱신일: `YYYY-MM-DD` (예: `2026-12-01`).',
|
||
'저장: `<workspace>/.astra/customers.jsonl` (로컬 only, 외부 안 보냄).\n',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'path') {
|
||
const p = getCustomersFilePath();
|
||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
|
||
chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readCustomerEvents().length}건 (.jsonl).\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'list') { _customersList(view, parts[1]?.toLowerCase()); return true; }
|
||
|
||
if (sub === 'show') {
|
||
const name = parts.slice(1).join(' ').trim();
|
||
if (!name) { chunk(view, '\n❌ 사용법: `/customers show <이름>`\n'); return true; }
|
||
_customersShow(view, name);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'add') {
|
||
const name = parts[1];
|
||
const mrrToken = parts[2];
|
||
const renewalToken = parts[3];
|
||
const planToken = parts[4];
|
||
if (!name || !mrrToken) {
|
||
chunk(view, '\n❌ 사용법: `/customers add <이름> <MRR> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
|
||
return true;
|
||
}
|
||
const mrr = parseAmount(mrrToken);
|
||
if (mrr === null) { chunk(view, `\n❌ MRR 파싱 실패: "${mrrToken}"\n`); return true; }
|
||
const event: CustomerEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
customerId: customerIdFromName(name),
|
||
customerName: name,
|
||
type: 'add',
|
||
mrr,
|
||
renewalAt: renewalToken && /^\d{4}-\d{2}-\d{2}$/.test(renewalToken) ? renewalToken : undefined,
|
||
plan: planToken,
|
||
};
|
||
const res = appendCustomerEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n✅ **${name}** 등록 — MRR ${fmtKrw(mrr)}원/월${event.renewalAt ? ` · 갱신 ${event.renewalAt}` : ''}${event.plan ? ` · ${event.plan}` : ''}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'renew') {
|
||
const name = parts[1];
|
||
const newRenewal = parts[2];
|
||
const newMrrToken = parts[3];
|
||
if (!name || !newRenewal) { chunk(view, '\n❌ 사용법: `/customers renew <이름> <새 갱신일> [새 MRR]`\n'); return true; }
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(newRenewal)) { chunk(view, `\n❌ 갱신일 형식: YYYY-MM-DD (입력: "${newRenewal}")\n`); return true; }
|
||
const newMrr = newMrrToken ? parseAmount(newMrrToken) : null;
|
||
const event: CustomerEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
customerId: customerIdFromName(name),
|
||
customerName: name,
|
||
type: 'renew',
|
||
renewalAt: newRenewal,
|
||
mrr: newMrr ?? undefined,
|
||
};
|
||
const res = appendCustomerEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n🔄 **${name}** 갱신 — ${newRenewal}${newMrr !== null ? ` · MRR ${fmtKrw(newMrr)}원/월` : ''}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'risk' || sub === 'churn' || sub === 'note') {
|
||
const name = parts[1];
|
||
const memo = parts.slice(2).join(' ').trim();
|
||
if (!name || !memo) {
|
||
chunk(view, `\n❌ 사용법: \`/customers ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`);
|
||
return true;
|
||
}
|
||
const event: CustomerEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
customerId: customerIdFromName(name),
|
||
customerName: name,
|
||
type: sub,
|
||
memo,
|
||
};
|
||
const res = appendCustomerEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
const emoji = sub === 'risk' ? '⚠️' : sub === 'churn' ? '💀' : '📝';
|
||
const label = sub === 'risk' ? '위험 표시' : sub === 'churn' ? '이탈 처리' : '메모 추가';
|
||
chunk(view, `\n${emoji} **${name}** ${label}: ${memo}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'update') {
|
||
const name = parts[1];
|
||
if (!name) { chunk(view, '\n❌ 사용법: `/customers update <이름> mrr=<금액> renewal=<YYYY-MM-DD> plan=<요금제>`\n'); return true; }
|
||
const rest = parts.slice(2);
|
||
const event: CustomerEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
customerId: customerIdFromName(name),
|
||
customerName: name,
|
||
type: 'update',
|
||
};
|
||
let touched = false;
|
||
for (const kv of rest) {
|
||
const m = kv.match(/^(\w+)=(.+)$/);
|
||
if (!m) continue;
|
||
const k = m[1].toLowerCase();
|
||
const v = m[2];
|
||
if (k === 'mrr') {
|
||
const n = parseAmount(v);
|
||
if (n !== null) { event.mrr = n; touched = true; }
|
||
} else if (k === 'renewal' || k === 'renewalat') {
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { event.renewalAt = v; touched = true; }
|
||
} else if (k === 'plan') {
|
||
event.plan = v; touched = true;
|
||
}
|
||
}
|
||
if (!touched) { chunk(view, '\n❌ 변경할 필드 없음. 예: `/customers update 큐브앤코 mrr=300만 renewal=2026-12-15`\n'); return true; }
|
||
const res = appendCustomerEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
const changes: string[] = [];
|
||
if (event.mrr !== undefined) changes.push(`MRR=${fmtKrw(event.mrr)}원`);
|
||
if (event.renewalAt) changes.push(`갱신=${event.renewalAt}`);
|
||
if (event.plan) changes.push(`요금제=${event.plan}`);
|
||
chunk(view, `\n✏️ **${name}** 업데이트: ${changes.join(' · ')}\n`);
|
||
return true;
|
||
}
|
||
|
||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/customers help\` 참조.\n`);
|
||
return true;
|
||
}
|
||
|
||
// ─── /hire ────────────────────────────────────────────────────────────────
|
||
|
||
function _hireDashboard(view: any): void {
|
||
const states = computeCandidateStates();
|
||
const all = Array.from(states.values());
|
||
if (all.length === 0) {
|
||
chunk(view, '\n👥 **/hire — 채용 파이프라인**\n\nℹ️ 등록된 후보 없음. 시작: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드`\n');
|
||
return;
|
||
}
|
||
|
||
const active = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||
const hired = all.filter((c) => c.stage === 'hired');
|
||
const rejected = all.filter((c) => c.stage === 'rejected' || c.stage === 'declined');
|
||
|
||
chunk(view, '\n👥 **/hire — 채용 파이프라인**\n');
|
||
chunk(view, '\n## 요약\n');
|
||
chunk(view, `- 진행 중 **${active.length}명** · 합격 ${hired.length}명 · 종료 ${rejected.length}명\n`);
|
||
|
||
const byRole = new Map<string, CandidateState[]>();
|
||
for (const c of active) {
|
||
const role = c.role || '미지정';
|
||
if (!byRole.has(role)) byRole.set(role, []);
|
||
byRole.get(role)!.push(c);
|
||
}
|
||
if (byRole.size > 0) {
|
||
chunk(view, '\n## 역할별 진행\n');
|
||
for (const [role, cs] of byRole) {
|
||
chunk(view, `- **${role}**: ${cs.length}명\n`);
|
||
}
|
||
}
|
||
|
||
const byStage = new Map<string, CandidateState[]>();
|
||
for (const c of active) {
|
||
if (!byStage.has(c.stage)) byStage.set(c.stage, []);
|
||
byStage.get(c.stage)!.push(c);
|
||
}
|
||
const sortedStages = Array.from(byStage.keys()).sort((a, b) => (STAGE_ORDER[a] ?? 50) - (STAGE_ORDER[b] ?? 50));
|
||
if (sortedStages.length > 0) {
|
||
chunk(view, '\n## 단계별\n');
|
||
for (const stage of sortedStages) {
|
||
const list = byStage.get(stage)!;
|
||
chunk(view, `\n### ${stageEmoji(stage)} ${stage} (${list.length})\n`);
|
||
for (const c of list.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || ''))) {
|
||
const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||
const stale = daysIn > 7 ? ` ⏰ ${daysIn}일 정체` : '';
|
||
const salary = c.salary !== undefined ? ` · ${fmtKrw(c.salary)}원` : '';
|
||
chunk(view, `- ${c.candidateName} _(${c.role || '미지정'})_${salary}${stale}\n`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hired.length > 0) {
|
||
chunk(view, `\n## 🎉 최근 합격 (${hired.length}명)\n`);
|
||
const recent = hired.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || '')).slice(0, 5);
|
||
for (const c of recent) {
|
||
chunk(view, `- ${c.candidateName} _(${c.role})_ — ${(c.lastEventAt || '').slice(0, 10)}\n`);
|
||
}
|
||
}
|
||
|
||
chunk(view, `\n_누적 이벤트 ${readHireEvents().length}건. \`/hire help\` 로 명령어._\n`);
|
||
}
|
||
|
||
function _hireList(view: any, filter: string | undefined): void {
|
||
const states = computeCandidateStates();
|
||
let all = Array.from(states.values());
|
||
if (filter) {
|
||
const f = filter.toLowerCase();
|
||
if (f === 'active') all = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||
else if (f === 'closed' || f === 'terminal') all = all.filter((c) => TERMINAL_STAGES.has(c.stage));
|
||
else all = all.filter((c) => c.stage === f || (c.role || '').toLowerCase() === f);
|
||
}
|
||
if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
|
||
chunk(view, `\n📋 **후보 목록 (${all.length}명${filter ? `, ${filter}` : ''})**\n\n`);
|
||
const sorted = all.slice().sort((a, b) => (STAGE_ORDER[a.stage] ?? 50) - (STAGE_ORDER[b.stage] ?? 50));
|
||
for (const c of sorted) {
|
||
const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||
chunk(view, `- ${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_ · ${c.stage} · ${daysIn}일 전\n`);
|
||
}
|
||
}
|
||
|
||
function _hireShow(view: any, name: string): void {
|
||
const states = computeCandidateStates();
|
||
const cid = candidateIdFromName(name);
|
||
let c = states.get(cid);
|
||
if (!c) {
|
||
const candidates = Array.from(states.values()).filter((x) => x.candidateName.toLowerCase().includes(name.toLowerCase()));
|
||
if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
|
||
if (candidates.length > 1) {
|
||
chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}명:\n`);
|
||
for (const x of candidates) chunk(view, `- ${x.candidateName} (${x.role})\n`);
|
||
return;
|
||
}
|
||
c = candidates[0];
|
||
}
|
||
|
||
chunk(view, `\n${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_\n`);
|
||
chunk(view, `\n- 단계: **${c.stage}**\n`);
|
||
if (c.salary !== undefined) chunk(view, `- 제안 연봉: ${fmtKrw(c.salary)}원\n`);
|
||
chunk(view, `- 시작: ${(c.addedAt || '').slice(0, 10)} · 최근 변경: ${(c.lastEventAt || '').slice(0, 10)} · 이벤트 ${c.eventCount}건\n`);
|
||
|
||
if (c.notes.length > 0) {
|
||
chunk(view, `\n## 메모·이벤트 (${c.notes.length}건)\n`);
|
||
const recent = c.notes.slice().reverse().slice(0, 10);
|
||
for (const n of recent) {
|
||
const date = (n.timestamp || '').slice(0, 10);
|
||
chunk(view, `- \`${date}\` ${n.memo}\n`);
|
||
}
|
||
if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
|
||
}
|
||
}
|
||
|
||
async function runHire(arg: string, view: any): Promise<boolean> {
|
||
const trimmed = arg.trim();
|
||
if (!trimmed) { _hireDashboard(view); return true; }
|
||
|
||
const parts = trimmed.split(/\s+/);
|
||
const sub = parts[0].toLowerCase();
|
||
|
||
if (sub === 'help' || sub === '?') {
|
||
chunk(view, [
|
||
'\n👥 **/hire — 채용 파이프라인**',
|
||
'',
|
||
'사용법:',
|
||
' `/hire` — 파이프라인 대시보드',
|
||
' `/hire add <이름> <역할>` — 신규 후보 (inbox 단계)',
|
||
' `/hire stage <이름> <새 단계>` — 단계 이동',
|
||
' `/hire note <이름> <텍스트>` — 자유 메모',
|
||
' `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer)',
|
||
' `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired)',
|
||
' `/hire reject <이름> <사유>` — 거절 (회사 측)',
|
||
' `/hire decline <이름> <사유>` — 후보 사양',
|
||
' `/hire show <이름>` — 상세 + 이력',
|
||
' `/hire list [active/closed/단계명/역할]` — 필터 목록',
|
||
' `/hire path` — 파일 위치',
|
||
'',
|
||
'단계 (기본 파이프라인): inbox → screened → interview → final → offer → accepted → hired',
|
||
'터미널: rejected · declined',
|
||
'',
|
||
'연봉 단위: `4500만` / `1억` / `45000000` 모두 OK.',
|
||
'입사일: `YYYY-MM-DD`.',
|
||
'저장: `<workspace>/.astra/hire.jsonl` (로컬 only).\n',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'path') {
|
||
const p = getHireFilePath();
|
||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
|
||
chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readHireEvents().length}건 (.jsonl).\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'list') { _hireList(view, parts[1]); return true; }
|
||
if (sub === 'show') {
|
||
const name = parts.slice(1).join(' ').trim();
|
||
if (!name) { chunk(view, '\n❌ 사용법: `/hire show <이름>`\n'); return true; }
|
||
_hireShow(view, name);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'add') {
|
||
const name = parts[1];
|
||
const role = parts.slice(2).join(' ').trim();
|
||
if (!name || !role) { chunk(view, '\n❌ 사용법: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드 시니어`\n'); return true; }
|
||
const event: HireEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
candidateId: candidateIdFromName(name),
|
||
candidateName: name,
|
||
role,
|
||
type: 'add',
|
||
stage: 'inbox',
|
||
};
|
||
const res = appendHireEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n📥 **${name}** 등록 — ${role} (inbox)\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'stage') {
|
||
const name = parts[1];
|
||
const newStage = parts[2]?.toLowerCase();
|
||
if (!name || !newStage) { chunk(view, '\n❌ 사용법: `/hire stage <이름> <단계>`\n'); return true; }
|
||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음. \`/hire add\` 먼저.\n`); return true; }
|
||
const event: HireEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
candidateId: existing.candidateId,
|
||
candidateName: existing.candidateName,
|
||
role: existing.role,
|
||
type: 'stage',
|
||
stage: newStage,
|
||
};
|
||
const res = appendHireEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n${stageEmoji(newStage)} **${existing.candidateName}**: ${existing.stage} → ${newStage}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'offer') {
|
||
const name = parts[1];
|
||
const salaryToken = parts[2];
|
||
const startDate = parts[3];
|
||
if (!name || !salaryToken) { chunk(view, '\n❌ 사용법: `/hire offer <이름> <연봉> [입사일]`\n예: `/hire offer 김개발 6000만 2026-07-01`\n'); return true; }
|
||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||
const salary = parseAmount(salaryToken);
|
||
if (salary === null) { chunk(view, `\n❌ 연봉 파싱 실패: "${salaryToken}"\n`); return true; }
|
||
const event: HireEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
candidateId: existing.candidateId,
|
||
candidateName: existing.candidateName,
|
||
role: existing.role,
|
||
type: 'offer',
|
||
stage: 'offer',
|
||
salary,
|
||
memo: startDate ? `오퍼 발송 (입사 예정: ${startDate})` : '오퍼 발송',
|
||
};
|
||
const res = appendHireEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n📨 **${existing.candidateName}** 오퍼 — ${fmtKrw(salary)}원${startDate ? ` · 입사 ${startDate}` : ''}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'hire') {
|
||
const name = parts[1];
|
||
const startDate = parts[2];
|
||
if (!name) { chunk(view, '\n❌ 사용법: `/hire hire <이름> [입사일]`\n'); return true; }
|
||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||
const event: HireEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
candidateId: existing.candidateId,
|
||
candidateName: existing.candidateName,
|
||
role: existing.role,
|
||
type: 'hire',
|
||
stage: 'hired',
|
||
memo: startDate ? `입사 확정 (시작: ${startDate})` : '입사 확정',
|
||
};
|
||
const res = appendHireEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
chunk(view, `\n🎉 **${existing.candidateName}** 입사 확정 — ${existing.role}${startDate ? ` (${startDate})` : ''}\n`);
|
||
return true;
|
||
}
|
||
|
||
if (sub === 'reject' || sub === 'decline' || sub === 'note') {
|
||
const name = parts[1];
|
||
const memo = parts.slice(2).join(' ').trim();
|
||
if (!name || !memo) { chunk(view, `\n❌ 사용법: \`/hire ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); return true; }
|
||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||
const event: HireEvent = {
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
timestamp: new Date().toISOString(),
|
||
candidateId: existing.candidateId,
|
||
candidateName: existing.candidateName,
|
||
role: existing.role,
|
||
type: sub,
|
||
stage: sub === 'note' ? undefined : sub === 'reject' ? 'rejected' : 'declined',
|
||
memo,
|
||
};
|
||
const res = appendHireEvent(event);
|
||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||
const labels: Record<string, string> = { reject: '❌ 거절', decline: '🚪 사양', note: '📝 메모' };
|
||
chunk(view, `\n${labels[sub]} **${existing.candidateName}**: ${memo}\n`);
|
||
return true;
|
||
}
|
||
|
||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/hire help\` 참조.\n`);
|
||
return true;
|
||
}
|
||
|
||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||
|
||
registerSlashCommand({ name: '/runway', description: '현금 / 월 소진율 / 런웨이 — 4인 기업 CEO 의 가장 중요한 숫자 (로컬 .jsonl)', handler: runRunway });
|
||
registerSlashCommand({ name: '/customers', description: '고객사 / MRR / 갱신 / 위험 트래커 — event-sourced 로그 (로컬 .jsonl)', handler: runCustomers });
|
||
registerSlashCommand({ name: '/hire', description: '채용 파이프라인 — 후보자 단계·오퍼·합격 트래커 (로컬 .jsonl)', handler: runHire });
|