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
@@ -0,0 +1,572 @@
/**
* TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup.
*
* v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등
* "팀 운영의 실시간 부분" 클러스터.
*
* 공통 헬퍼는 `./_shared.ts` 에서. 옛 slashRouter 의 local parseTaskOwner/
* parseFlexibleDate 도 여기로 (parseFlexibleDate 는 /task 만 사용).
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
import { parseTaskOwner } from './_shared';
import { createCalendarEvent, createTask, listTasks, _addDaysDate } from '../../calendar';
import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
// ─── 공통 헬퍼 — /task 전용 ──────────────────────────────────────────────
/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. */
function parseFlexibleDate(s: string): string | null {
if (!s) return null;
let y: number, mo: number, d: number;
let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
else return null;
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
const date = new Date(y, mo - 1, d);
if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
// ─── /task — Google Tasks + Calendar 동시 등록 ──────────────────────────
async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; }
if (!arg.trim()) {
chunk(view, [
'\n📋 **/task — Google Tasks + Calendar 동시 등록**',
'',
'사용법:',
' `/task [@담당자] <제목> <시작일> <완료일>` — 기간 작업',
' `/task [@담당자] <제목> <날짜>` — 하루짜리 작업',
'',
'날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`',
'담당자: `@` 접두사로 첫 토큰에 (예: `@기획자`). 생략 가능 — 있으면 제목 앞 `[담당자]` 로 prefix 됨.',
'',
'예시:',
' `/task @기획자 Apple 계정 생성 요청 26/05/27 26/06/28`',
' `/task @디자이너 메인 화면 시안 2026-07-01 2026-07-15`',
' `/task 약값 결제 26/06/01` (담당자 없음)',
'',
'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.',
'',
].join('\n'));
return true;
}
const tokens = arg.trim().split(/\s+/);
if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; }
let owner: string | undefined;
if (tokens[0]?.startsWith('@') && tokens[0].length > 1) {
owner = tokens[0].slice(1);
tokens.shift();
if (tokens.length < 1) { chunk(view, '\n❌ 제목·날짜 누락 (담당자만 입력됨).\n'); return true; }
}
const lastDate = parseFlexibleDate(tokens[tokens.length - 1]);
const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null;
let startYmd: string, endYmd: string, titleTokens: string[];
if (lastDate && secondLastDate) {
startYmd = secondLastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -2);
} else if (lastDate) {
startYmd = lastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -1);
} else {
chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`);
return true;
}
const baseTitle = titleTokens.join(' ').trim();
if (!baseTitle) { chunk(view, '\n❌ 제목 누락.\n'); return true; }
if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; }
const title = owner ? `[${owner}] ${baseTitle}` : baseTitle;
const isRange = startYmd !== endYmd;
const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd;
chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}${owner ? ` · 담당: @${owner}` : ''}\n`);
const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`;
const successes: string[] = [];
const failures: string[] = [];
let calLink: string | undefined;
const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes;
const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes });
if (taskResult.ok) successes.push('Tasks');
else failures.push(`Tasks: ${taskResult.error}`);
const calEnd = _addDaysDate(endYmd, 1);
const calResult = await createCalendarEvent(context, { title, start: startYmd, end: calEnd, allDay: true, description: notes });
if (calResult.ok) { successes.push('Calendar'); calLink = calResult.event.htmlLink; }
else failures.push(`Calendar: ${calResult.error}`);
if (failures.length === 0) chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`);
else if (successes.length > 0) {
chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`);
for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
} else {
chunk(view, `❌ 모두 실패\n`);
for (const f of failures) chunk(view, ` · ${f}\n`);
}
if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`);
return true;
}
// ─── /decisions — Chronicle ADR 검색 ─────────────────────────────────────
async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; }
const tokens = arg.trim().split(/\s+/).filter(Boolean);
let ownerFilter: string | undefined;
const keywordParts: string[] = [];
for (const t of tokens) {
if (t.startsWith('@') && t.length > 1) ownerFilter = t.slice(1);
else keywordParts.push(t);
}
const keyword = keywordParts.join(' ').toLowerCase().trim();
if (!keyword && !ownerFilter) {
chunk(view, [
'\n📋 **/decisions — Chronicle 결정 기록 검색**',
'',
'사용법:',
' `/decisions <키워드>` — 키워드로 ADR 검색',
' `/decisions @<담당자>` — 담당자 언급 결정만',
' `/decisions <키워드> @<담당자>` — 둘 다 만족',
'',
'예시:',
' `/decisions 환불 정책`',
' `/decisions @기획자`',
' `/decisions 결제 흐름 @개발`',
'',
'Chronicle ADR 파일 (`<recordRoot>/decisions/ADR-NNNN-*.md`) 을 스캔합니다. 최신순 정렬, 최대 20건.',
'',
].join('\n'));
return true;
}
const store = new ChronicleProjectStore(context);
const profiles = store.getAll();
if (profiles.length === 0) {
chunk(view, '\n❌ Chronicle 프로젝트가 없습니다. workspace 폴더를 열고 사이드바에서 chronicle 활성화하세요.\n');
return true;
}
interface Hit { project: string; file: string; filePath: string; mtime: number; title: string; snippet: string; }
const hits: Hit[] = [];
for (const profile of profiles) {
const decisionsDir = path.join(profile.recordRoot, 'decisions');
if (!fs.existsSync(decisionsDir)) continue;
let fileNames: string[] = [];
try { fileNames = fs.readdirSync(decisionsDir); } catch { continue; }
for (const fileName of fileNames) {
if (!fileName.endsWith('.md') || !fileName.startsWith('ADR-')) continue;
const filePath = path.join(decisionsDir, fileName);
let content = '';
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
const lower = content.toLowerCase();
if (keyword && !lower.includes(keyword)) continue;
if (ownerFilter && !content.includes(ownerFilter)) continue;
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : fileName.replace(/\.md$/, '');
let snippet = '';
if (keyword) {
const idx = lower.indexOf(keyword);
if (idx >= 0) {
const start = Math.max(0, idx - 60);
const end = Math.min(content.length, idx + 180);
snippet = content.slice(start, end).replace(/\s+/g, ' ').trim();
}
} else {
const paragraphs = content.split(/\n\s*\n/).map(p => p.trim()).filter(p => p && !p.startsWith('#') && !p.startsWith('>'));
snippet = (paragraphs[0] || '').slice(0, 220).replace(/\s+/g, ' ').trim();
}
let mtime = 0;
try { mtime = fs.statSync(filePath).mtimeMs; } catch { /* keep 0 */ }
hits.push({ project: profile.projectName, file: fileName, filePath, mtime, title, snippet });
}
}
if (hits.length === 0) {
const filterDesc = [keyword && `키워드 "${keyword}"`, ownerFilter && `@${ownerFilter}`].filter(Boolean).join(' + ');
chunk(view, `\n${filterDesc} 에 매치되는 결정 기록 없음. (검색 대상: ${profiles.length}개 프로젝트)\n`);
return true;
}
hits.sort((a, b) => b.mtime - a.mtime);
const filterDesc = [keyword && `키워드: ${keyword}`, ownerFilter && `담당: @${ownerFilter}`].filter(Boolean).join(' · ');
chunk(view, `\n📋 **결정 검색 결과 ${hits.length}건** (${filterDesc})\n\n`);
const MAX_SHOW = 20;
for (const h of hits.slice(0, MAX_SHOW)) {
const date = h.mtime ? new Date(h.mtime).toISOString().slice(0, 10) : '날짜 미상';
chunk(view, `### ${h.title}\n`);
chunk(view, `- 📅 ${date} · 📁 ${h.project} · \`${h.file}\`\n`);
if (h.snippet) chunk(view, `- 💬 …${h.snippet}\n`);
chunk(view, `- 🔗 \`${h.filePath}\`\n\n`);
}
if (hits.length > MAX_SHOW) chunk(view, `_…+${hits.length - MAX_SHOW}건 더 (필터를 좁히면 줄어듭니다)_\n`);
return true;
}
// ─── /onesie — 멤버별 1:1 미팅 준비 카드 ─────────────────────────────────
async function runOnesie(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /onesie 실행 불가.\n'); return true; }
const memberRaw = arg.trim().split(/\s+/)[0] || '';
const member = memberRaw.replace(/^@/, '').trim();
if (!member) {
chunk(view, [
'\n📋 **/onesie [멤버] — 1:1 미팅 준비 카드**',
'',
'사용법: `/onesie <담당자>` 또는 `/onesie @<담당자>`',
'예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`',
'',
'대상자의 Tasks 진행 상황(완료/지연/다가오는)과 최근 Chronicle 결정 기록을 모아 1:1 준비 카드 생성. 자동 대화 토픽 제안 포함.',
'',
'※ `/task @<멤버> ...` 로 task 를 등록해 두면 자동으로 잡힙니다. `/meet` 액션 아이템도 owner 가 있으면 잡힘.',
'',
].join('\n'));
return true;
}
chunk(view, `\n📋 **1:1 준비 카드 — @${member}**\n`);
chunk(view, '\n📥 Tasks 가져오는 중...\n');
const taskResult = await listTasks(context, { showCompleted: true, maxResults: 200 });
const allTasks = taskResult.ok ? taskResult.tasks : [];
if (!taskResult.ok) chunk(view, `\n⚠️ Tasks 조회 실패: ${taskResult.error}\n (Chronicle 검색은 계속 진행)\n`);
const memberPrefix = `[${member}]`;
const memberTasks = allTasks.filter((t) =>
t.title.includes(memberPrefix)
|| (t.notes || '').includes(`@${member}`)
|| (t.notes || '').includes(`담당: ${member}`),
);
const today = new Date().toISOString().slice(0, 10);
const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const completedRecent = memberTasks
.filter((t) => t.status === 'completed' && (t.completed || '') >= thirtyDaysAgoIso)
.sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
const overdue = memberTasks
.filter((t) => t.status === 'needsAction' && t.due && t.due < today)
.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
const upcoming = memberTasks
.filter((t) => t.status === 'needsAction' && t.due && t.due >= today)
.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
const noDate = memberTasks.filter((t) => t.status === 'needsAction' && !t.due);
const store = new ChronicleProjectStore(context);
const profiles = store.getAll();
interface AdrHit { date: string; mtime: number; title: string; file: string; project: string; }
const adrHits: AdrHit[] = [];
for (const profile of profiles) {
const decisionsDir = path.join(profile.recordRoot, 'decisions');
if (!fs.existsSync(decisionsDir)) continue;
let names: string[] = [];
try { names = fs.readdirSync(decisionsDir); } catch { continue; }
for (const fn of names) {
if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
const fp = path.join(decisionsDir, fn);
let content = '';
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
if (!content.includes(member)) continue;
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
let mtime = 0;
try { mtime = fs.statSync(fp).mtimeMs; } catch { /* keep 0 */ }
const date = mtime ? new Date(mtime).toISOString().slice(0, 10) : '';
adrHits.push({ date, mtime, title, file: fn, project: profile.projectName });
}
}
adrHits.sort((a, b) => b.mtime - a.mtime);
chunk(view, `\n## 최근 30일 완료 (${completedRecent.length}건)\n`);
if (completedRecent.length === 0) chunk(view, '_없음_\n');
else for (const t of completedRecent.slice(0, 10)) {
const d = (t.completed || '').slice(0, 10);
chunk(view, `- ✅ ${d}${t.title}\n`);
}
if (completedRecent.length > 10) chunk(view, `_…+${completedRecent.length - 10}건 더_\n`);
chunk(view, `\n## 지연 (${overdue.length}건)\n`);
if (overdue.length === 0) chunk(view, '_없음_\n');
else for (const t of overdue) chunk(view, `- 🔴 ${t.due} (마감 지남) — ${t.title}\n`);
chunk(view, `\n## 진행 중 / 다가오는 (${upcoming.length}건)\n`);
if (upcoming.length === 0) chunk(view, '_없음_\n');
else for (const t of upcoming.slice(0, 10)) chunk(view, `- 🟡 ${t.due}${t.title}\n`);
if (upcoming.length > 10) chunk(view, `_…+${upcoming.length - 10}건 더_\n`);
if (noDate.length > 0) {
chunk(view, `\n## 마감일 없음 (${noDate.length}건)\n`);
for (const t of noDate.slice(0, 5)) chunk(view, `- ⚪ ${t.title}\n`);
if (noDate.length > 5) chunk(view, `_…+${noDate.length - 5}건 더_\n`);
}
chunk(view, `\n## 최근 결정 — @${member} 언급 (${adrHits.length}건)\n`);
if (adrHits.length === 0) chunk(view, '_없음_\n');
else for (const h of adrHits.slice(0, 5)) chunk(view, `- 📋 ${h.date}${h.title} (\`${h.file}\`)\n`);
const topics: string[] = [];
if (overdue.length > 0) topics.push(`🔴 지연 ${overdue.length}건 블로커 확인 — 무엇이 막혔나, 도와줄 일은`);
if (upcoming.length > 5) topics.push(`🟡 다가오는 마감 ${upcoming.length}건 — 우선순위 합의·과부하 여부`);
if (completedRecent.length > 5) topics.push(`✅ 최근 완료 ${completedRecent.length}건 많음 — 회고 / 잘 된 점 / 패턴`);
else if (completedRecent.length === 0 && memberTasks.length > 0) topics.push('⚠️ 최근 30일 완료 0건 — 어떤 일에 시간을 쓰고 있는지 확인');
else if (memberTasks.length === 0) topics.push('⚠️ 등록된 Task 자체가 0 — 일하는 게 안 보임. owner 태깅 시작 필요');
if (noDate.length > 3) topics.push(`⚪ 마감일 없는 작업 ${noDate.length}건 — 우선순위 합의로 마감 부여`);
if (adrHits.length > 0) topics.push(`📋 최근 결정 (${adrHits[0].title.slice(0, 40)}…) — 이해·실행 상황 확인`);
chunk(view, `\n## 💬 1:1 대화 토픽 제안\n`);
if (topics.length === 0) chunk(view, '_특이사항 없음 — 일반 안부 + 다음 주 우선순위 정도_\n');
else for (const t of topics) chunk(view, `- ${t}\n`);
return true;
}
// ─── /blocked — 전사 across 지연·블로커 한 화면 ──────────────────────────
async function runBlocked(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /blocked 실행 불가.\n'); return true; }
const memberFilter = (arg.trim().split(/\s+/)[0] || '').replace(/^@/, '').trim() || undefined;
chunk(view, `\n🚨 **전사 블로커·지연 뷰**${memberFilter ? ` — @${memberFilter}` : ''}\n`);
chunk(view, '\n📥 Tasks 가져오는 중...\n');
const result = await listTasks(context, { showCompleted: false, maxResults: 300 });
if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
const tasks = result.tasks;
interface Row { due?: string; owner?: string; title: string; }
const today = new Date().toISOString().slice(0, 10);
const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const overdue: Row[] = [];
const thisWeek: Row[] = [];
const noDate: Row[] = [];
for (const t of tasks) {
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
if (memberFilter && (owner || '').toLowerCase() !== memberFilter.toLowerCase()) continue;
const row: Row = { due: t.due, owner, title: displayTitle };
if (!t.due) noDate.push(row);
else if (t.due < today) overdue.push(row);
else if (t.due <= weekLater) thisWeek.push(row);
}
overdue.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
thisWeek.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
noDate.sort((a, b) => (a.owner || 'zzz').localeCompare(b.owner || 'zzz'));
const fmtRow = (r: Row): string => {
const o = r.owner ? `@${r.owner}` : '(owner 없음)';
return `- 📅 \`${r.due || '----------'}\` · **${o}** — ${r.title}`;
};
const totalShown = overdue.length + thisWeek.length + noDate.length;
if (totalShown === 0) {
chunk(view, `\n✅ 지연·임박 항목 없음${memberFilter ? ` (@${memberFilter})` : ''}. 진행 상황 양호.\n`);
return true;
}
if (overdue.length > 0) {
chunk(view, `\n## 🔴 지연 ${overdue.length}건 — 즉시 확인 필요\n`);
const MAX_OVERDUE = 20;
for (const r of overdue.slice(0, MAX_OVERDUE)) chunk(view, fmtRow(r) + '\n');
if (overdue.length > MAX_OVERDUE) chunk(view, `_…+${overdue.length - MAX_OVERDUE}건 더_\n`);
}
if (thisWeek.length > 0) {
chunk(view, `\n## 🟡 이번 주 마감 ${thisWeek.length}건 (~${weekLater})\n`);
const MAX_WEEK = 15;
for (const r of thisWeek.slice(0, MAX_WEEK)) chunk(view, fmtRow(r) + '\n');
if (thisWeek.length > MAX_WEEK) chunk(view, `_…+${thisWeek.length - MAX_WEEK}건 더_\n`);
}
if (noDate.length > 0) {
chunk(view, `\n## ⚪ 마감일 없음 ${noDate.length}건 — 우선순위 합의 필요\n`);
const MAX_NODATE = 10;
for (const r of noDate.slice(0, MAX_NODATE)) chunk(view, fmtRow(r) + '\n');
if (noDate.length > MAX_NODATE) chunk(view, `_…+${noDate.length - MAX_NODATE}건 더_\n`);
}
if (!memberFilter && (overdue.length + thisWeek.length) > 0) {
const counts = new Map<string, { overdue: number; week: number }>();
for (const r of overdue) {
const k = r.owner || '(없음)';
const c = counts.get(k) || { overdue: 0, week: 0 };
c.overdue++;
counts.set(k, c);
}
for (const r of thisWeek) {
const k = r.owner || '(없음)';
const c = counts.get(k) || { overdue: 0, week: 0 };
c.week++;
counts.set(k, c);
}
const ranked = [...counts.entries()]
.sort((a, b) => (b[1].overdue * 2 + b[1].week) - (a[1].overdue * 2 + a[1].week));
chunk(view, `\n## 📊 멤버별 압박 ${ranked.length}\n`);
for (const [member, c] of ranked) {
chunk(view, `- **@${member}** — 지연 ${c.overdue}${c.week ? ` · 이번 주 ${c.week}` : ''}\n`);
}
chunk(view, '\n💡 압박 큰 멤버부터 `/onesie @<멤버>` 로 1:1 카드 확인 권장.\n');
}
return true;
}
// ─── /standup — 팀 스탠드업 카드 (슬랙 복붙 친화) ────────────────────────
async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; }
const mode = (arg.trim().split(/\s+/)[0] || 'weekly').toLowerCase();
const windowDays = mode === 'daily' ? 1 : mode === 'monthly' ? 30 : 7;
const modeLabel = mode === 'daily' ? '일일' : mode === 'monthly' ? '월간' : '주간';
if (arg.trim() && !['daily', 'weekly', 'monthly', ''].includes(mode)) {
chunk(view, [
'\n📋 **/standup [daily/weekly/monthly] — 팀 스탠드업 카드**',
'',
'사용법:',
' `/standup` — 주간 (기본, 7일 윈도우)',
' `/standup daily` — 일일 (1일 윈도우)',
' `/standup weekly` — 주간 (7일)',
' `/standup monthly` — 월간 (30일)',
'',
'멤버별로 완료 / 진행·예정 / 블로커 3-row + 이번 기간 결정 목록을 슬랙·노션에 복붙 가능한 마크다운으로 출력.',
'',
].join('\n'));
return true;
}
chunk(view, `\n📊 **팀 스탠드업 — ${modeLabel} (${windowDays}일 윈도우)**\n · ${new Date().toISOString().slice(0, 10)} 기준\n`);
chunk(view, '\n📥 Tasks 가져오는 중...\n');
const result = await listTasks(context, { showCompleted: true, maxResults: 300 });
if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
const today = new Date().toISOString().slice(0, 10);
const windowAgoIso = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString();
const upcomingEnd = new Date(Date.now() + windowDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
interface MemberSlot { completed: { date: string; title: string }[]; upcoming: { due: string; title: string }[]; overdue: { due: string; title: string }[]; noDate: { title: string }[]; }
const byMember = new Map<string, MemberSlot>();
const ensure = (k: string): MemberSlot => {
let s = byMember.get(k);
if (!s) { s = { completed: [], upcoming: [], overdue: [], noDate: [] }; byMember.set(k, s); }
return s;
};
for (const t of result.tasks) {
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
const member = owner || '(owner 없음)';
const slot = ensure(member);
if (t.status === 'completed') {
if ((t.completed || '') >= windowAgoIso) {
slot.completed.push({ date: (t.completed || '').slice(0, 10), title: displayTitle });
}
} else {
if (!t.due) slot.noDate.push({ title: displayTitle });
else if (t.due < today) slot.overdue.push({ due: t.due, title: displayTitle });
else if (t.due <= upcomingEnd) slot.upcoming.push({ due: t.due, title: displayTitle });
}
}
for (const s of byMember.values()) {
s.completed.sort((a, b) => b.date.localeCompare(a.date));
s.upcoming.sort((a, b) => a.due.localeCompare(b.due));
s.overdue.sort((a, b) => a.due.localeCompare(b.due));
}
if (byMember.size === 0) {
chunk(view, '\n️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n');
return true;
}
const store = new ChronicleProjectStore(context);
const profiles = store.getAll();
interface RecentAdr { date: string; title: string; file: string; mtime: number; }
const recentAdrs: RecentAdr[] = [];
const windowAgoMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
for (const profile of profiles) {
const decisionsDir = path.join(profile.recordRoot, 'decisions');
if (!fs.existsSync(decisionsDir)) continue;
let names: string[] = [];
try { names = fs.readdirSync(decisionsDir); } catch { continue; }
for (const fn of names) {
if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
const fp = path.join(decisionsDir, fn);
let mtime = 0;
try { mtime = fs.statSync(fp).mtimeMs; } catch { continue; }
if (mtime < windowAgoMs) continue;
let content = '';
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
recentAdrs.push({ date: new Date(mtime).toISOString().slice(0, 10), title, file: fn, mtime });
}
}
recentAdrs.sort((a, b) => b.mtime - a.mtime);
const memberRanked = [...byMember.entries()].sort((a, b) => {
const score = (s: MemberSlot) => s.completed.length + s.upcoming.length + s.overdue.length * 2;
return score(b[1]) - score(a[1]);
});
chunk(view, '\n---\n');
chunk(view, `\n## 📊 팀 스탠드업 — ${modeLabel}\n`);
chunk(view, `*${windowDays}일 윈도우 · 기준일 ${today}*\n`);
for (const [member, s] of memberRanked) {
chunk(view, `\n### @${member}\n`);
if (s.completed.length === 0) chunk(view, `**✅ 완료**: _없음_\n`);
else {
chunk(view, `**✅ 완료 (${s.completed.length})**:\n`);
const MAX = 8;
for (const c of s.completed.slice(0, MAX)) chunk(view, `- ${c.date}${c.title}\n`);
if (s.completed.length > MAX) chunk(view, `- _…+${s.completed.length - MAX}건_\n`);
}
if (s.upcoming.length === 0 && s.noDate.length === 0) chunk(view, `**🎯 진행/예정**: _없음_\n`);
else {
chunk(view, `**🎯 진행/예정 (${s.upcoming.length + s.noDate.length})**:\n`);
const MAX = 6;
for (const u of s.upcoming.slice(0, MAX)) chunk(view, `- ${u.due}${u.title}\n`);
for (const n of s.noDate.slice(0, Math.max(0, MAX - s.upcoming.length))) chunk(view, `- (마감 미정) — ${n.title}\n`);
const total = s.upcoming.length + s.noDate.length;
if (total > MAX) chunk(view, `- _…+${total - MAX}건_\n`);
}
if (s.overdue.length === 0) chunk(view, `**🚧 블로커**: _없음_\n`);
else {
chunk(view, `**🚧 블로커 (${s.overdue.length}건 지연)**:\n`);
for (const o of s.overdue.slice(0, 5)) chunk(view, `- 🔴 ${o.due} (지남) — ${o.title}\n`);
if (s.overdue.length > 5) chunk(view, `- _…+${s.overdue.length - 5}건_\n`);
}
}
if (recentAdrs.length > 0) {
chunk(view, `\n---\n\n## 📋 이번 ${modeLabel} 결정 (${recentAdrs.length}건)\n`);
for (const a of recentAdrs.slice(0, 10)) chunk(view, `- ${a.date}${a.title}\n`);
if (recentAdrs.length > 10) chunk(view, `- _…+${recentAdrs.length - 10}건_\n`);
}
chunk(view, '\n---\n\n💡 위 마크다운을 그대로 슬랙·노션에 복붙하세요. 멤버 활동 순 정렬.\n');
return true;
}
// ─── 등록 ─────────────────────────────────────────────────────────────────
registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask });
registerSlashCommand({ name: '/decisions', description: 'Chronicle 결정 기록(ADR) 검색 — 키워드/담당자로 필터', handler: runDecisions });
registerSlashCommand({ name: '/onesie', description: '멤버별 1:1 미팅 준비 카드 — Tasks 진행 + 최근 결정 + 대화 토픽 제안', handler: runOnesie });
registerSlashCommand({ name: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked });
registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup });