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>
147 lines
5.4 KiB
TypeScript
147 lines
5.4 KiB
TypeScript
/**
|
|
* Runway / Cash 누적 저장소.
|
|
*
|
|
* 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를
|
|
* 한 명령 (`/runway`) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는
|
|
* "지금 통장에 얼마, 한 달에 얼마 나감" 을 코드 옆에서 잡는 가벼운 트래커.
|
|
*
|
|
* 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 외부 회계 SaaS 연동 안 함.
|
|
* 위치: `<workspace>/.astra/runway.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화.
|
|
*
|
|
* 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only.
|
|
*/
|
|
|
|
import { createEventStore } from '../_shared/eventSourcedStore';
|
|
|
|
const STORE_REL_PATH = '.astra/runway.jsonl';
|
|
|
|
export type RunwayEntryType = 'snapshot' | 'expense' | 'revenue' | 'burn';
|
|
|
|
export interface RunwayEntry {
|
|
/** unique id — timestamp 기반. */
|
|
id: string;
|
|
/** ISO timestamp. */
|
|
timestamp: string;
|
|
/** 항목 종류 — snapshot(잔고) / expense(지출) / revenue(수입) / burn(월 소진율 수동 설정). */
|
|
type: RunwayEntryType;
|
|
/** 금액 — KRW 기본 단위, 소수점 허용. */
|
|
amount: number;
|
|
/** 통화 — 기본 'KRW'. 추후 'USD' 등 확장 가능. */
|
|
currency?: string;
|
|
/** 카테고리 — expense 의 경우 'salary' / 'rent' / 'saas' / 'misc' 등. */
|
|
category?: string;
|
|
/** 자유 메모. */
|
|
memo?: string;
|
|
}
|
|
|
|
const _store = createEventStore<RunwayEntry>({
|
|
relPath: STORE_REL_PATH,
|
|
validate: (e): e is RunwayEntry => !!e
|
|
&& typeof (e as any).id === 'string'
|
|
&& typeof (e as any).amount === 'number'
|
|
&& typeof (e as any).type === 'string',
|
|
});
|
|
|
|
export const getRunwayFilePath = _store.getFilePath;
|
|
export const readRunway = _store.read;
|
|
export const appendRunway = _store.append;
|
|
|
|
/**
|
|
* 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위.
|
|
*
|
|
* - latestCash: 가장 최근 'snapshot' entry 의 amount (없으면 null).
|
|
* - explicitBurn: 가장 최근 'burn' entry — 사용자가 수동 설정한 월 소진율.
|
|
* - computedBurn: 최근 30일 expense - revenue, 30일 미만이면 일 평균 × 30 으로 보정.
|
|
* - effectiveBurn: explicitBurn 우선, 없으면 computedBurn.
|
|
* - runwayMonths: latestCash / effectiveBurn — burn 이 0 이하면 Infinity.
|
|
*/
|
|
export interface RunwayStatus {
|
|
latestCash: number | null;
|
|
latestCashAt: string | null;
|
|
explicitBurn: number | null;
|
|
computedBurn: number | null;
|
|
effectiveBurn: number | null;
|
|
runwayMonths: number | null;
|
|
last30Expense: number;
|
|
last30Revenue: number;
|
|
last30Days: number;
|
|
totalEntries: number;
|
|
}
|
|
|
|
export function computeRunwayStatus(now: Date = new Date()): RunwayStatus {
|
|
const entries = readRunway();
|
|
const nowMs = now.getTime();
|
|
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
let latestCash: number | null = null;
|
|
let latestCashAt: string | null = null;
|
|
let explicitBurn: number | null = null;
|
|
let last30Expense = 0;
|
|
let last30Revenue = 0;
|
|
let oldestRecentMs = nowMs;
|
|
let hasRecent = false;
|
|
|
|
for (const e of entries) {
|
|
const t = Date.parse(e.timestamp);
|
|
if (e.type === 'snapshot') {
|
|
if (!latestCashAt || (Date.parse(e.timestamp) >= Date.parse(latestCashAt))) {
|
|
latestCash = e.amount;
|
|
latestCashAt = e.timestamp;
|
|
}
|
|
} else if (e.type === 'burn') {
|
|
if (!explicitBurn || t >= (entries.find(x => x.type === 'burn' && x.amount === explicitBurn)?.timestamp ? Date.parse(e.timestamp) : 0)) {
|
|
explicitBurn = e.amount;
|
|
}
|
|
} else if (e.type === 'expense' && nowMs - t <= thirtyDaysMs) {
|
|
last30Expense += e.amount;
|
|
if (t < oldestRecentMs) oldestRecentMs = t;
|
|
hasRecent = true;
|
|
} else if (e.type === 'revenue' && nowMs - t <= thirtyDaysMs) {
|
|
last30Revenue += e.amount;
|
|
if (t < oldestRecentMs) oldestRecentMs = t;
|
|
hasRecent = true;
|
|
}
|
|
}
|
|
|
|
// 최신 burn 정확히 다시 — 위 로직이 꼬여서 단순화.
|
|
explicitBurn = null;
|
|
let burnAt = 0;
|
|
for (const e of entries) {
|
|
if (e.type !== 'burn') continue;
|
|
const t = Date.parse(e.timestamp);
|
|
if (t >= burnAt) { explicitBurn = e.amount; burnAt = t; }
|
|
}
|
|
|
|
const netBurn30 = last30Expense - last30Revenue;
|
|
let computedBurn: number | null = null;
|
|
let last30Days = 0;
|
|
if (hasRecent) {
|
|
const span = Math.max(1, Math.ceil((nowMs - oldestRecentMs) / (24 * 60 * 60 * 1000)));
|
|
last30Days = Math.min(30, span);
|
|
// 30일 미만이면 일 평균 × 30 으로 환산.
|
|
if (last30Days < 30) computedBurn = (netBurn30 / last30Days) * 30;
|
|
else computedBurn = netBurn30;
|
|
}
|
|
|
|
const effectiveBurn = explicitBurn ?? computedBurn;
|
|
let runwayMonths: number | null = null;
|
|
if (latestCash !== null && effectiveBurn !== null && effectiveBurn > 0) {
|
|
runwayMonths = latestCash / effectiveBurn;
|
|
} else if (latestCash !== null && effectiveBurn !== null && effectiveBurn <= 0) {
|
|
runwayMonths = Infinity;
|
|
}
|
|
|
|
return {
|
|
latestCash,
|
|
latestCashAt,
|
|
explicitBurn,
|
|
computedBurn,
|
|
effectiveBurn,
|
|
runwayMonths,
|
|
last30Expense,
|
|
last30Revenue,
|
|
last30Days,
|
|
totalEntries: entries.length,
|
|
};
|
|
}
|