Files
connectai/src/features/teamops/handlers/trackers.ts
T
koriweb 7bec20620a 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>
2026-06-01 11:55:22 +09:00

705 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });