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>
This commit is contained in:
2026-06-04 16:12:33 +09:00
parent ebfce17b03
commit 2ea5185cd6
8 changed files with 257 additions and 335 deletions
+11
View File
@@ -1,5 +1,16 @@
# Astra Patch Notes
## v2.2.204 (2026-06-04)
### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주)
- **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼)
- `/weekly <차주 시작일> <차주 종료일>` — 입력 날짜는 **차주** 기준, **금주**(차주 시작 직전 7일)는 자동 역산해 함께 검색.
- Google Tasks 의 마감/완료일로 금주·차주를 **코드가 버킷팅**(예측 가능), 각 task 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 LLM 이 서술. `[태그]`(예: [이머시브]/[3D App]/[기타])별로 정렬된 `금주/차주` 포맷 출력.
- 신규 [weeklyPrompt.ts](src/features/datacollect/prompts/weeklyPrompt.ts) + [coordination.ts](src/features/teamops/handlers/coordination.ts) `runWeekly`.
### 🎯 `/meet` 정확도·실용성 개선
- **캘린더 task 상세 강화** — 액션 아이템 표에 "작업 상세" 열 추가, 캘린더 notes 가 *무슨 작업이고 무엇을 해야 하는지*를 담도록 재구성 ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts) · [handlers.ts](src/features/datacollect/handlers.ts) · [calendarHelpers.ts](src/features/datacollect/scheduling/calendarHelpers.ts)).
- **할루시네이션·발언 오귀속 억제** — "발언자 무시" 규칙을 "발언자 추적"으로 반전, 비선형 회의 재조립("인접 ≠ 연결") + 근거·정확성 규칙(추론 금지, 불명확 시 "확인 필요", 없는 정보 날조 금지) 추가.
## v2.2.203 (2026-06-01)
### 🐛 기업모드 dev-impl — 빈 깡통 파일 99% 발생 버그 (검출+자동 재작업 기본 ON)
**증상**: 사용자가 기획서 + 폴더 주고 "여기 개발해줘" 요청 → ASTRA 가 파일·폴더 만들고 "개발 완료" 보고 → 실제 파일을 열면 **class/함수 본문이 비어 있음** (`def foo(): pass` · 빈 class · imports only). 99% 확률 재발.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.203",
"version": "2.2.204",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
+14 -1
View File
@@ -696,7 +696,20 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
const { date, tentative } = resolveTaskDate(task.due, meetingDate, today);
if (tentative) tentativeCount++;
const evTitle = tentative ? `${task.work} (미확정)` : task.work;
const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`;
const detailLine = task.detail?.trim()
? task.detail.trim()
: '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)';
const notes = [
`■ 작업 상세`,
detailLine,
``,
`■ 맥락`,
`· 회의록: ${meetTitle}`,
`· 담당: ${task.owner || '(미지정)'}`,
`· 기한: ${task.due?.trim() || '(미표기)'}${date}${tentative ? ' (미확정·자동 산정)' : ''}`,
``,
`— Astra /meet 자동 등록`,
].join('\n');
const successes: string[] = [];
const failures: string[] = [];
+24 -10
View File
@@ -10,7 +10,8 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
(Actionable Minutes) .
# (Role)
- Fact Extractor: 사실만
- Fact Extractor: 녹취록에
- Attribution Tracker: 누가
- Decision Tracker: 결정
- Action Organizer: 실행
- Context Filter: 불필요한 ()
@@ -19,17 +20,26 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
1순위: 메타데이터 / 2순위: 녹취록 . .
# (Processing Flow)
1. Deconstruction , , ID는 .
2. Classification Fact / Discussion / Decision / Risk / Action .
3. Decision Logic
1. Speaker Tracking ID/ . ** .**
2. Topic Reclustering (ABZ A ). ** ** . · (** **).
3. Deconstruction , .
4. Classification Fact / Discussion / Decision / Risk / Action .
5. Decision Logic
- Decision
- + Action
- / Discussion
- Open Issue
4. Structuring , , .
- / Open Issue
6. Structuring , , .
# · (Grounding Rules , )
- ** .** , .
- ** .** "(발언 주체 불명확)" "~라는 의견이 제시됨" . .
- ** ···· .** "확인 필요" .
- , "(확인 필요)" .
- Decision은 '결정됨'. '논의 중' .
# (Validation)
점검한다: Decision은 / Action은 .
: Decision은 Action은 .
, · . .
[]
@@ -55,7 +65,7 @@ ${transcript}
:
### [ ]
- ****:
- ** **:
- ** **: "OOO: ~" . .
- ****: [ / / ]
## 2.
@@ -65,8 +75,12 @@ ${transcript}
## 4.
## 5.
| | | |
| --- | --- | --- |
. \`|\` 문자를 쓰지 말 것.
| | | | |
| --- | --- | --- | --- |
- ** **: . . ("검토", "확인" )
- ** **: **, , ** 2~3 (·· ·). ·· . "추가 확인 필요: …" . .
, .`;
}
@@ -0,0 +1,96 @@
/**
* `/weekly` LLM .
*
* 입력: 캘린더(Google Tasks) / task + task
* ···(= /meet·/task " / "). /
* **(coordination.ts) due/completed ** .
* * * **** .
*
* bullet task .
*/
export interface WeeklyTask {
title: string;
/** 'YYYY-MM-DD' 또는 '' (마감 미정). */
due: string;
status: 'needsAction' | 'completed';
/** 완료 시각 'YYYY-MM-DD' (status 'completed' 일 때만). */
completedYmd?: string;
notes?: string;
}
export interface WeeklyPromptInput {
thisWeek: WeeklyTask[];
nextWeek: WeeklyTask[];
thisRange: [string, string];
nextRange: [string, string];
}
/** /weekly 전용 시스템 프롬프트 — 기본(UX 분석가) 대신 주간 보고 작성자로 역할 고정. */
export const WEEKLY_SYSTEM =
'당신은 팀 주간 업무 보고서를 작성하는 PM 보조다. '
+ '제공된 task 데이터(제목·마감·상태·메모)에 있는 사실만으로 보고서를 쓴다. '
+ '메모에 없는 일정·숫자·고유명사·결정을 절대 지어내지 않으며, 모든 출력은 한국어로 작성한다.';
function serializeTasks(tasks: WeeklyTask[]): string {
if (tasks.length === 0) return '(해당 범위에 task 없음)';
return tasks.map((t, i) => {
const statusLabel = t.status === 'completed'
? `완료(${t.completedYmd || '시점 미상'})`
: '진행/예정';
const notes = (t.notes || '').trim();
const notesBlock = notes
? notes.split('\n').map((l) => ` ${l}`).join('\n')
: ' (메모 없음)';
return [
`${i + 1}. 제목: ${t.title}`,
` 마감: ${t.due || '미정'} | 상태: ${statusLabel}`,
` 메모:`,
notesBlock,
].join('\n');
}).join('\n\n');
}
export function buildWeeklyPrompt(input: WeeklyPromptInput): string {
const { thisWeek, nextWeek, thisRange, nextRange } = input;
return `# 임무 (Objective)
(Google Tasks) · , ** ** . .
# ( )
- / . [ ] [] , [ ] [] . ** .**
- 기간: ${thisRange[0]} ~ ${thisRange[1]} / 기간: ${nextRange[0]} ~ ${nextRange[1]}
# (Rules)
1. task : \`: [태그] 작업명 (M/D)\` 형식. 제목 앞의 \`[태그]\`(예: [이머시브], [3D App], [기타])는 그대로 유지한다.
- M/D (: 6/12). \`(6/4 완료)\`, 예정은 \`(6/8)\`, 마감이 차주인데 금주에 진행 중이면 \`(6/12 완료 예상)\`처럼 메모 근거가 있을 때만 표기.
- \`(미확정)\` 같은 자동 꼬리표는 빼고 작업명만 쓴다.
2. \`[태그]\`끼리 인접하도록 정렬한다.
3. task (\` - \`)으로 세부 내용을 2~4개 적는다. **이 세부 내용은 오직 해당 task 의 '메모'에서만** 끌어온다(작업 상세·맥락·기한 표기). 메모를 짧은 서술형으로 다듬되, 날짜·대상·수치는 메모에 적힌 그대로 인용한다.
4. ** .** "추가 확인 필요" "(확인 필요)" .
5. task \`: (해당 작업 없음)\` 한 줄만 적는다.
# ( · )
[]
[ ]
: [] (M/D)
-
-
: [] (M/D)
-
[]
[ ]
: [] (M/D)
-
---
[ ] (${thisWeek.length})
${serializeTasks(thisWeek)}
[ ] (${nextWeek.length})
${serializeTasks(nextWeek)}
, (·· ).`;
}
@@ -70,9 +70,13 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): {
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
}
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
export function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
const rows: { owner: string; work: string; due: string }[] = [];
/**
* "## 5. 액션 아이템" .
* 4 ( | | | ) () 3 ( | | )
* . 3 detail .
*/
export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string }[] {
const rows: { owner: string; work: string; detail: string; due: string }[] = [];
let inSection = false;
for (const line of report.split('\n')) {
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
@@ -83,7 +87,11 @@ export function parseActionItems(report: string): { owner: string; work: string;
if (cells.length < 3) continue;
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
if (cells.length >= 4) {
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3] });
} else {
rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2] });
}
}
return rows;
}
@@ -15,6 +15,8 @@ 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 전용 ──────────────────────────────────────────────
@@ -563,9 +565,100 @@ async function runStandup(arg: string, view: any, context?: vscode.ExtensionCont
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 });
+6 -319
View File
@@ -1,11 +1,13 @@
/**
* TeamOps Dashboards /morning · /evening · /cohort · /weekly (CEO · ).
* TeamOps Dashboards /morning · /evening · /cohort (CEO · ).
*
* v2.2.198 slashRouter.ts . cross-data (Tasks + customers +
* hire + runway + Chronicle ADR) // .
* hire + runway + Chronicle ADR) / .
* ( /weekly CEO v2.2.204 /weekly task /
* , coordination.ts .)
*
* `./_shared.ts` . dashboards helper (_isoWeek, _aggregateWeek,
* _morningActions ) .
* `./_shared.ts` . dashboards helper (_morningActions )
* .
*/
import * as vscode from 'vscode';
@@ -484,323 +486,8 @@ async function runCohort(arg: string, view: any): Promise<boolean> {
return true;
}
// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ────────────────────────────
function _isoWeek(d: Date): { year: number; week: number; label: string } {
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = (target.getUTCDay() + 6) % 7;
target.setUTCDate(target.getUTCDate() - dayNum + 3);
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000));
return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` };
}
interface WeeklyWindow {
startIso: string;
endIso: string;
startMs: number;
endMs: number;
label: string;
}
function _thisWeekWindow(now: Date = new Date()): WeeklyWindow {
const dayOfWeek = now.getDay();
const daysFromMonday = (dayOfWeek + 6) % 7;
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday);
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59);
const { label: yw } = _isoWeek(monday);
const startIso = monday.toISOString().slice(0, 10);
const endIso = sunday.toISOString().slice(0, 10);
const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`;
const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`;
return { startIso, endIso, startMs: monday.getTime(), endMs: sunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
}
function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow {
const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000);
const priorSunday = new Date(thisWeek.startMs - 1000);
const { label: yw } = _isoWeek(priorMonday);
const startIso = priorMonday.toISOString().slice(0, 10);
const endIso = priorSunday.toISOString().slice(0, 10);
const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`;
const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`;
return { startIso, endIso, startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
}
interface WeeklyAggregate {
taskCompleted: number;
taskByOwner: Map<string, number>;
customerEvents: number;
customerNewCount: number;
customerRenewCount: number;
customerRiskCount: number;
customerChurnCount: number;
customerNewMrr: number;
hireEvents: number;
hireMoved: number;
hireAdded: number;
hireHired: number;
runwayExpense: number;
runwayRevenue: number;
runwayLastCash: number | null;
runwayFirstCash: number | null;
adrCount: number;
}
function _aggregateWeek(
win: WeeklyWindow,
completedTasks: any[],
cevs: CustomerEvent[],
hevs: HireEvent[],
rs: RunwayEntry[],
adrs: { date: string; title: string }[],
): WeeklyAggregate {
const agg: WeeklyAggregate = {
taskCompleted: 0, taskByOwner: new Map(),
customerEvents: 0, customerNewCount: 0, customerRenewCount: 0,
customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0,
hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0,
runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null,
adrCount: 0,
};
for (const t of completedTasks) {
if (!t.completed) continue;
const ms = Date.parse(t.completed);
if (ms < win.startMs || ms > win.endMs) continue;
agg.taskCompleted++;
const { owner } = parseTaskOwner(t.title, t.notes);
const k = owner || '(미지정)';
agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1);
}
for (const e of cevs) {
const ms = Date.parse(e.timestamp);
if (ms < win.startMs || ms > win.endMs) continue;
agg.customerEvents++;
if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; }
else if (e.type === 'renew') agg.customerRenewCount++;
else if (e.type === 'risk') agg.customerRiskCount++;
else if (e.type === 'churn') agg.customerChurnCount++;
}
for (const e of hevs) {
const ms = Date.parse(e.timestamp);
if (ms < win.startMs || ms > win.endMs) continue;
agg.hireEvents++;
if (e.type === 'add') agg.hireAdded++;
else if (e.type === 'stage') agg.hireMoved++;
else if (e.type === 'hire') agg.hireHired++;
}
const cashInWin = rs
.filter((r) => r.type === 'snapshot')
.filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; })
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
if (cashInWin.length > 0) {
agg.runwayFirstCash = cashInWin[0].amount;
agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount;
}
for (const e of rs) {
const ms = Date.parse(e.timestamp);
if (ms < win.startMs || ms > win.endMs) continue;
if (e.type === 'expense') agg.runwayExpense += e.amount;
else if (e.type === 'revenue') agg.runwayRevenue += e.amount;
}
for (const a of adrs) {
if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++;
}
return agg;
}
function _deltaSymbol(now: number, prev: number): string {
if (prev === 0 && now === 0) return '→';
if (prev === 0) return `${now}`;
const diff = now - prev;
if (diff > 0) return `${diff}`;
if (diff < 0) return `${Math.abs(diff)}`;
return '→';
}
async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
const trimmed = arg.trim().toLowerCase();
if (trimmed === 'help' || trimmed === '?') {
chunk(view, [
'\n📅 **/weekly — 주간 리뷰 카드 (대표용)**',
'',
'사용법:',
' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비',
'',
'`/standup weekly` 와 차이:',
'- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)',
'- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)',
'',
'데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n',
].join('\n'));
return true;
}
const thisWeek = _thisWeekWindow();
const priorWeek = _priorWeekWindow(thisWeek);
chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`);
let completedTasks: any[] = [];
let tasksError: string | undefined;
if (context) {
try {
const res = await listTasks(context, { showCompleted: true, maxResults: 500 });
if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed');
else tasksError = res.error;
} catch (e: any) { tasksError = e?.message || String(e); }
}
const cevs = readCustomerEvents();
const hevs = readHireEvents();
const rs = readRunway();
const adrs: { date: string; title: string }[] = [];
try {
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0 && context) {
const cs = new ChronicleProjectStore(context);
const projects = cs.getAll();
for (const p of projects) {
const decisionsDir = path.join(p.recordRoot, 'decisions');
if (!fs.existsSync(decisionsDir)) continue;
const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md'));
for (const f of files) {
try {
const full = path.join(decisionsDir, f);
const stat = fs.statSync(full);
const d = new Date(stat.mtimeMs).toISOString().slice(0, 10);
const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' ');
adrs.push({ date: d, title });
} catch { /* skip */ }
}
}
}
} catch { /* ignore */ }
const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs);
const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs);
chunk(view, '\n## ✅ 이번 주 진척\n');
if (tasksError) chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
else if (aggNow.taskCompleted === 0) chunk(view, '- _완료된 작업 없음._\n');
else {
chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`);
const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]);
for (const [owner, n] of ranked) {
const prev = aggPrev.taskByOwner.get(owner) || 0;
chunk(view, `- **@${owner}**: ${n}${_deltaSymbol(n, prev)}\n`);
}
}
if (aggNow.customerEvents > 0) {
chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`);
if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`);
if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}\n`);
if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}\n`);
if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}\n`);
}
if (aggNow.hireEvents > 0) {
chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`);
if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}\n`);
if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}\n`);
if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}\n`);
}
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) {
chunk(view, '\n### 💰 재무\n');
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) {
const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue;
chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`);
}
if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) {
const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash;
chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)}${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`);
} else if (aggNow.runwayLastCash !== null) {
chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`);
}
}
if (aggNow.adrCount > 0) {
chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`);
const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5);
for (const a of weekAdrs) chunk(view, `- ${a.date}${a.title}\n`);
}
chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`);
chunk(view, `- 작업: ${aggNow.taskCompleted}${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`);
chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`);
chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`);
const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue;
const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue;
if (burnNow !== 0 || burnPrev !== 0) {
const diff = burnNow - burnPrev;
const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`);
}
chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`);
chunk(view, '\n## 🌅 다음 주 준비\n');
const customerStates = computeCustomerStates();
const upcoming = Array.from(customerStates.values())
.filter((c) => c.status !== 'churned')
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 14)
.sort((a, b) => (a.days as number) - (b.days as number));
if (upcoming.length > 0) {
chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`);
for (const { c, days } of upcoming.slice(0, 5)) {
const emoji = (days as number) <= 7 ? '🔴' : '🟡';
chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
}
}
let nextWeekDue = 0;
if (!tasksError && context) {
try {
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
if (res.ok) {
const startNext = new Date(thisWeek.endMs + 1000);
const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000);
const startIso = startNext.toISOString().slice(0, 10);
const endIso = endNext.toISOString().slice(0, 10);
nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length;
}
} catch { /* ignore */ }
}
if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}\n`);
const stalled = Array.from(computeCandidateStates().values())
.filter((c) => !TERMINAL_STAGES.has(c.stage))
.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
if (stalled.length > 0) {
chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
for (const c of stalled.slice(0, 3)) {
const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
}
}
const reflections = [
'이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?',
'이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?',
'이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.',
'이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?',
'이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?',
'이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?',
];
const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7));
const idx = weekKey % reflections.length;
chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`);
chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n');
return true;
}
// ─── 등록 ─────────────────────────────────────────────────────────────────
registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning });
registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening });
registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });
registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });