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:
@@ -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 });
|
||||
Reference in New Issue
Block a user