/** * 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'; import { callLmSynthesis } from '../../datacollect/llm'; import { buildWeeklyPrompt, WEEKLY_SYSTEM, type WeeklyTask } from '../../datacollect/prompts/weeklyPrompt'; // ─── 공통 헬퍼 — /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 { 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 { 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 파일 (`/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 { 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 { 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(); 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 { 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(); 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; } // ─── /weekly — 캘린더 task 기반 주간 보고서 (금주/차주) ────────────────── // `/weekly <차주시작일> <차주종료일>` — 입력한 날짜는 **차주** 기준. // 금주(차주 시작 직전 7일)는 자동 역산해 함께 검색한다. 버킷팅은 due/completed // 날짜로 코드가 처리(예측 가능), 서술 포맷팅만 LLM(meet 메모 → narrative bullet). async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise { if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /weekly 실행 불가.\n'); return true; } const tokens = arg.trim().split(/\s+/).filter(Boolean); if (tokens.length < 2) { chunk(view, [ '\n📋 **/weekly — 주간 업무 보고서 (금주/차주)**', '', '사용법: `/weekly <차주 시작일> <차주 종료일>`', ' · 입력한 두 날짜는 **차주** 기준입니다.', ' · 금주(차주 시작 직전 7일)는 자동으로 역산해 함께 검색합니다.', '', '날짜 형식: `YYYY-MM-DD` · `YYYY/MM/DD` · `YY/MM/DD`', '', '예시: `/weekly 2026-06-08 2026-06-12` → 차주 6/8~6/12, 금주 6/1~6/7', '', '캘린더(Google Tasks)에 등록된 작업의 마감·완료일로 금주/차주를 나누고,', '각 작업의 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 채웁니다.', '※ `/meet`·`/task` 로 등록한 작업이 소스입니다 — 메모가 충실할수록 보고서가 정확합니다.', '', ].join('\n')); return true; } let nextStart = parseFlexibleDate(tokens[0]); let nextEnd = parseFlexibleDate(tokens[1]); if (!nextStart || !nextEnd) { chunk(view, `\n❌ 날짜 형식 오류 — "${tokens[0]}" / "${tokens[1]}". 사용 가능: YYYY-MM-DD · YYYY/MM/DD · YY/MM/DD.\n`); return true; } if (nextStart > nextEnd) { [nextStart, nextEnd] = [nextEnd, nextStart]; } // 금주 = 차주 시작 직전 7일 (차주 시작 -7 ~ 차주 시작 -1). const thisStart = _addDaysDate(nextStart, -7); const thisEnd = _addDaysDate(nextStart, -1); chunk(view, `\n📊 **주간 보고서**\n · 금주: ${thisStart} ~ ${thisEnd}\n · 차주: ${nextStart} ~ ${nextEnd}\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 inRange = (d: string, a: string, b: string) => !!d && d >= a && d <= b; const toWeekly = (t: typeof result.tasks[number]): WeeklyTask => ({ title: t.title, due: t.due || '', status: t.status, completedYmd: t.completed ? t.completed.slice(0, 10) : undefined, notes: t.notes, }); const thisWeek: WeeklyTask[] = []; const nextWeek: WeeklyTask[] = []; for (const t of result.tasks) { const completedYmd = (t.completed || '').slice(0, 10); if (inRange(t.due || '', nextStart, nextEnd)) { nextWeek.push(toWeekly(t)); } else if (inRange(t.due || '', thisStart, thisEnd) || inRange(completedYmd, thisStart, thisEnd)) { thisWeek.push(toWeekly(t)); } } if (thisWeek.length === 0 && nextWeek.length === 0) { chunk(view, `\nℹ️ 금주(${thisStart}~${thisEnd})·차주(${nextStart}~${nextEnd}) 범위에 등록된 task 가 없습니다.\n (\`/meet\` 또는 \`/task\` 로 등록하면 잡힙니다.)\n`); return true; } chunk(view, `\n· 금주 ${thisWeek.length}건 · 차주 ${nextWeek.length}건 → 보고서 합성 중…\n`); let report = ''; try { report = await callLmSynthesis( buildWeeklyPrompt({ thisWeek, nextWeek, thisRange: [thisStart, thisEnd], nextRange: [nextStart, nextEnd] }), WEEKLY_SYSTEM, ); if (!report) throw new Error('LLM 응답이 비어 있습니다.'); } catch (e: any) { chunk(view, `\n⚠️ 보고서 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`); return true; } chunk(view, '\n' + report.trim() + '\n'); return true; } // ─── 등록 ───────────────────────────────────────────────────────────────── registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask }); registerSlashCommand({ name: '/weekly', description: '캘린더 task 기반 주간 보고서 — 차주 날짜 입력, 금주 자동 역산 (금주/차주 포맷)', handler: runWeekly }); 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 });