/** * 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 = { 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 { 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. 소수점·콤마 허용.', '저장 위치: `/.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 = { 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 = { 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 <이름> [갱신일] [요금제]`\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 { 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 <이름> [갱신일] [요금제]` — 신규 등록', ' `/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`).', '저장: `/.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 <이름> [갱신일] [요금제]`\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= 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(); 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(); 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 { 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`.', '저장: `/.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 = { 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 });