2ea5185cd6
- /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>
666 lines
35 KiB
TypeScript
666 lines
35 KiB
TypeScript
/**
|
||
* 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 });
|