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:
2026-06-01 11:55:22 +09:00
parent 15a34e0889
commit 7bec20620a
40 changed files with 4784 additions and 4545 deletions
+704
View File
@@ -0,0 +1,704 @@
/**
* 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 });