Files
connectai/src/features/teamops/handlers/coordination.ts
T
koriweb 2ea5185cd6 feat(weekly): /weekly 주간 보고서(금주/차주) 추가 + /meet 정확도 개선 (v2.2.204)
- /weekly: 차주 날짜 입력→금주 자동 역산, Google Tasks 기반 금주/차주 보고서.
  버킷팅은 코드(예측 가능), 포맷팅만 LLM. 신규 weeklyPrompt.ts + coordination.ts runWeekly.
- 기존 CEO /weekly 리뷰 카드(dashboards.ts) 제거 — 이름 충돌 해소, /weekly 일원화.
- /meet: 액션아이템에 '작업 상세' 열 추가, 캘린더 notes 가 실제 작업 내용을 담도록 재구성.
- /meet: 발언자 추적 복원 + 비선형 회의 재조립 + 근거/할루시네이션 억제 규칙으로 오귀속 감소.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:12:33 +09:00

666 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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;
}
// ─── /weekly — 캘린더 task 기반 주간 보고서 (금주/차주) ──────────────────
// `/weekly <차주시작일> <차주종료일>` — 입력한 날짜는 **차주** 기준.
// 금주(차주 시작 직전 7일)는 자동 역산해 함께 검색한다. 버킷팅은 due/completed
// 날짜로 코드가 처리(예측 가능), 서술 포맷팅만 LLM(meet 메모 → narrative bullet).
async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
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 });