From 9ca95ab9970e599e03acd904562069c291558713 Mon Sep 17 00:00:00 2001 From: g1nation Date: Sat, 16 May 2026 22:07:06 +0900 Subject: [PATCH] v2.2.15: Astra Office Refactor & Multi-Service Integration --- .astra/project-context/architecture.md | 67 +- .astra/project-context/scan-cache.json | 300 +++- ...d46d2ca2057b05c488be1dcf439166ac5a9a1.json | 2 +- ...9f4f39d2bc368f77456c37b5eef9a94a66b5c.json | 2 +- ...5c7a44d7661af673b24e3f49551a7a2e50280.json | 2 +- ...adc543795e4b427b64540a49c9ab27c7fe213.json | 4 +- ...son => stress_conflict_1778936679262.json} | 20 +- PATCHNOTES.md | 12 + docs/ASTRA_OFFICE_REFACTOR.md | 198 +++ docs/records/ConnectAI/chronicle.config.json | 2 +- media/sidebar.css | 26 + media/sidebar.html | 15 + media/sidebar.js | 77 + package.json | 14 +- src/agent.ts | 279 ++++ src/extension.ts | 207 +++ src/features/astraOffice/index.ts | 18 + src/features/astraOffice/presenter.ts | 181 +++ src/features/astraOffice/schema.ts | 305 ++++ src/features/astraOffice/view/layoutSchema.ts | 180 +++ src/features/astraOffice/view/officeBody.ts | 21 + src/features/astraOffice/view/officeStyles.ts | 121 ++ src/features/astraOffice/view/panelHtml.ts | 26 + src/features/astraOffice/view/runtime.ts | 1254 ++++++++++++++++ src/features/calendar/calendarApi.ts | 205 +++ src/features/calendar/calendarCache.ts | 170 +++ src/features/calendar/icsParser.ts | 114 ++ src/features/calendar/index.ts | 32 + src/features/calendar/oauth.ts | 235 +++ src/features/company/agents.ts | 17 +- src/features/company/dispatcher.ts | 17 + src/features/company/pipelineTemplates.ts | 32 +- src/features/company/promptBuilder.ts | 54 + src/features/sheets/index.ts | 13 + src/features/sheets/sheetsApi.ts | 166 +++ src/features/tasks/index.ts | 13 + src/features/tasks/taskStore.ts | 245 ++++ src/sidebar/chatHandlers.ts | 46 + src/sidebarProvider.ts | 1301 ++--------------- src/utils.ts | 81 + tests/calendarApi.test.ts | 131 ++ tests/icsParser.test.ts | 134 ++ tests/officeSchema.test.ts | 241 +++ tests/pipelineTemplates.test.ts | 69 + tests/sheetsApi.test.ts | 113 ++ tests/taskStore.test.ts | 185 +++ 46 files changed, 5648 insertions(+), 1299 deletions(-) rename .astra/tests/stress/.astra/missions/{stress_conflict_1778905142797.json => stress_conflict_1778936679262.json} (82%) create mode 100644 docs/ASTRA_OFFICE_REFACTOR.md create mode 100644 src/features/astraOffice/index.ts create mode 100644 src/features/astraOffice/presenter.ts create mode 100644 src/features/astraOffice/schema.ts create mode 100644 src/features/astraOffice/view/layoutSchema.ts create mode 100644 src/features/astraOffice/view/officeBody.ts create mode 100644 src/features/astraOffice/view/officeStyles.ts create mode 100644 src/features/astraOffice/view/panelHtml.ts create mode 100644 src/features/astraOffice/view/runtime.ts create mode 100644 src/features/calendar/calendarApi.ts create mode 100644 src/features/calendar/calendarCache.ts create mode 100644 src/features/calendar/icsParser.ts create mode 100644 src/features/calendar/index.ts create mode 100644 src/features/calendar/oauth.ts create mode 100644 src/features/sheets/index.ts create mode 100644 src/features/sheets/sheetsApi.ts create mode 100644 src/features/tasks/index.ts create mode 100644 src/features/tasks/taskStore.ts create mode 100644 tests/calendarApi.test.ts create mode 100644 tests/icsParser.test.ts create mode 100644 tests/officeSchema.test.ts create mode 100644 tests/pipelineTemplates.test.ts create mode 100644 tests/sheetsApi.test.ts create mode 100644 tests/taskStore.test.ts diff --git a/.astra/project-context/architecture.md b/.astra/project-context/architecture.md index edc1194..eb11b3a 100644 --- a/.astra/project-context/architecture.md +++ b/.astra/project-context/architecture.md @@ -3,15 +3,15 @@ ## Snapshot -- **Workspace**: `ConnectAI` `v2.2.13` _(absolute path varies by environment; resolved from the active VS Code workspace)_ +- **Workspace**: `ConnectAI` `v2.2.14` _(absolute path varies by environment; resolved from the active VS Code workspace)_ - **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making. - **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner -- **Stats**: 224 source files, ~46,311 lines across 5 top-level modules. +- **Stats**: 248 source files, ~50,110 lines across 5 top-level modules. ## Last Refresh -- **Time**: 2026-05-16T04:18:55.379Z -- **Files newly analysed**: 2 -- **Files reused from cache**: 222 +- **Time**: 2026-05-16T13:04:11.625Z +- **Files newly analysed**: 6 +- **Files reused from cache**: 242 ## Directory Map ```mermaid @@ -37,11 +37,11 @@ mindmap > Arrows: which top-level module imports from which. ```mermaid flowchart LR - src["src/
110 files"] + src["src/
127 files"] media["media/
6 files"] - tests["tests/
27 files"] + tests["tests/
33 files"] core_py["core_py/
6 files"] - docs["docs/
75 files"] + docs["docs/
76 files"] tests --> src ``` @@ -55,19 +55,19 @@ flowchart LR > Imported by many other files — touching these has wide blast radius. - `src/utils.ts` — referenced by **49** files - `src/config.ts` — referenced by **16** files -- `src/features/company/types.ts` — referenced by **12** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta +- `src/features/company/types.ts` — referenced by **13** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/core/services.ts` — referenced by **10** files - `src/lib/paths.ts` — referenced by **10** files +- `src/agent.ts` — referenced by **7** files - `src/retrieval/lessonHelpers.ts` — referenced by **6** files · Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons - `src/skills/agentKnowledgeMap.ts` — referenced by **6** files -- `src/lib/engine.ts` — referenced by **6** files ## Modules -### `src/` — 110 files, ~31,312 lines +### `src/` — 127 files, ~33,943 lines **Sub-directories** -- `src/features/` (37) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 +- `src/features/` (54) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H - `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토 - `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 - `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I @@ -81,43 +81,43 @@ flowchart LR - `src/scaffolder/` (2) — Scaffolder template catalog. Templates are pure data — (projectName) => { [relativePath]: contents }. New templates are **Key files** -- `src/utils.ts` (279 lines) +- `src/utils.ts` (360 lines) - `src/config.ts` (298 lines) - `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta - `src/core/services.ts` (164 lines) - `src/lib/paths.ts` (151 lines) - `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so -- `src/sidebarProvider.ts` (5505 lines) +- `src/sidebarProvider.ts` (4141 lines) - `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic - `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다. - `src/skills/agentKnowledgeMap.ts` (374 lines) - `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons -- `src/agent.ts` (3260 lines) +- `src/agent.ts` (3509 lines) - `src/lib/engine.ts` (906 lines) -- `src/features/company/dispatcher.ts` (1419 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt +- `src/features/company/dispatcher.ts` (1435 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt - `src/features/approval/approvalQueue.ts` (129 lines) - `src/integrations/telegram/telegramClient.ts` (154 lines) +- `src/features/astraOffice/view/runtime.ts` (1254 lines) — 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정. +- `src/features/company/agents.ts` (211 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 +- `src/features/company/pixelOfficeState.ts` (286 lines) — Pixel Office — Agent Work Pipeline 상태를 시각화하는 UI Layer 전용 모듈. ─────────────────── 설계 원칙 ─────────────────── 1. Agent 핵심 판단 로직을 절대 바꾸지 않는다. Pipeline 진행, contract 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher - `src/features/company/sessionStore.ts` (231 lines) — Disk persistence for company-mode session artefacts. Each company turn produces a timestamped directory: /.astra/company/sessions/2026-05-13T21-29/ ├─ brief.md ← CEO's task decompositio - `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin - `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계 -- `src/features/company/agents.ts` (196 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 +- `src/extension.ts` (1179 lines) - `src/features/company/resumeStore.ts` (134 lines) — Disk persistence for company-turn resume state. 각 turn의 sessionDir 안에 resume.json을 두고, dispatcher가 매 의미 있는 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다. 재개 시점에는 이 파일을 읽어 nextIndex 부터 dispatch 재개. - `src/core/astraPath.ts` (50 lines) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토리를 해결하여, 사용자 프로젝트 루트가 아닌 ConnectAI 패키지 내부에 데이터를 저장합니다. 이 모듈은 AAL(Astra Autonomou -- `src/extension.ts` (972 lines) -- `src/features/company/intentAlignment.ts` (334 lines) — Intent Alignment — 사용자의 자연어 요청을 실행 가능한 작업 조건으로 변환. 사용자는 자기 의도와 배경지식이 에이전트에게 충분히 전달되었다고 착각하는 경향이 있다 (투명성의 착각·지식의 저주·공통 기반 부족). 그래서 에이전트가 즉시 작업에 돌입하면 사용자가 머릿속에 가진 것과 다른 결과를 만들어 낸다. 이 모듈은 그 격차를 메꾸는 한 단계 -- `src/features/projectChronicle/types.ts` (118 lines) -### `media/` — 6 files, ~6,766 lines +### `media/` — 6 files, ~6,863 lines **Key files** -- `media/sidebar.css` (1986 lines) — Stylesheet -- `media/sidebar.js` (3605 lines) -- `media/sidebar.html` (531 lines) — Astra +- `media/sidebar.css` (2016 lines) — Stylesheet +- `media/sidebar.js` (3657 lines) +- `media/sidebar.html` (546 lines) — Astra - `media/settings-panel.css` (210 lines) — Stylesheet - `media/settings-panel.html` (164 lines) — Astra Settings - `media/settings-panel.js` (270 lines) -### `tests/` — 27 files, ~4,938 lines +### `tests/` — 33 files, ~5,811 lines *Depends on*: `src/` **Sub-directories** @@ -136,19 +136,19 @@ flowchart LR - `tests/skillInjectionService.test.ts` (172 lines) — Unit tests for FileSystemSkillInjectionService. Strategy: drive the service against a real temp directory so path-traversal defenses and writeFileSync paths are exercised end-to-end. The service accep - `tests/dataProcessor.test.ts` (87 lines) — / - `tests/findBrainFilesCache.test.ts` (80 lines) — Unit tests for findBrainFiles TTL cache. +- `tests/officeSchema.test.ts` (241 lines) - `tests/paths.test.ts` (84 lines) — Unit tests for the centralized path resolver. - `tests/systemSpecs.test.ts` (90 lines) — Unit tests for SystemSpecs + HeuristicModelMemoryEstimator. Strategy: - HeuristicModelMemoryEstimator is pure — directly drive it with model ids. - NodeSystemSpecsProvider depends on os. so we test: a - `tests/transaction.test.ts` (68 lines) — / - `tests/vulnerability.test.ts` (60 lines) — / - `tests/brainIndex.test.ts` (107 lines) +- `tests/calendarApi.test.ts` (131 lines) - `tests/contextManager.test.ts` (129 lines) +- `tests/icsParser.test.ts` (134 lines) - `tests/lessonHelpers.test.ts` (191 lines) - `tests/projectChronicle.test.ts` (199 lines) - `tests/responseRecovery.test.ts` (151 lines) - `tests/scoring.test.ts` (134 lines) -- `tests/integration_retrieval.test.ts` (91 lines) -- `tests/mocks/vscode.js` (68 lines) -- `tests/projectChronicleGuardPrompt.test.ts` (52 lines) ### `core_py/` — 6 files, ~409 lines @@ -160,7 +160,7 @@ flowchart LR - `core_py/optimizer.py` (55 lines) - `core_py/queue_worker.py` (82 lines) -### `docs/` — 75 files, ~2,886 lines +### `docs/` — 76 files, ~3,084 lines **Sub-directories** - `docs/records/` (63) — Astra Project Chronicle Records @@ -169,6 +169,7 @@ flowchart LR **Key files** - `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서 - `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document +- `docs/ASTRA_OFFICE_REFACTOR.md` (198 lines) — Astra Office Refactor — Design Doc - `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan - `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview - `docs/records/ConnectAI/development/2026-05-03_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview @@ -191,12 +192,11 @@ flowchart LR - `docs/records/ConnectAI/bugs/BUG-0007-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... - `docs/records/ConnectAI/bugs/BUG-0008-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused... - `docs/records/ConnectAI/bugs/BUG-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ... -- `docs/records/ConnectAI/bugs/BUG-0010-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ... ## VS Code Extension Surface - **Extension ID**: `g1nation.astra` - **Activation events**: `onStartupFinished` -- **Commands** (24): +- **Commands** (27): - `g1nation.newChat` — Astra: New Chat - `g1nation.exportChat` — Astra: Export Chat as Markdown - `g1nation.explainSelection` — Astra: Explain Selected Code @@ -221,6 +221,9 @@ flowchart LR - `g1nation.company.manage` — Astra: Manage 1인 기업 Agents - `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder - `g1nation.company.pixelOffice.open` — Astra: Open Pixel Office (Full Screen) + - `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅 + - `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅 + - `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐 - **Configuration** (50 settings): - `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks. - `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response. @@ -319,7 +322,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한 **Designed for High-Performance Decision Making.** Copyright (C) **g1nation**. All rights reserved. -_Last auto-scan: 2026-05-16T04:18:55.379Z · signature `b1025fa`_ +_Last auto-scan: 2026-05-16T13:04:11.625Z · signature `b9442404`_ ## Purpose diff --git a/.astra/project-context/scan-cache.json b/.astra/project-context/scan-cache.json index 497782c..e013442 100644 --- a/.astra/project-context/scan-cache.json +++ b/.astra/project-context/scan-cache.json @@ -1,11 +1,11 @@ { "version": 1, - "generatedAt": "2026-05-16T04:18:55.389Z", + "generatedAt": "2026-05-16T13:04:11.635Z", "files": { "src/agent.ts": { - "mtimeMs": 1778902489000, - "size": 187410, - "lines": 3260, + "mtimeMs": 1778936503000, + "size": 201748, + "lines": 3509, "role": "", "imports": [ "src/utils", @@ -259,9 +259,9 @@ "imports": [] }, "src/extension.ts": { - "mtimeMs": 1778902489000, - "size": 50938, - "lines": 972, + "mtimeMs": 1778935438000, + "size": 61216, + "lines": 1179, "role": "", "imports": [ "src/utils", @@ -290,7 +290,8 @@ "src/retrieval", "src/retrieval/lessonHelpers", "src/skills/scopedBrainRetriever", - "src/integrations/telegram/conversationHistory" + "src/integrations/telegram/conversationHistory", + "src/features/calendar" ] }, "src/features/approval/approvalPanelProvider.ts": { @@ -321,10 +322,126 @@ "src/features/approval/approvalQueue" ] }, + "src/features/astraOffice/index.ts": { + "mtimeMs": 1778931477000, + "size": 789, + "lines": 18, + "role": "Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import.", + "imports": [ + "src/features/astraOffice/view/panelHtml", + "src/features/astraOffice/schema", + "src/features/astraOffice/presenter", + "src/features/astraOffice/view/layoutSchema" + ] + }, + "src/features/astraOffice/presenter.ts": { + "mtimeMs": 1778932441000, + "size": 7368, + "lines": 181, + "role": "Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로 변환하는 pure 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표. 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서: - sideb", + "imports": [ + "src/features/company/pixelOfficeState", + "src/features/company/types", + "src/features/astraOffice/schema" + ] + }, + "src/features/astraOffice/schema.ts": { + "mtimeMs": 1778931632000, + "size": 10631, + "lines": 305, + "role": "OfficeSnapshot — Astra Office 의 도메인 타입. 동시성 진실 (docs/ASTRAOFFICEREFACTOR.md §1): dispatcher 는 직렬이라 한 시점에 active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할. 이 세션에서는 타입 + validator + empty factory 만. 백", + "imports": [ + "src/features/company/pixelOfficeState" + ] + }, + "src/features/astraOffice/view/layoutSchema.ts": { + "mtimeMs": 1778931707000, + "size": 6475, + "lines": 180, + "role": "Pixel Office layout 저장 스키마 — workspaceState 의 g1nation.pixelOfficeLayout 키 에 저장되는 객체의 런타임 validator + v1 → v2 migration. 옛 runtime.ts 의 isV2Snap() heuristic 을 정식 schema 로 격상. webview 에서 받는 즉시 한 번 통과시키", + "imports": [] + }, + "src/features/astraOffice/view/officeBody.ts": { + "mtimeMs": 1778931340000, + "size": 1804, + "lines": 21, + "role": "자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등.", + "imports": [] + }, + "src/features/astraOffice/view/officeStyles.ts": { + "mtimeMs": 1778931340000, + "size": 14309, + "lines": 121, + "role": "자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등. design doc: docs/ASTRAOFFICEREFACTOR.md", + "imports": [] + }, + "src/features/astraOffice/view/panelHtml.ts": { + "mtimeMs": 1778931468000, + "size": 923, + "lines": 26, + "role": "Full Astra Office webview HTML composition. 옛 sidebarProvider.ts 의 거대한 pixelOfficePanelHtml 을 4개 파일로 분리한 entry. 이번 세션은 동작 동등 분리 만. 다음 세션에 mini view 와 공통 presenter 도입.", + "imports": [ + "src/features/astraOffice/view/officeStyles", + "src/features/astraOffice/view/officeBody", + "src/features/astraOffice/view/runtime" + ] + }, + "src/features/astraOffice/view/runtime.ts": { + "mtimeMs": 1778933617000, + "size": 58118, + "lines": 1254, + "role": "자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.", + "imports": [] + }, + "src/features/calendar/calendarApi.ts": { + "mtimeMs": 1778935879000, + "size": 8782, + "lines": 205, + "role": "Google Calendar API v3 — event create/list 호출. access token 은 caller 가 직접 주입한다. 만료 처리는 withFreshAccessToken 헬퍼가 refresh token 으로 갱신 → 호출 → 401 발생 시 한 번 더 갱신 + 재시도. 외부 라이브러리(googleapis) 안 씀 — Calendar ", + "imports": [ + "src/features/calendar/oauth", + "src/features/calendar/calendarCache" + ] + }, + "src/features/calendar/calendarCache.ts": { + "mtimeMs": 1778935391000, + "size": 8115, + "lines": 170, + "role": "Google Calendar (iCal) 캐시 — fetch + parse + 회사 shared/calendarcache.md 에 저장. Connectorigin 의 googlecalendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음. 사용자가 Google Calendar 설정 → \"비공개 주소(iCal 형식)\" 복", + "imports": [ + "src/features/calendar/icsParser" + ] + }, + "src/features/calendar/icsParser.ts": { + "mtimeMs": 1778934638000, + "size": 4823, + "lines": 114, + "role": "Minimal ICS parser — no library deps. Connectorigin 의 Python 버전을 그대로 옮겼고, 본 함수는 pure 라서 단위테스트가 쉽다. 처리 범위: - VEVENT 블록 추출 - line continuation (다음 줄이 공백 시작) 펼치기 - SUMMARY / DESCRIPTION / LOCATION / DTST", + "imports": [] + }, + "src/features/calendar/index.ts": { + "mtimeMs": 1778935400000, + "size": 561, + "lines": 32, + "role": "", + "imports": [ + "src/features/calendar/icsParser", + "src/features/calendar/calendarCache", + "src/features/calendar/oauth", + "src/features/calendar/calendarApi" + ] + }, + "src/features/calendar/oauth.ts": { + "mtimeMs": 1778935851000, + "size": 10705, + "lines": 235, + "role": "Google OAuth 2.0 — loopback (Desktop app) 흐름. Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1: redirect URI 를 허용한다. 본 모듈은: 1. ephemeral port 에 일회용 HTTP 서버 띄움 2. 사용자 브라우저로 Google 로", + "imports": [] + }, "src/features/company/agents.ts": { - "mtimeMs": 1778765657000, - "size": 13783, - "lines": 196, + "mtimeMs": 1778936611000, + "size": 15031, + "lines": 211, "role": "기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 ", "imports": [ "src/features/company/types" @@ -368,9 +485,9 @@ ] }, "src/features/company/dispatcher.ts": { - "mtimeMs": 1778902489000, - "size": 72121, - "lines": 1419, + "mtimeMs": 1778936524000, + "size": 73113, + "lines": 1435, "role": "Sequential dispatcher for 1인 기업 모드. Drives one company \"turn\": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt", "imports": [ "src/core/services", @@ -387,6 +504,8 @@ "src/features/company/telegramReport", "src/features/company/types", "src/features/company/intentAlignment", + "src/features/calendar", + "src/features/tasks", "src/config", "src/features/selfReflector/selfReflectorVerifier", "src/features/selfReflector/selfReflectorExecution", @@ -433,9 +552,9 @@ ] }, "src/features/company/pipelineTemplates.ts": { - "mtimeMs": 1778902489000, - "size": 13681, - "lines": 250, + "mtimeMs": 1778933936000, + "size": 15125, + "lines": 278, "role": "Built-in pipeline templates for 1인 기업 모드. These are blueprints, not data — they're surfaced in the manage panel's \"템플릿에서 추가\" dropdown so a non-developer user can stamp out a working pipeline in one cl", "imports": [ "src/features/company/types" @@ -456,9 +575,9 @@ "imports": [] }, "src/features/company/promptBuilder.ts": { - "mtimeMs": 1778902489000, - "size": 14499, - "lines": 260, + "mtimeMs": 1778936588000, + "size": 18723, + "lines": 314, "role": "System-prompt construction for company-mode agents. Each specialist needs a prompt that includes: - Their identity (name, role, specialty) + optional persona. - The action-tag contract (, ", "imports": [ "src/features/company/agents", @@ -668,6 +787,40 @@ "src/lib/paths" ] }, + "src/features/sheets/index.ts": { + "mtimeMs": 1778935930000, + "size": 237, + "lines": 13, + "role": "", + "imports": [ + "src/features/sheets/sheetsApi" + ] + }, + "src/features/sheets/sheetsApi.ts": { + "mtimeMs": 1778936110000, + "size": 7794, + "lines": 166, + "role": "Google Sheets API v4 — read / write / append. 토큰은 calendar 와 공유 (같은 OAuth 에 spreadsheets scope 포함). 별도 셋업 없음 — \"Astra: Google Calendar OAuth 연결\" 명령으로 한 번 로그인하면 둘 다 동작한다. 외부 라이브러리 안 씀 — Sheets API REST", + "imports": [ + "src/features/calendar/calendarApi" + ] + }, + "src/features/tasks/index.ts": { + "mtimeMs": 1778936468000, + "size": 225, + "lines": 13, + "role": "", + "imports": [ + "src/features/tasks/taskStore" + ] + }, + "src/features/tasks/taskStore.ts": { + "mtimeMs": 1778936462000, + "size": 9121, + "lines": 245, + "role": "Task tracker — .astra/company/shared/tasks.md 단일 파일. 설계 원칙: - 외부 DB 없이 사람이 읽을 수 있는 마크다운 테이블에 누적. git 으로 history 추적 가능. - 파싱은 regex 기반 (셀 구분자 |). 사용자가 손으로 편집해도 fault-tolerant. - 모든 task 는 안정적 id (t001,", + "imports": [] + }, "src/integrations/telegram/conversationHistory.ts": { "mtimeMs": 1778684811000, "size": 6273, @@ -1006,9 +1159,9 @@ ] }, "src/sidebar/chatHandlers.ts": { - "mtimeMs": 1778902489000, - "size": 34805, - "lines": 630, + "mtimeMs": 1778934077000, + "size": 37222, + "lines": 676, "role": "", "imports": [ "src/sidebarProvider", @@ -1030,9 +1183,9 @@ ] }, "src/sidebarProvider.ts": { - "mtimeMs": 1778904191000, - "size": 245949, - "lines": 5505, + "mtimeMs": 1778933999000, + "size": 188454, + "lines": 4141, "role": "", "imports": [ "src/utils", @@ -1053,6 +1206,7 @@ "src/features/projectArchitecture/intentDetector", "src/features/company", "src/core/services", + "src/features/astraOffice", "src/features/company/dispatcher" ] }, @@ -1113,9 +1267,9 @@ "imports": [] }, "src/utils.ts": { - "mtimeMs": 1778902489000, - "size": 12155, - "lines": 279, + "mtimeMs": 1778936575000, + "size": 15995, + "lines": 360, "role": "", "imports": [ "src/config", @@ -1144,23 +1298,23 @@ "imports": [] }, "media/sidebar.css": { - "mtimeMs": 1778902489000, - "size": 85387, - "lines": 1986, + "mtimeMs": 1778934126000, + "size": 86702, + "lines": 2016, "role": "Stylesheet", "imports": [] }, "media/sidebar.html": { - "mtimeMs": 1778902489000, - "size": 33230, - "lines": 531, + "mtimeMs": 1778934094000, + "size": 34587, + "lines": 546, "role": "Astra", "imports": [] }, "media/sidebar.js": { - "mtimeMs": 1778902489000, - "size": 206892, - "lines": 3605, + "mtimeMs": 1778934151000, + "size": 211710, + "lines": 3657, "role": "", "imports": [] }, @@ -1191,6 +1345,16 @@ "src/retrieval/brainIndex" ] }, + "tests/calendarApi.test.ts": { + "mtimeMs": 1778935637000, + "size": 5103, + "lines": 131, + "role": "", + "imports": [ + "src/features/calendar/calendarApi", + "src/agent" + ] + }, "tests/contextManager.test.ts": { "mtimeMs": 1778594523000, "size": 6545, @@ -1218,6 +1382,15 @@ "src/utils" ] }, + "tests/icsParser.test.ts": { + "mtimeMs": 1778934828000, + "size": 5011, + "lines": 134, + "role": "", + "imports": [ + "src/features/calendar/icsParser" + ] + }, "tests/integration_retrieval.test.ts": { "mtimeMs": 1777949141000, "size": 4017, @@ -1274,6 +1447,17 @@ "role": "", "imports": [] }, + "tests/officeSchema.test.ts": { + "mtimeMs": 1778932509000, + "size": 9884, + "lines": 241, + "role": "", + "imports": [ + "src/features/astraOffice/schema", + "src/features/astraOffice/view/layoutSchema", + "src/features/astraOffice/presenter" + ] + }, "tests/paths.test.ts": { "mtimeMs": 1778250990000, "size": 2590, @@ -1283,6 +1467,15 @@ "src/lib/paths" ] }, + "tests/pipelineTemplates.test.ts": { + "mtimeMs": 1778934174000, + "size": 2984, + "lines": 69, + "role": "", + "imports": [ + "src/features/company/pipelineTemplates" + ] + }, "tests/projectChronicle.test.ts": { "mtimeMs": 1778169995000, "size": 8359, @@ -1356,6 +1549,16 @@ "src/features/secondBrainTrace" ] }, + "tests/sheetsApi.test.ts": { + "mtimeMs": 1778936029000, + "size": 3913, + "lines": 113, + "role": "", + "imports": [ + "src/features/sheets/sheetsApi", + "src/agent" + ] + }, "tests/skillInjectionService.test.ts": { "mtimeMs": 1778681774000, "size": 6741, @@ -1383,6 +1586,16 @@ "src/system/specs" ] }, + "tests/taskStore.test.ts": { + "mtimeMs": 1778936645000, + "size": 7226, + "lines": 185, + "role": "", + "imports": [ + "src/features/tasks/taskStore", + "src/agent" + ] + }, "tests/telegramBot.test.ts": { "mtimeMs": 1778253785000, "size": 13012, @@ -1455,6 +1668,15 @@ "role": "", "imports": [] }, + "docs/ASTRA_OFFICE_REFACTOR.md": { + "mtimeMs": 1778931177000, + "size": 9395, + "lines": 198, + "role": "Astra Office Refactor — Design Doc", + "imports": [ + "docs/features/astraOffice" + ] + }, "docs/Advanced_Features_Implementation_Guide.md": { "mtimeMs": 1777808065000, "size": 1804, @@ -1638,7 +1860,7 @@ "imports": [] }, "docs/records/ConnectAI/chronicle.config.json": { - "mtimeMs": 1778902789000, + "mtimeMs": 1778932145000, "size": 416, "lines": 11, "role": "JSON configuration", diff --git a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json index fee384f..70d8f2d 100644 --- a/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json +++ b/.astra/tests/stress/.astra/cache/259a37934ead3910a8722b82054d46d2ca2057b05c488be1dcf439166ac5a9a1.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1778905142810, + "createdAt": 1778936679275, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json index 383041a..0ad5117 100644 --- a/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json +++ b/.astra/tests/stress/.astra/cache/65775be352df43297b63c7af59c9f4f39d2bc368f77456c37b5eef9a94a66b5c.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1778905142809, + "createdAt": 1778936679275, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json index f189851..4f0fa21 100644 --- a/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json +++ b/.astra/tests/stress/.astra/cache/6894d26c5b0a55d25d756a473225c7a44d7661af673b24e3f49551a7a2e50280.json @@ -1,5 +1,5 @@ { "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", - "createdAt": 1778905142808, + "createdAt": 1778936679274, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json index 826a7bd..7126ee7 100644 --- a/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json +++ b/.astra/tests/stress/.astra/cache/88cb61499f88ed38165b64bd3e8adc543795e4b427b64540a49c9ab27c7fe213.json @@ -1,5 +1,5 @@ { - "result": "---\nid: stress_conflict_1778905142797\ndate: 2026-05-16T04:19:02.810Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", - "createdAt": 1778905142810, + "result": "---\nid: stress_conflict_1778936679262\ndate: 2026-05-16T13:04:39.276Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (12ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (0ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (1ms)\n", + "createdAt": 1778936679276, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1778905142797.json b/.astra/tests/stress/.astra/missions/stress_conflict_1778936679262.json similarity index 82% rename from .astra/tests/stress/.astra/missions/stress_conflict_1778905142797.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1778936679262.json index 0fd7956..720f644 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1778905142797.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1778936679262.json @@ -1,8 +1,8 @@ { - "missionId": "stress_conflict_1778905142797", + "missionId": "stress_conflict_1778936679262", "status": "completed", - "startTime": "2026-05-16T04:19:02.797Z", - "totalElapsedMs": 13, + "startTime": "2026-05-16T13:04:39.262Z", + "totalElapsedMs": 14, "results": { "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", @@ -16,30 +16,30 @@ { "from": "idle", "to": "planner", - "durationMs": 11, + "durationMs": 12, "message": "전략 수립 중...", - "ts": "2026-05-16T04:19:02.808Z" + "ts": "2026-05-16T13:04:39.274Z" }, { "from": "planner", "to": "researcher", - "durationMs": 1, + "durationMs": 0, "message": "핵심 정보 수집 및 분석 중...", - "ts": "2026-05-16T04:19:02.809Z" + "ts": "2026-05-16T13:04:39.274Z" }, { "from": "researcher", "to": "writer", "durationMs": 1, "message": "최종 리포트 작성 및 편집 중...", - "ts": "2026-05-16T04:19:02.810Z" + "ts": "2026-05-16T13:04:39.275Z" }, { "from": "writer", "to": "completed", - "durationMs": 0, + "durationMs": 1, "message": "미션 완료", - "ts": "2026-05-16T04:19:02.810Z" + "ts": "2026-05-16T13:04:39.276Z" } ], "resilienceMetrics": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index bdaedb1..2cbbbb5 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,17 @@ # Astra Patch Notes +## v2.2.15 (2026-05-16) +### 💎 Astra Office Refactor & Multi-Service Integration (Calendar, Sheets, Tasks) +- **아스트라 오피스(Astra Office) 대규모 리팩토링:** 모놀리식 구조에서 기능 기반 모듈 구조(`src/features/astraOffice/`)로 전면 개편했습니다. `OfficeSnapshot` 데이터 모델 도입을 통해 에이전트의 활동 가시성과 레이아웃 관리 능력을 혁신적으로 개선했습니다. +- **캘린더(Calendar) 연동:** ICS 파일 분석 및 일정 관리 기능을 추가하여 에이전트가 일정 기반의 맥락을 인지할 수 있도록 했습니다. +- **시트(Sheets) 및 태스크(Tasks) 관리:** 구글 시트 스타일의 데이터 처리와 계층적 작업 관리(Task Store) 엔진을 탑재하여 생산성 도구로서의 기능을 강화했습니다. +- **에이전트 엔진 복원력 고도화:** 디스패처와 프롬프트 빌더의 정합성을 개선하고, 복합 파이프라인 수행 시의 안정성을 높였습니다. +- **신규 패키징:** `astra-2.2.15.vsix` 패키지를 통해 차세대 오피스 아키텍처와 새로운 연동 기능들을 배포합니다. + +--- + + + ## v2.2.14 (2026-05-16) ### 🎭 Advanced Pixel Office Customization & Face Directions - **캐릭터 방향성 고도화:** 기존 좌우(Left/Right) 방향에 더해 상하(Up/Down) 방향 스프라이트 지원을 추가하여 더욱 다채로운 사무실 연출이 가능해졌습니다. diff --git a/docs/ASTRA_OFFICE_REFACTOR.md b/docs/ASTRA_OFFICE_REFACTOR.md new file mode 100644 index 0000000..8076f81 --- /dev/null +++ b/docs/ASTRA_OFFICE_REFACTOR.md @@ -0,0 +1,198 @@ +# Astra Office Refactor — Design Doc + +작성: 2026-05-16 +대상 코드: `src/sidebarProvider.ts` (3860~4900 line 의 `_pixelOfficePanelHtml`), +`src/features/company/pixelOfficeState.ts`, +`media/sidebar.{html,js,css}` 의 mini Pixel Office 영역. + +--- + +## 0. 한 줄 진단 + +이 기능은 "UI 스킨" 단계는 통과했지만, "회사 운영 가시화 시스템" 으로 키우려면 (a) 데이터 모델, (b) 파일 구조, (c) mini/full 공용 presenter 가 빠져 있다. 본 문서는 그 세 개를 한 번에 정렬하는 합의안. + +--- + +## 1. 동시성 진실 (Truth) + +> 백엔드 dispatcher 는 한 turn 동안 **정확히 한 명의 agent 만 활성**이다. + +근거 — `src/features/company/dispatcher.ts:1-32`: +- 주석: "sequential dispatch keeps exactly one model resident at a time" +- 메인 loop `for (let i = startIdx; i < total; i++)` — 직렬 +- callback 순서: `agent-start` → AI call → `agent-done` → 다음 agent + +**결정**: 화면이 "여러 명이 동시에 일한다" 는 *연출* 은 가능하지만, **데이터 모델은 활성 agent 1명을 진실로 삼는다**. 비활성 agents 는 "지난 활동" 또는 "다음 차례" 로 표현. + +이게 안 지켜지면 미래의 진짜 병렬 dispatch 도입 시 스키마를 또 갈아엎어야 한다. + +--- + +## 2. 도메인 모델 — `OfficeSnapshot` + +### 2.1 현재 모델 (얇음) + +```ts +// pixelOfficeState.ts:49 +interface AgentWorkState { + agentId: string; + agentName: string; + status: AgentStatus; + currentTask?: string; + currentStep?: string; + // ... 그 외 단일 슬롯 필드 +} +``` + +문제: 한 시점의 active agent 만 표현. "직전 agent 가 무슨 일 했었나", "다음에 누가 차례", "현재 roster 가 누구누구", "회사 전반 phase" 같은 정보가 없다. + +### 2.2 새 모델 + +```ts +type AgentSnapshot = { + agentId: string; // company roster id (built-in 또는 custom) + agentName: string; // 표시 이름 + roleCategory: RoleCategory; // ceo | planner | researcher | designer | developer | qa | inspector | support | writer + status: AgentStatus; + currentStep?: string; + lastLog?: string; // 머리 위 말풍선 source + lastActivityAt: number; // 정렬용 epoch ms +}; + +type OfficeSnapshot = { + // 회사 전체 phase — 어디까지 진행됐는가 + phase: 'idle' | 'intake' | 'planning' | 'executing' | 'reviewing' | 'awaiting-approval' | 'reporting' | 'done' | 'error'; + // 활성 agent id — null 이면 idle. dispatcher 직렬성 보장. + activeAgentId: string | null; + // roster — 이 turn 에 참여 가능한 모든 agent (built-in + custom). 빈 책상 표시도 가능. + roster: AgentSnapshot[]; + // 현재 요구사항/계약 + task?: { goal: string; context?: string; format?: string; criteria?: string[]; openQuestions?: string[] }; + // pipeline 진행도 + pipeline?: { stages: Array<{ label: string; agentId?: string; status: 'done'|'active'|'pending' }>; index: number }; + // 승인 대기 + awaiting?: { kind: 'approval' | 'clarification'; questions: string[] }; + // 누적 활동 (ticker용) — 최근 N개 ring buffer + activity: Array<{ ts: number; agentId: string; text: string; kind?: 'ok'|'warn'|'err'|'info' }>; + // 직전 emit 으로부터 새 활동 N개 — 말풍선 트리거 + newBubbles: Array<{ agentId: string; text: string; type: 'event'|'warning'|'error'|'success'|'status' }>; + updatedAt: number; +}; +``` + +### 2.3 마이그레이션 + +기존 `AgentWorkState` → `OfficeSnapshot` 매핑: +- `agentId`, `agentName`, `status` → `activeAgentId`, `roster[active].agentName`, `roster[active].status`, `phase` 매핑 +- `bubbles[]` (별도 시퀀스) → `newBubbles` (snapshot 내부로 흡수) +- `activityItems` (별도 메시지) → `activity` (snapshot 내부로 흡수) +- `pipelineStages` → `pipeline.stages` + +**점진 마이그레이션**: presenter 단계가 두 모델 모두 받게. 백엔드는 `OfficeSnapshot` 로 옮기고, presenter 가 webview 로 보내는 message 는 한 종류 (`officeSnapshot`) 로 통합. 옛 `pixelOfficeUpdate` / `pixelOfficeActivity` 는 1버전 동안 호환 보존. + +--- + +## 3. 파일 구조 + +### 3.1 현재 + +``` +src/sidebarProvider.ts (~4900 lines, 그 중 800+ 가 inline office HTML/CSS/JS) +src/features/company/pixelOfficeState.ts (200줄, 타입 + bubble text pool) +media/sidebar.js (mini Pixel Office 렌더링 코드가 섞여있음) +media/sidebar.html (mini Pixel Office DOM 마크업) +media/sidebar.css (mini Pixel Office 스타일) +``` + +### 3.2 목표 + +``` +src/features/astraOffice/ +├── index.ts # public API (createOfficeView, presenter exports) +├── schema.ts # OfficeSnapshot 타입 정의 + 런타임 validator +├── presenter.ts # 백엔드 이벤트 → OfficeSnapshot 변환기 (pure) +├── view/ +│ ├── panelHtml.ts # full Astra Office webview HTML 생성 함수 (현재 _pixelOfficePanelHtml) +│ ├── officeView.css.ts # style 문자열 export +│ ├── runtime.ts # 브라우저 측 JS — 캐릭터/애니메이션/말풍선/ticker (string export) +│ ├── layoutEditor.ts # 브라우저 측 JS — 편집 모드/속성 패널 (string export) +│ └── layoutSchema.ts # 저장 layout 스키마 (v1/v2 validator, migration) +└── viewModel.ts # 공용 viewModel — mini/full 둘 다 입력으로 받음 + +media/sidebar.js (mini) # viewModel 기반 렌더로 정리 (별도 단계) +``` + +### 3.3 이번 세션 범위 + +- `view/panelHtml.ts`, `view/runtime.ts`, `view/layoutEditor.ts`, `view/officeView.css.ts` 추출 +- `schema.ts` 작성 (타입 + validator) +- `presenter.ts` stub (실제 변환은 다음 세션에서 옛 코드와 wiring) +- `index.ts` re-export + +sidebarProvider.ts 는 `import { renderAstraOfficeHtml } from './features/astraOffice'` 한 줄로 호출. 동작 변화 없음. mini view 통합은 다음 세션. + +--- + +## 4. Company Roster 통합 (#3, 다음 세션) + +현재: full view 의 `DEFAULT_STATIONS` 가 8개 role 을 하드코딩. company 의 custom agent 가 추가돼도 화면에 안 나타남. + +해결: layout 의 `desks[].agentKey` 를 `agentId` 로 (roleCategory 가 아닌). presenter 가 OfficeSnapshot 보낼 때 roster 의 모든 active agent 를 포함. webview 는 desk 를 그릴 때 agentId 로 매칭. 매핑 없는 agent 는 default 좌석에 자동 배치. + +alias map (`writer→planner` 등) 은 presenter 단계의 1회성 변환으로만 두고, 도메인 모델은 늘 정확한 `agentId` 를 들고 다닌다. + +--- + +## 5. mini/full 공용 presenter (#4, 다음 세션) + +현재: mini (media/sidebar.js) 와 full (sidebarProvider.ts inline) 이 각자 다른 규칙으로 message 를 해석. drift 발생 중. + +목표: +``` +백엔드 events + ↓ +presenter (pure) + ↓ OfficeSnapshot + ├→ mini view (media/sidebar.js : OfficeSnapshot → mini DOM) + └→ full view (features/astraOffice/view/runtime.ts : OfficeSnapshot → stage) +``` + +이번 세션은 `OfficeSnapshot` 만 정의. mini/full 의 wiring 은 다음 세션. + +--- + +## 6. Layout schema validation (#5, 이번 세션) + +현재: `workspaceState.get(key)` 가 `unknown` 반환. 깨진 데이터/이전 버전 데이터는 `_isV2Snap()` heuristic 으로만 분기. + +해결: `view/layoutSchema.ts` 에 두 가지: +1. `validateLayout(raw): LayoutV2 | null` — invalid 면 null, valid 면 정규화된 v2 객체 +2. `migrateLayout(raw): LayoutV2 | null` — v1 → v2 변환 + 누락 필드 기본값 채우기 + +webview 측에서 받는 즉시 한 번 통과시킨다. 통과 못 한 데이터는 default 로 fallback (시각적 reset). + +--- + +## 7. 이번 세션 deliverable + +- [x] 본 design doc (`docs/ASTRA_OFFICE_REFACTOR.md`) +- [ ] `src/features/astraOffice/` 디렉토리 생성 + 파일 분리 (sidebarProvider.ts 의 ~800줄 inline 추출) +- [ ] `schema.ts` — `OfficeSnapshot` 타입 + `validateOfficeSnapshot()` +- [ ] `view/layoutSchema.ts` — layout v2 validator + migration +- [ ] `presenter.ts` stub (export 인터페이스만, 내부 변환은 다음 세션) +- [ ] sidebarProvider.ts 는 `renderAstraOfficeHtml(cspSource, derivedBase)` 한 줄 호출로 정리. 동작 동등. + +**관찰 가능한 변화 (사용자 입장)**: 없음. 코드 구조만 정리. + +다음 세션에서: +- mini view 도 같은 presenter / viewModel 사용 (#4) +- company roster derive (#3) +- 옛 message types 제거 (`pixelOfficeUpdate` / `pixelOfficeActivity` → `officeSnapshot` 단일화) + +--- + +## 8. 비결정 (열린 질문) + +- mini view 와 full view 가 사실상 다른 UX (mini=status pane, full=2D stage) 인데 진짜로 *완전히 같은* viewModel 을 공유해야 하나? 현실적으로 mini 는 viewModel 의 subset (active agent + 최근 로그 + bubble 1개) 만 쓸 가능성. 다음 세션에서 mini 작업 시 결정. +- presenter 를 webview 로 push 하는 메시지 빈도. 현재 매 event 마다 broadcast → roster 가 커지면 비용 증가. coalescing window (e.g., 60ms) 가 필요할 수도. 다음 세션에서 결정. +- layout schema versioning — workspaceState 에 schema version key 를 별도로 저장할지, 객체 안에 `schema: 2` 만 넣고 끝낼지. 이번 세션은 후자 (현재 코드 호환), 별도 key 는 추후. diff --git a/docs/records/ConnectAI/chronicle.config.json b/docs/records/ConnectAI/chronicle.config.json index c8ea6fc..317538a 100644 --- a/docs/records/ConnectAI/chronicle.config.json +++ b/docs/records/ConnectAI/chronicle.config.json @@ -7,5 +7,5 @@ "corePurpose": "", "detailLevel": "standard", "createdAt": "2026-05-13T13:09:33.788Z", - "updatedAt": "2026-05-16T04:20:09.223Z" + "updatedAt": "2026-05-16T11:49:05.841Z" } diff --git a/media/sidebar.css b/media/sidebar.css index 43c2498..17c8698 100644 --- a/media/sidebar.css +++ b/media/sidebar.css @@ -1931,6 +1931,32 @@ /* compact toggle chips kept visible in the top bar (Trace / Web) */ .toggle-chip { font-size: 10.5px; padding: 0 8px; } + /* 스코프 프리셋 segmented control — 기업 모드 chip 옆에 붙임. + 세 버튼이 하나의 그룹으로 보이도록 inner border-radius 제거 + 사이 1px 분리. + hidden 속성은 HTML5 기본 inherit, JS 가 toggle. */ + .scope-seg { display: inline-flex; align-items: center; gap: 1px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 5px; background: rgba(255, 255, 255, 0.03); padding: 0; height: 24px; } + .scope-seg[hidden] { display: none; } + .scope-seg-btn { + background: transparent; + color: var(--muted, #94a3b8); + border: 0; + padding: 0 8px; + height: 100%; + font-size: 10.5px; + font-weight: 500; + cursor: pointer; + letter-spacing: 0.02em; + border-radius: 0; + } + .scope-seg-btn:hover { background: rgba(255, 255, 255, 0.05); color: var(--text, #e5e7eb); } + .scope-seg-btn.active { + background: rgba(99, 102, 241, 0.18); + color: #c7d2fe; + font-weight: 700; + } + .scope-seg-btn:first-child { border-top-left-radius: 4px; border-bottom-left-radius: 4px; } + .scope-seg-btn:last-child { border-top-right-radius: 4px; border-bottom-right-radius: 4px; } + /* a trigger + popover menu (Tools ▾ / Edit ▾ / Records ▾) */ .hdr-dropdown { position: relative; display: inline-flex; } .hdr-menu { diff --git a/media/sidebar.html b/media/sidebar.html index 9f09561..40d50b4 100644 --- a/media/sidebar.html +++ b/media/sidebar.html @@ -23,6 +23,21 @@ prompt edits · Knowledge Mix sliders). --> + +
diff --git a/media/sidebar.js b/media/sidebar.js index d9e5bec..e8a93c9 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -945,6 +945,7 @@ case 'companyStatus': { const v = msg.value || {}; renderCompanyChip(!!v.enabled, v.summary || ''); + renderScopeSeg(v.activePipelineId || null); break; } case 'companyIntentDecision': { @@ -965,11 +966,51 @@ break; } case 'pixelOfficeUpdate': { + // 새 path (officeSnapshot) 가 한 번이라도 도착했다면 옛 message 는 무시. + if (window.__officeSnapshotSeen) break; if (typeof window.__pixelOfficeApply === 'function') { window.__pixelOfficeApply(msg.value || {}); } break; } + case 'officeSnapshot': { + // refactor #E — mini view 도 OfficeSnapshot 수신. + // OfficeSnapshot 을 옛 {state, bubbles, config} payload 모양으로 변환 후 + // 기존 __pixelOfficeApply 재사용. dual-mode 안전 전환. + window.__officeSnapshotSeen = true; + const snap = msg.value; + if (!snap || typeof window.__pixelOfficeApply !== 'function') break; + const roster = Array.isArray(snap.roster) ? snap.roster : []; + const active = (snap.activeAgentId && roster.find((a) => a.agentId === snap.activeAgentId)) || roster[0]; + const phaseToStatus = (p) => { + if (p === 'awaiting-approval') return 'waiting_approval'; + if (p === 'reporting') return 'done'; + if (p === 'intake') return 'analyzing'; + return p || 'idle'; + }; + const synthetic = { + agentId: snap.activeAgentId || (active && active.agentId) || 'main', + agentName: (active && active.agentName) || 'Agent', + status: (active && active.status) || phaseToStatus(snap.phase), + currentTask: snap.task && snap.task.goal, + currentStep: active && active.currentStep, + message: snap.activeAgentId || '', + recentLogs: (active && active.lastLog) ? [active.lastLog] : [], + progress: snap.pipeline ? (snap.pipeline.index / Math.max(1, snap.pipeline.stages.length)) : 0, + pipelineStages: snap.pipeline && snap.pipeline.stages, + needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined, + awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined, + requirementContract: snap.task, + updatedAt: snap.updatedAt, + }; + window.__pixelOfficeApply({ + state: synthetic, + bubbles: Array.isArray(snap.newBubbles) ? snap.newBubbles : [], + // config 는 그대로 유지 — snapshot 에는 enabled 만 함의적, 옛 cfg 가 살아있음. + config: undefined, + }); + break; + } case 'companyAlignmentCard': { // Intent Alignment 카드. kind에 따라 4가지 모드: // - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용) @@ -1843,8 +1884,44 @@ ? `1인 기업 ON · ${summary || ''}`.trim() : '1인 기업 모드 OFF — 클릭해서 켜기', ); + // 스코프 프리셋 segmented control 도 기업 모드 ON 일 때만 노출. + const scopeSeg = document.getElementById('companyScopeSeg'); + if (scopeSeg) scopeSeg.hidden = !active; }; + // 활성 pipeline 의 id 가 어느 SCOPE 프리셋의 suggestedPipelineId 와 매칭되는지로 active 표시. + // companyStatus 메시지가 activePipelineId 를 보낼 때마다 호출. + const SCOPE_PRESET_TO_PIPELINE_ID = { + 'plan-only': 'plan-only', + 'dev-only': 'dev-only', + 'full-product-dev': 'product-dev', + }; + const renderScopeSeg = (activePipelineId) => { + const scopeSeg = document.getElementById('companyScopeSeg'); + if (!scopeSeg) return; + for (const btn of scopeSeg.querySelectorAll('.scope-seg-btn')) { + const tplId = btn.getAttribute('data-scope'); + const expected = SCOPE_PRESET_TO_PIPELINE_ID[tplId]; + btn.classList.toggle('active', !!activePipelineId && activePipelineId === expected); + } + }; + // Wire up clicks once. + const _scopeSeg = document.getElementById('companyScopeSeg'); + if (_scopeSeg && !_scopeSeg.dataset.wired) { + _scopeSeg.dataset.wired = '1'; + _scopeSeg.addEventListener('click', (e) => { + const btn = e.target && e.target.closest && e.target.closest('.scope-seg-btn'); + if (!btn) return; + const tplId = btn.getAttribute('data-scope'); + if (!tplId) return; + // Optimistic visual flip — backend ack 가 companyStatus 갱신으로 결과 확정. + for (const b of _scopeSeg.querySelectorAll('.scope-seg-btn')) { + b.classList.toggle('active', b === btn); + } + vscode.postMessage({ type: 'setCompanyScopePreset', templateId: tplId }); + }); + } + if (_companyChip) { _companyChip.onclick = () => { const isActive = _companyChip.classList.contains('active'); diff --git a/package.json b/package.json index 6e73078..a0a187b 100644 --- a/package.json +++ b/package.json @@ -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.14", + "version": "2.2.15", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -134,6 +134,18 @@ { "command": "g1nation.company.pixelOffice.open", "title": "Astra: Open Pixel Office (Full Screen)" + }, + { + "command": "g1nation.calendar.connect", + "title": "Astra: Google Calendar (iCal) 연결 📅" + }, + { + "command": "g1nation.calendar.refresh", + "title": "Astra: Google Calendar 새로고침 📅" + }, + { + "command": "g1nation.calendar.connectOAuth", + "title": "Astra: Google Calendar OAuth 연결 (쓰기) 🔐" } ], "keybindings": [ diff --git a/src/agent.ts b/src/agent.ts index 70bca35..b66ccbb 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -3303,6 +3303,185 @@ export class AgentExecutor { } catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); } } + // Action 9: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성. + // 형식: 설명 + // 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함), + // end | duration (분, default 60), location, all_day (true/false) + const calRegex = /]*)>([\s\S]*?)<\/create_calendar_event>/gi; + while ((match = calRegex.exec(aiMessage)) !== null) { + const attrs = _parseCalEventAttrs(match[1]); + const desc = match[2].trim(); + if (!attrs.title || !attrs.start) { + report.push(`❌ Calendar Event: title / start 누락`); + continue; + } + try { + const { createCalendarEvent } = await import('./features/calendar'); + const r = await createCalendarEvent(this.context, { + title: attrs.title, + start: attrs.start, + end: attrs.end, + durationMinutes: attrs.duration, + location: attrs.location, + description: desc || undefined, + allDay: attrs.allDay, + }); + if (r.ok) { + report.push(`📅 Calendar Event Created: ${r.event.title} (${r.event.startIso})`); + // chatHistory 에 결과 주입 — agent 가 다음 답변에서 link 인용 가능. + this.chatHistory.push({ + role: 'system', + content: `[Calendar event created] ${r.event.title} · ${r.event.startIso}\nLink: ${r.event.htmlLink}`, + internal: true, + }); + } else { + report.push(`❌ Calendar Event Failed: ${r.error}`); + } + } catch (err: any) { report.push(`❌ Calendar Event Error: ${err?.message ?? String(err)}`); } + } + + // Action 10/11/12: Google Sheets read / write / append. + // 모두 spreadsheet_id (속성) + range (속성) 필수. write/append 는 본문이 TSV. + // + // + // 이름\t나이\t직책 + // 민지\t29\t디자이너 + // + // + // 2026-05-21\t새 항목\t완료 + // + const sheetReadRegex = //]*?)\s*\/>/gi; + while ((match = sheetReadRegex.exec(aiMessage)) !== null) { + const a = _parseSheetAttrs(match[1]); + if (!a.spreadsheetId || !a.range) { + report.push(`❌ Sheet Read: spreadsheet_id / range 누락`); + continue; + } + try { + const { readSheetRange, valuesToMarkdownTable } = await import('./features/sheets'); + const r = await readSheetRange(this.context, a.spreadsheetId, a.range); + if (r.ok) { + const md = valuesToMarkdownTable(r.values); + report.push(`📊 Sheet Read: ${a.spreadsheetId.slice(0, 8)}…/${r.range} (${r.values.length} rows)`); + this.chatHistory.push({ + role: 'system', + content: `[Sheet read ${r.range}]\n${md}`, + internal: true, + }); + } else { + report.push(`❌ Sheet Read Failed: ${r.error}`); + } + } catch (err: any) { report.push(`❌ Sheet Read Error: ${err?.message ?? String(err)}`); } + } + const sheetWriteRegex = /]*)>([\s\S]*?)<\/write_sheet>/gi; + while ((match = sheetWriteRegex.exec(aiMessage)) !== null) { + const a = _parseSheetAttrs(match[1]); + const body = match[2]; + if (!a.spreadsheetId || !a.range) { + report.push(`❌ Sheet Write: spreadsheet_id / range 누락`); + continue; + } + try { + const { writeSheetRange, parseTsvBody } = await import('./features/sheets'); + const values = parseTsvBody(body); + if (values.length === 0) { + report.push(`❌ Sheet Write: 본문 비어있음`); + continue; + } + const r = await writeSheetRange(this.context, a.spreadsheetId, a.range, values); + if (r.ok) { + report.push(`📊 Sheet Write: ${r.updatedRange} (${r.updatedCells} cells)`); + } else { + report.push(`❌ Sheet Write Failed: ${r.error}`); + } + } catch (err: any) { report.push(`❌ Sheet Write Error: ${err?.message ?? String(err)}`); } + } + const sheetAppendRegex = /]*)>([\s\S]*?)<\/append_sheet>/gi; + while ((match = sheetAppendRegex.exec(aiMessage)) !== null) { + const a = _parseSheetAttrs(match[1]); + const body = match[2]; + if (!a.spreadsheetId || !a.range) { + report.push(`❌ Sheet Append: spreadsheet_id / range 누락`); + continue; + } + try { + const { appendSheetRows, parseTsvBody } = await import('./features/sheets'); + const values = parseTsvBody(body); + if (values.length === 0) { + report.push(`❌ Sheet Append: 본문 비어있음`); + continue; + } + const r = await appendSheetRows(this.context, a.spreadsheetId, a.range, values); + if (r.ok) { + report.push(`📊 Sheet Append: ${r.appendedRange} (${r.updatedCells} cells)`); + } else { + report.push(`❌ Sheet Append Failed: ${r.error}`); + } + } catch (err: any) { report.push(`❌ Sheet Append Error: ${err?.message ?? String(err)}`); } + } + + // Action 13/14/15: Task tracker — _shared/tasks.md 에 누적. + // 회의록·계획·작업 진척 추적의 단일 출처. status: open/in_progress/blocked/done. + // + // + // + const addTaskRegex = //]*?)\s*\/>/gi; + while ((match = addTaskRegex.exec(aiMessage)) !== null) { + const a = _parseTaskAttrs(match[1]); + if (!a.title) { report.push(`❌ Add Task: title 누락`); continue; } + try { + const { readTaskStore, writeTaskStore, addTask } = await import('./features/tasks'); + const store = readTaskStore(this.context); + const created = addTask(store, { + title: a.title, + owner: a.owner, + due: a.due, + notes: a.notes, + status: a.status, + }); + writeTaskStore(this.context, store); + report.push(`📋 Task Added: ${created.id} · ${created.title}${created.due ? ' (due ' + created.due + ')' : ''}`); + } catch (err: any) { report.push(`❌ Add Task Error: ${err?.message ?? String(err)}`); } + } + const updTaskRegex = //]*?)\s*\/>/gi; + while ((match = updTaskRegex.exec(aiMessage)) !== null) { + const a = _parseTaskAttrs(match[1]); + if (!a.id) { report.push(`❌ Update Task: id 누락`); continue; } + try { + const { readTaskStore, writeTaskStore, updateTask } = await import('./features/tasks'); + const store = readTaskStore(this.context); + const patch: any = {}; + if (a.title) patch.title = a.title; + if (a.owner) patch.owner = a.owner; + if (a.due) patch.due = a.due; + if (a.notes) patch.notes = a.notes; + if (a.status) patch.status = a.status; + const updated = updateTask(store, a.id, patch); + if (!updated) { + report.push(`❌ Update Task: ${a.id} 를 active 목록에서 못 찾음`); + } else { + writeTaskStore(this.context, store); + report.push(`📋 Task Updated: ${updated.id} → ${updated.status}${updated.due ? ' (due ' + updated.due + ')' : ''}`); + } + } catch (err: any) { report.push(`❌ Update Task Error: ${err?.message ?? String(err)}`); } + } + const compTaskRegex = //]*?)\s*\/>/gi; + while ((match = compTaskRegex.exec(aiMessage)) !== null) { + const a = _parseTaskAttrs(match[1]); + if (!a.id) { report.push(`❌ Complete Task: id 누락`); continue; } + try { + const { readTaskStore, writeTaskStore, completeTask } = await import('./features/tasks'); + const store = readTaskStore(this.context); + const closed = completeTask(store, a.id); + if (!closed) { + report.push(`❌ Complete Task: ${a.id} 못 찾음 (이미 done 이거나 존재 X)`); + } else { + writeTaskStore(this.context, store); + report.push(`✅ Task Done: ${closed.id} · ${closed.title}`); + } + } catch (err: any) { report.push(`❌ Complete Task Error: ${err?.message ?? String(err)}`); } + } + if (firstCreatedFile) { // Always open file results in the editor group (column 2) — the ConnectAI // sidebar lives in column 3 and we don't want freshly-written files to @@ -3369,3 +3548,103 @@ export class AgentExecutor { } } } + +/** + * / / 의 attribute 파서. + * 모든 필드 optional 로 받고 caller 가 필수 체크. status 는 정규화 (in_progress, 등). + */ +export function _parseTaskAttrs(raw: string): { + id?: string; + title?: string; + owner?: string; + due?: string; + notes?: string; + status?: import('./features/tasks').TaskStatus; +} { + const out: any = {}; + const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + const key = m[1].toLowerCase(); + const val = (m[2] ?? m[3] ?? m[4] ?? '').trim(); + if (!val) continue; + switch (key) { + case 'id': out.id = val; break; + case 'title': out.title = val; break; + case 'owner': out.owner = val; break; + case 'due': out.due = val; break; + case 'notes': out.notes = val; break; + case 'status': { + const v = val.toLowerCase().replace(/\s+/g, '_'); + if (v === 'in_progress' || v === 'inprogress' || v === 'progress') out.status = 'in_progress'; + else if (v === 'blocked' || v === 'block') out.status = 'blocked'; + else if (v === 'done' || v === 'completed' || v === 'closed') out.status = 'done'; + else out.status = 'open'; + break; + } + } + } + return out; +} + +/** + * / / 의 attribute 문자열을 객체로 파싱. + * spreadsheet_id / spreadsheetId / sheetId 모두 받는 — LLM 의 변형 emission 흡수. + */ +export function _parseSheetAttrs(raw: string): { spreadsheetId?: string; range?: string } { + const out: { spreadsheetId?: string; range?: string } = {}; + const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + const key = m[1].toLowerCase(); + const val = (m[2] ?? m[3] ?? m[4] ?? '').trim(); + if (!val) continue; + if (key === 'spreadsheet_id' || key === 'spreadsheetid' || key === 'sheet_id' || key === 'sheetid') { + out.spreadsheetId = val; + } else if (key === 'range') { + out.range = val; + } + } + return out; +} + +/** + * 의 attribute 문자열을 객체로 파싱. + * 큰따옴표 / 작은따옴표 / 따옴표 없이 (공백·`>` 으로 종료) 모두 허용 — LLM 이 어떤 + * 스타일로 emit 해도 통과시키기 위함. 단위테스트 가능하도록 export. + */ +export function _parseCalEventAttrs(raw: string): { + title?: string; + start?: string; + end?: string; + duration?: number; + location?: string; + allDay?: boolean; +} { + const out: any = {}; + // attr_name = "value" | 'value' | bare. `-` 포함 키 (all-day) 지원. + const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; + let m: RegExpExecArray | null; + while ((m = re.exec(raw)) !== null) { + const key = m[1].toLowerCase(); + const val = (m[2] ?? m[3] ?? m[4] ?? '').trim(); + if (!val) continue; + switch (key) { + case 'title': out.title = val; break; + case 'start': out.start = val; break; + case 'end': out.end = val; break; + case 'duration': { + const n = parseInt(val, 10); + if (!Number.isNaN(n) && n > 0) out.duration = n; + break; + } + case 'location': out.location = val; break; + case 'all_day': + case 'allday': + case 'all-day': + out.allDay = val === 'true' || val === '1' || val === 'yes'; + break; + } + } + return out; +} diff --git a/src/extension.ts b/src/extension.ts index 5d1d90b..f0b644a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -664,6 +664,22 @@ export async function activate(context: vscode.ExtensionContext) { // 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소. provider?.openPixelOfficePanel(); }), + // Google Calendar (iCal 읽기 전용) — 셋업 / 재연결 / 해제 / 즉시 새로고침. + vscode.commands.registerCommand('g1nation.calendar.connect', async () => { + await runConnectGoogleCalendarIcal(context); + }), + vscode.commands.registerCommand('g1nation.calendar.refresh', async () => { + const { refreshCalendarCache } = await import('./features/calendar'); + const r = await refreshCalendarCache(context); + if (r.ok) { + vscode.window.showInformationMessage(`📅 캘린더 ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.`); + } else { + vscode.window.showErrorMessage(r.error || 'Calendar 새로고침 실패'); + } + }), + vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => { + await runConnectGoogleCalendarOAuth(context); + }), ); /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind @@ -891,6 +907,197 @@ export async function deactivate() { } } +/** + * Google Calendar (iCal 읽기 전용) 연결 마법사. + * + * 사용자 흐름: + * 1. 이미 셋업 됐으면 "연결 해제 / URL 변경 / 지금 새로고침 / 취소" 선택지 노출 + * 2. 새로 셋업: Google Calendar 설정 페이지 외부 브라우저로 열고 → 비공개 iCal URL 입력 + * 3. 입력값을 globalState 에 저장 후 즉시 한 번 새로고침 실행 → 캐시 파일 생성 안내 + * + * OAuth 가 아닌 read-only iCal 만 — 셋업 3분, 토큰 관리 없음. + */ +async function runConnectGoogleCalendarIcal(context: vscode.ExtensionContext) { + const { readCalendarConfig, writeCalendarConfig, refreshCalendarCache } = + await import('./features/calendar'); + const cur = readCalendarConfig(context); + if (cur.icalUrl) { + const choice = await vscode.window.showInformationMessage( + `📅 이미 연결됨${cur.lastFetchAt ? ` (마지막 동기화: ${cur.lastFetchAt.slice(0, 16)})` : ''}`, + { modal: false }, + '지금 새로고침', + 'URL 변경', + '연결 해제', + '취소', + ); + if (!choice || choice === '취소') return; + if (choice === '지금 새로고침') { + const r = await refreshCalendarCache(context); + if (r.ok) vscode.window.showInformationMessage(`📅 ${r.count}개 일정 동기화 완료.`); + else vscode.window.showErrorMessage(r.error || '새로고침 실패'); + return; + } + if (choice === '연결 해제') { + await writeCalendarConfig(context, { icalUrl: '', lastFetchAt: undefined }); + vscode.window.showInformationMessage('Google Calendar 연결 해제됨. 캐시 파일은 그대로 둡니다.'); + return; + } + // URL 변경 → 아래 입력 흐름으로 fall through + } else { + const intro = await vscode.window.showInformationMessage( + '📅 Google Calendar 연결 (읽기 전용, 셋업 3분)\n\n비공개 iCal URL 1개만 있으면 됩니다. OAuth 없음.\n\n계속할까요?', + { modal: true }, + '시작', + 'Google Calendar 설정 페이지 열기', + '취소', + ); + if (!intro || intro === '취소') return; + if (intro === 'Google Calendar 설정 페이지 열기') { + await vscode.env.openExternal( + vscode.Uri.parse('https://calendar.google.com/calendar/u/0/r/settings'), + ); + const back = await vscode.window.showInformationMessage( + '1. 왼쪽에서 본인 캘린더 클릭 → "캘린더 통합" 섹션\n2. "비공개 주소(iCal 형식)" 옆 복사 버튼 클릭\n3. URL 복사한 뒤 ↓', + { modal: true }, + '복사함 — URL 붙여넣기', + '취소', + ); + if (back !== '복사함 — URL 붙여넣기') return; + } + } + + const url = await vscode.window.showInputBox({ + title: 'Google Calendar 비공개 iCal URL', + prompt: 'calendar.google.com/calendar/ical/.../private-XXX/basic.ics 형태', + placeHolder: 'https://calendar.google.com/calendar/ical/...', + value: cur.icalUrl, + password: true, + ignoreFocusOut: true, + validateInput: (v) => { + const t = (v || '').trim(); + if (!t) return '비어있어요'; + if (!/^https?:\/\//.test(t)) return 'http:// 또는 https:// 로 시작해야 합니다.'; + return null; + }, + }); + if (!url) return; + + await writeCalendarConfig(context, { icalUrl: url.trim() }); + const r = await refreshCalendarCache(context); + if (r.ok) { + vscode.window.showInformationMessage( + `✅ 연결 완료 — ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.\n\n이제 기업 모드에서 모든 에이전트가 다가오는 일정을 자동으로 참고합니다.`, + ); + } else { + vscode.window.showErrorMessage( + `URL 저장은 됐지만 첫 새로고침 실패: ${r.error}\n\nURL 이 정확한지 다시 확인해주세요.`, + ); + } +} + +/** + * Google Calendar OAuth (쓰기) 연결 마법사. + * + * iCal 마법사와 별도 — 이쪽은 agent 가 회의록 보고 자동으로 일정 *만들* 수 있게 한다. + * 셋업 5~10분: Google Cloud Console 에서 OAuth Client ID/Secret 발급 → 본 마법사가 + * loopback OAuth 흐름 실행 → refresh token 받아 globalState 저장. + */ +async function runConnectGoogleCalendarOAuth(context: vscode.ExtensionContext) { + const { readCalendarConfig, writeCalendarConfig, runOAuthLoopback, fetchUserEmail } = + await import('./features/calendar'); + const cur = readCalendarConfig(context); + const already = !!(cur.clientId && cur.clientSecret && cur.refreshToken); + if (already) { + const choice = await vscode.window.showInformationMessage( + `✅ 이미 OAuth 연결됨${cur.connectedAs ? ` (${cur.connectedAs})` : ''}`, + { modal: false }, + '재연결', + '연결 해제', + '취소', + ); + if (!choice || choice === '취소') return; + if (choice === '연결 해제') { + await writeCalendarConfig(context, { + clientId: undefined, clientSecret: undefined, refreshToken: undefined, + accessToken: undefined, accessTokenExpiresAt: undefined, + connectedAs: undefined, connectedAt: undefined, + }); + vscode.window.showInformationMessage( + 'OAuth 연결 해제. https://myaccount.google.com/permissions 에서도 권한을 직접 회수할 수 있습니다.', + ); + return; + } + // 재연결 → 아래 flow + } else { + const intro = await vscode.window.showInformationMessage( + '📅 Google Calendar 쓰기 연결 (OAuth, 5~10분)\n\n회의록을 받으면 agent 가 자동으로 일정을 생성하게 됩니다.\n\n1단계: Google Cloud Console 에서 OAuth Client ID 발급 (수동 클릭, 가이드 따라)\n2단계: ID + Secret 붙여넣기\n3단계: 브라우저 로그인', + { modal: true }, + '시작', + 'Cloud Console 먼저 열기', + '취소', + ); + if (!intro || intro === '취소') return; + if (intro === 'Cloud Console 먼저 열기') { + await vscode.env.openExternal(vscode.Uri.parse('https://console.cloud.google.com/apis/credentials')); + const back = await vscode.window.showInformationMessage( + '아래 절차 마치고 돌아오세요:\n\n1. 새 프로젝트 만들기 (또는 기존)\n2. APIs & Services → Library → "Google Calendar API" 활성화\n3. OAuth 동의 화면 — External, Test users 에 본인 이메일\n4. Credentials → Create OAuth 2.0 Client ID → "Desktop app"\n5. Client ID + Client Secret 복사', + { modal: true }, + '다 됐음 →', + '취소', + ); + if (back !== '다 됐음 →') return; + } + } + + const clientId = await vscode.window.showInputBox({ + title: 'Google OAuth Client ID', + prompt: 'Credentials 페이지에서 복사한 Client ID', + placeHolder: 'xxxxxxxx.apps.googleusercontent.com', + value: cur.clientId, + ignoreFocusOut: true, + validateInput: (v) => (v || '').trim() ? null : '비어있어요', + }); + if (!clientId) return; + const clientSecret = await vscode.window.showInputBox({ + title: 'Google OAuth Client Secret', + prompt: '같은 화면의 Client Secret', + placeHolder: 'GOCSPX-...', + value: cur.clientSecret, + password: true, + ignoreFocusOut: true, + validateInput: (v) => (v || '').trim() ? null : '비어있어요', + }); + if (!clientSecret) return; + + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: '🔐 Google 로그인 대기 중…', + cancellable: true, + }, async (progress, cancelToken) => { + progress.report({ message: '브라우저에서 Google 로그인 진행하세요 (최대 5분 대기)' }); + const result = await runOAuthLoopback(clientId.trim(), clientSecret.trim(), cancelToken); + if (!result.ok) { + vscode.window.showErrorMessage(`OAuth 실패: ${result.error}`); + return; + } + const email = await fetchUserEmail(result.accessToken); + await writeCalendarConfig(context, { + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + refreshToken: result.refreshToken, + accessToken: result.accessToken, + accessTokenExpiresAt: result.expiresAt, + calendarId: cur.calendarId ?? 'primary', + defaultDurationMinutes: cur.defaultDurationMinutes ?? 60, + connectedAs: email, + connectedAt: new Date().toISOString(), + }); + vscode.window.showInformationMessage( + `✅ Google Calendar 쓰기 연결 완료!${email ? ' (' + email + ')' : ''}\n\n이제 회의록을 보내거나 due 가 있는 작업을 알려주면 agent 가 자동으로 일정을 생성합니다.`, + ); + }); +} + async function runInitialSetup(context: vscode.ExtensionContext) { // 이미 사용자가 URL을 설정했다면 자동 감지를 스킵 const existingUrl = vscode.workspace.getConfiguration('g1nation').get('ollamaUrl'); diff --git a/src/features/astraOffice/index.ts b/src/features/astraOffice/index.ts new file mode 100644 index 0000000..e194d70 --- /dev/null +++ b/src/features/astraOffice/index.ts @@ -0,0 +1,18 @@ +// Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema +// 도 같은 entry 로 노출 예정. +// +// 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import. + +export { renderAstraOfficePanelHtml } from './view/panelHtml'; +export type { AstraOfficePanelAssets } from './view/panelHtml'; +export type { + OfficeSnapshot, + OfficeAgentSnapshot, + OfficePhase, + OfficeActivityItem, + OfficeBubbleSeed, +} from './schema'; +export { validateOfficeSnapshot, makeEmptyOfficeSnapshot } from './schema'; +export { presentOfficeSnapshot } from './presenter'; +export type { LayoutV2, OfficeDeskCell, OfficeProp } from './view/layoutSchema'; +export { validateLayout, migrateLayout } from './view/layoutSchema'; diff --git a/src/features/astraOffice/presenter.ts b/src/features/astraOffice/presenter.ts new file mode 100644 index 0000000..46bbdb2 --- /dev/null +++ b/src/features/astraOffice/presenter.ts @@ -0,0 +1,181 @@ +/** + * Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로 + * 변환하는 *pure* 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표. + * + * 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서: + * - sidebarProvider 의 `_pixelOfficeBroadcast` 가 옛 message 대신 OfficeSnapshot 송신 + * - mini view (media/sidebar.js) 와 full view (features/astraOffice/view/runtime.ts) + * 둘 다 `officeSnapshot` message 를 받아 자기 식대로 렌더 + * + * 이번 세션에 옛 message 와 OfficeSnapshot 을 *동시에* 보낼 수도 있게 — 호환 모드. 다음 + * 세션에서 옛 message 제거. + */ + +import type { AgentWorkState, AgentBubble } from '../company/pixelOfficeState'; +import type { CompanyState } from '../company/types'; +import { + type OfficeSnapshot, + type OfficeAgentSnapshot, + type OfficePhase, + type OfficeActivityItem, + type OfficeBubbleSeed, + makeEmptyOfficeSnapshot, +} from './schema'; + +/** mini/full view 가 받는 message envelope. */ +export interface OfficeSnapshotMessage { + type: 'officeSnapshot'; + value: OfficeSnapshot; +} + +/** 옛 AgentWorkState.status → OfficePhase 매핑. */ +const STATUS_TO_PHASE: Record = { + idle: 'idle', + intake: 'intake', + analyzing: 'planning', + need_clarification: 'awaiting-approval', + contract_ready: 'planning', + planning: 'planning', + executing: 'executing', + reviewing: 'reviewing', + waiting_approval: 'awaiting-approval', + error: 'error', + done: 'done', +}; + +/** agentId 가 alias 면 정식 id 로 정규화. dispatcher / agents.ts 와 같은 규칙. */ +const AGENT_ALIASES: Record = { + writer: 'writer', + editor: 'designer', + secretary: 'support', + business: 'inspector', +}; + +export function normalizeAgentId(rawAgentId: string | undefined): string | null { + if (!rawAgentId) return null; + const lower = rawAgentId.toLowerCase(); + return AGENT_ALIASES[lower] ?? lower; +} + +/** + * 옛 입력들을 합쳐 새 OfficeSnapshot 을 만든다. 입력 중 일부가 undefined 라도 안전. + * + * 이번 세션 stub: 옛 AgentWorkState 의 *단일 슬롯* 정보로 roster 1개 짜리 snapshot 만 + * 생성. 다음 세션에서 CompanyState 의 active roster 전체로 확장. + */ +export function presentOfficeSnapshot(input: { + activeState?: AgentWorkState; + recentBubbles?: AgentBubble[]; + recentActivity?: OfficeActivityItem[]; + company?: CompanyState; + /** + * 이 turn 의 active agent roster — `listActiveAgentsByCategory` 결과를 평탄화해서 전달. + * 빈 배열/undefined 면 옛 동작 (active agent 1명만) 으로 fallback. + */ + roster?: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }>; +}): OfficeSnapshot { + const snap = makeEmptyOfficeSnapshot(); + const { activeState, recentBubbles, recentActivity, roster: rosterInput } = input; + + if (activeState) { + const phase = STATUS_TO_PHASE[activeState.status] ?? 'idle'; + snap.phase = phase; + snap.activeAgentId = normalizeAgentId(activeState.agentId); + const activeId = snap.activeAgentId; + // Roster: + // - rosterInput 이 주어지면 (#G) 회사 전체 active agent 사용. active agent 만 + // activeState 의 상태로 표시하고 나머지는 idle. + // - 없으면 (옛 caller) 활성 agent 1명만 fallback. + if (rosterInput && rosterInput.length > 0) { + const now = Date.now(); + snap.roster = rosterInput.map((r) => { + const isActive = activeId !== null && r.agentId === activeId; + return { + agentId: r.agentId, + agentName: r.agentName, + roleCategory: r.roleCategory, + status: isActive ? activeState.status : 'idle', + currentStep: isActive ? activeState.currentStep : undefined, + lastLog: isActive ? (activeState.recentLogs ?? []).slice(-1)[0] : undefined, + lastActivityAt: isActive ? (activeState.updatedAt ?? now) : 0, + }; + }); + } else { + const role = _inferRoleCategory(activeState.agentId); + const agent: OfficeAgentSnapshot = { + agentId: snap.activeAgentId ?? activeState.agentId, + agentName: activeState.agentName ?? activeState.agentId, + roleCategory: role, + status: activeState.status, + currentStep: activeState.currentStep, + lastLog: (activeState.recentLogs ?? []).slice(-1)[0], + lastActivityAt: activeState.updatedAt ?? Date.now(), + }; + snap.roster = [agent]; + } + if (activeState.currentTask) { + snap.task = { + goal: activeState.currentTask, + criteria: activeState.requirementContract?.criteria, + openQuestions: activeState.requirementContract?.openQuestions, + format: activeState.requirementContract?.format, + context: activeState.requirementContract?.context, + }; + } + if (activeState.pipelineStages) { + snap.pipeline = { + stages: activeState.pipelineStages.map((s) => ({ + label: s.label, + agentId: s.agent, + status: s.status, + })), + index: activeState.pipelineStages.findIndex((s) => s.status === 'active'), + }; + } + if (activeState.needUserInput?.length || activeState.awaitingApproval) { + snap.awaiting = { + kind: activeState.awaitingApproval ? 'approval' : 'clarification', + questions: activeState.awaitingApproval + ? [activeState.awaitingApproval] + : (activeState.needUserInput ?? []), + }; + } + } + + if (recentActivity?.length) snap.activity = recentActivity.slice(-32); + + if (recentBubbles?.length) { + snap.newBubbles = recentBubbles + .map((b) => _toBubbleSeed(b)) + .filter((b): b is OfficeBubbleSeed => b !== null); + } + + snap.updatedAt = Date.now(); + return snap; +} + +// ── helpers ── + +function _inferRoleCategory(rawAgentId: string | undefined): OfficeAgentSnapshot['roleCategory'] { + if (!rawAgentId) return 'support'; + const id = rawAgentId.toLowerCase(); + if (id.includes('ceo')) return 'ceo'; + if (id.includes('plan')) return 'planner'; + if (id.includes('research')) return 'researcher'; + if (id.includes('design')) return 'designer'; + if (id.includes('writer')) return 'writer'; + if (id.includes('editor')) return 'designer'; + if (id.includes('developer') || id.includes('dev')) return 'developer'; + if (id.includes('qa')) return 'qa'; + if (id.includes('inspect') || id.includes('business')) return 'inspector'; + return 'support'; +} + +function _toBubbleSeed(b: AgentBubble): OfficeBubbleSeed | null { + if (!b || !b.text) return null; + return { + agentId: normalizeAgentId(b.agentId) ?? b.agentId, + text: b.text, + type: (b.type as OfficeBubbleSeed['type']) ?? 'status', + }; +} diff --git a/src/features/astraOffice/schema.ts b/src/features/astraOffice/schema.ts new file mode 100644 index 0000000..dc20ce8 --- /dev/null +++ b/src/features/astraOffice/schema.ts @@ -0,0 +1,305 @@ +/** + * OfficeSnapshot — Astra Office 의 도메인 타입. + * + * 동시성 진실 (docs/ASTRA_OFFICE_REFACTOR.md §1): dispatcher 는 직렬이라 한 시점에 + * active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할. + * + * 이 세션에서는 *타입 + validator + empty factory* 만. 백엔드 emit 측 wiring 은 + * 다음 세션에서 단계적으로 옮긴다. 현재는 옛 AgentWorkState/CompanyTurnEvent 가 + * presenter 입력으로 살아있고, 출력만 OfficeSnapshot. + */ + +import type { AgentStatus } from '../company/pixelOfficeState'; + +export type OfficePhase = + | 'idle' + | 'intake' + | 'planning' + | 'executing' + | 'reviewing' + | 'awaiting-approval' + | 'reporting' + | 'done' + | 'error'; + +/** 한 명의 agent 가 한 시점에 가진 상태. */ +export interface OfficeAgentSnapshot { + /** company roster 의 정식 id (built-in 또는 custom). */ + agentId: string; + /** 표시 이름. */ + agentName: string; + /** 책상 색깔 / 매핑에 쓰이는 role category. */ + roleCategory: + | 'ceo' + | 'planner' + | 'researcher' + | 'designer' + | 'developer' + | 'qa' + | 'inspector' + | 'support' + | 'writer'; + status: AgentStatus; + /** "검수 라운드 2/3", "타입 누락" 같은 짧은 부가 정보. */ + currentStep?: string; + /** 머리 위 말풍선 source. presenter 가 비워서 보내면 webview 는 풍선 안 띄움. */ + lastLog?: string; + /** 정렬 / 신선도 판정. */ + lastActivityAt: number; +} + +export interface OfficeActivityItem { + /** epoch ms. */ + ts: number; + agentId: string; + text: string; + kind?: 'ok' | 'warn' | 'err' | 'info'; +} + +/** webview 가 풍선을 띄울 때 참고할 hint. presenter 가 매 update 마다 새 풍선 후보를 만들어 보냄. */ +export interface OfficeBubbleSeed { + agentId: string; + text: string; + /** 색깔/스타일. presenter 가 status/event/warning/error/success 중 분류. */ + type: 'status' | 'event' | 'warning' | 'error' | 'success'; +} + +export interface OfficeSnapshot { + /** 회사 전체 phase. activeAgent 가 어떤 단계에 있는지와 분리해서 추적. */ + phase: OfficePhase; + /** 활성 agent id — 직렬 dispatch 가정. null 이면 idle. */ + activeAgentId: string | null; + /** 이 turn 에 참여 가능한 모든 agent. webview 의 책상 그리드 source. */ + roster: OfficeAgentSnapshot[]; + /** 현재 요구사항 / 계약 요약. */ + task?: { + goal: string; + context?: string; + format?: string; + criteria?: string[]; + openQuestions?: string[]; + }; + /** 파이프라인 모드의 stage 진행도. */ + pipeline?: { + stages: Array<{ + label: string; + agentId?: string; + status: 'done' | 'active' | 'pending'; + }>; + index: number; + }; + /** 승인 / 추가 정보 대기. */ + awaiting?: { + kind: 'approval' | 'clarification'; + questions: string[]; + }; + /** 누적 활동 ring buffer — webview ticker 의 source. */ + activity: OfficeActivityItem[]; + /** 이 update 사이클에서 새로 생긴 풍선 후보. presenter 가 매 emit 마다 갱신. */ + newBubbles: OfficeBubbleSeed[]; + /** 디버깅/정렬용. */ + updatedAt: number; +} + +const VALID_PHASES: ReadonlySet = new Set([ + 'idle', + 'intake', + 'planning', + 'executing', + 'reviewing', + 'awaiting-approval', + 'reporting', + 'done', + 'error', +]); + +const VALID_ROLES: ReadonlySet = new Set([ + 'ceo', + 'planner', + 'researcher', + 'designer', + 'developer', + 'qa', + 'inspector', + 'support', + 'writer', +]); + +const VALID_STATUSES: ReadonlySet = new Set([ + 'idle', + 'intake', + 'analyzing', + 'need_clarification', + 'contract_ready', + 'planning', + 'executing', + 'reviewing', + 'waiting_approval', + 'error', + 'done', +]); + +const VALID_BUBBLE_TYPES = new Set([ + 'status', + 'event', + 'warning', + 'error', + 'success', +]); + +const VALID_KINDS = new Set>([ + 'ok', + 'warn', + 'err', + 'info', +]); + +/** + * 런타임 schema validation. 임의의 unknown 입력을 받아 *완전한 OfficeSnapshot* 또는 + * null 을 반환한다. 누락된 필드는 안전한 기본값으로 채운다. 잘못된 enum 값은 'idle'/ + * 기본 role 로 fall through. 호출자는 null 이면 default snapshot 으로 폴백 권장. + */ +export function validateOfficeSnapshot(raw: unknown): OfficeSnapshot | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + + const phaseRaw = String(r.phase ?? 'idle'); + const phase: OfficePhase = (VALID_PHASES as ReadonlySet).has(phaseRaw) + ? (phaseRaw as OfficePhase) + : 'idle'; + + const activeAgentId = + typeof r.activeAgentId === 'string' && r.activeAgentId.length > 0 + ? r.activeAgentId + : null; + + const rosterRaw = Array.isArray(r.roster) ? r.roster : []; + const roster: OfficeAgentSnapshot[] = rosterRaw + .map((a) => _validateAgent(a)) + .filter((a): a is OfficeAgentSnapshot => a !== null); + + const taskRaw = r.task as Record | undefined; + const task = taskRaw && typeof taskRaw === 'object' && typeof taskRaw.goal === 'string' + ? { + goal: taskRaw.goal, + context: _optStr(taskRaw.context), + format: _optStr(taskRaw.format), + criteria: Array.isArray(taskRaw.criteria) + ? taskRaw.criteria.filter((x): x is string => typeof x === 'string') + : undefined, + openQuestions: Array.isArray(taskRaw.openQuestions) + ? taskRaw.openQuestions.filter((x): x is string => typeof x === 'string') + : undefined, + } + : undefined; + + const pipelineRaw = r.pipeline as Record | undefined; + const pipeline = pipelineRaw && Array.isArray(pipelineRaw.stages) + ? { + stages: pipelineRaw.stages + .map((s) => _validateStage(s)) + .filter((s): s is { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } => s !== null), + index: typeof pipelineRaw.index === 'number' ? pipelineRaw.index : 0, + } + : undefined; + + const awaitingRaw = r.awaiting as Record | undefined; + const awaiting = awaitingRaw && (awaitingRaw.kind === 'approval' || awaitingRaw.kind === 'clarification') + ? { + kind: awaitingRaw.kind as 'approval' | 'clarification', + questions: Array.isArray(awaitingRaw.questions) + ? awaitingRaw.questions.filter((x): x is string => typeof x === 'string') + : [], + } + : undefined; + + const activity: OfficeActivityItem[] = Array.isArray(r.activity) + ? r.activity + .map((it) => _validateActivity(it)) + .filter((it): it is OfficeActivityItem => it !== null) + : []; + + const newBubbles: OfficeBubbleSeed[] = Array.isArray(r.newBubbles) + ? r.newBubbles + .map((b) => _validateBubble(b)) + .filter((b): b is OfficeBubbleSeed => b !== null) + : []; + + const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : Date.now(); + + return { phase, activeAgentId, roster, task, pipeline, awaiting, activity, newBubbles, updatedAt }; +} + +export function makeEmptyOfficeSnapshot(): OfficeSnapshot { + return { + phase: 'idle', + activeAgentId: null, + roster: [], + activity: [], + newBubbles: [], + updatedAt: Date.now(), + }; +} + +// ─────────── helpers ─────────── + +function _optStr(v: unknown): string | undefined { + return typeof v === 'string' ? v : undefined; +} + +function _validateAgent(raw: unknown): OfficeAgentSnapshot | null { + if (!raw || typeof raw !== 'object') return null; + const a = raw as Record; + if (typeof a.agentId !== 'string' || !a.agentId) return null; + + const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet).has(a.roleCategory) + ? (a.roleCategory as OfficeAgentSnapshot['roleCategory']) + : 'support'; + const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet).has(a.status) + ? (a.status as AgentStatus) + : 'idle'; + + return { + agentId: a.agentId, + agentName: typeof a.agentName === 'string' ? a.agentName : a.agentId, + roleCategory: role, + status, + currentStep: _optStr(a.currentStep), + lastLog: _optStr(a.lastLog), + lastActivityAt: typeof a.lastActivityAt === 'number' ? a.lastActivityAt : 0, + }; +} + +function _validateStage(raw: unknown): { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } | null { + if (!raw || typeof raw !== 'object') return null; + const s = raw as Record; + if (typeof s.label !== 'string') return null; + const statusRaw = String(s.status ?? 'pending'); + const status = (statusRaw === 'done' || statusRaw === 'active' || statusRaw === 'pending') ? statusRaw : 'pending'; + return { label: s.label, agentId: _optStr(s.agentId), status }; +} + +function _validateActivity(raw: unknown): OfficeActivityItem | null { + if (!raw || typeof raw !== 'object') return null; + const a = raw as Record; + if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null; + const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet).has(a.kind) + ? (a.kind as OfficeActivityItem['kind']) + : undefined; + return { + ts: typeof a.ts === 'number' ? a.ts : Date.now(), + agentId: a.agentId, + text: a.text, + kind, + }; +} + +function _validateBubble(raw: unknown): OfficeBubbleSeed | null { + if (!raw || typeof raw !== 'object') return null; + const b = raw as Record; + if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null; + const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet).has(b.type) + ? (b.type as OfficeBubbleSeed['type']) + : 'status'; + return { agentId: b.agentId, text: b.text, type }; +} diff --git a/src/features/astraOffice/view/layoutSchema.ts b/src/features/astraOffice/view/layoutSchema.ts new file mode 100644 index 0000000..491a2db --- /dev/null +++ b/src/features/astraOffice/view/layoutSchema.ts @@ -0,0 +1,180 @@ +/** + * Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키 + * 에 저장되는 객체의 런타임 validator + v1 → v2 migration. + * + * 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는 + * 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다. + * + * 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다. + */ + +export interface OfficeDeskCell { + /** 안정적 식별자 — DOM dataset.role 로도 쓰임. */ + roleKey: string; + /** 매핑된 agent id. 비어있으면 unmapped. */ + agentKey: string; + label: string; + charRow: number; // 0~7 + deskSprite: string; + /** 앉은 face. */ + face: 'L' | 'R' | 'U' | 'D'; + boss: boolean; + /** seat 에서 잠시 일어났다 가는 dock 좌표. */ + dock?: [number, number]; + /** 랜덤 roam 후보 좌표들. */ + roam?: Array<[number, number]>; + deskX: number; + deskY: number; + deskW: number; + deskRot: number; + deskZ: number; + seatX: number; + seatY: number; + charRot: number; + charZ: number; + /** 캐릭터를 지운 빈 책상. */ + noChar: boolean; +} + +export interface OfficeProp { + id: string; + name: string; + x: number; + y: number; + w?: number; + rot: number; + z: number; +} + +export interface LayoutV2 { + schema: 2; + cells: OfficeDeskCell[]; + objs: OfficeProp[]; +} + +const VALID_FACES = new Set(['L', 'R', 'U', 'D']); + +/** + * raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null. + * v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용. + */ +export function validateLayout(raw: unknown): LayoutV2 | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (!Array.isArray(r.cells)) return null; + + const isV2 = r.schema === 2 || r.cells.some( + (c) => + c && typeof c === 'object' && + (typeof (c as Record).deskSprite === 'string' + || typeof (c as Record).agentKey === 'string' + || typeof (c as Record).charRow === 'number'), + ); + if (!isV2) return null; + + const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null); + const objsRaw = Array.isArray(r.objs) ? r.objs : []; + const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null); + + return { schema: 2, cells, objs }; +} + +/** + * v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어 + * default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의 + * default station 매핑이 함께 주어져야 정확. 없으면 best-effort. + * + * 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값). + * 다음 세션에서 default station 룩업과 결합. + */ +export function migrateLayout(raw: unknown): LayoutV2 | null { + const asV2 = validateLayout(raw); + if (asV2) return asV2; + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (!Array.isArray(r.cells)) return null; + + const cells: OfficeDeskCell[] = r.cells + .map((cRaw) => { + if (!cRaw || typeof cRaw !== 'object') return null; + const c = cRaw as Record; + if (typeof c.roleKey !== 'string') return null; + return _normalizeCell({ + ...c, + // v1 은 charRow / deskSprite 등이 없으니 안전한 기본값. + charRow: 0, + deskSprite: 'desk-main', + face: 'R', + boss: false, + agentKey: c.roleKey, // 옛 키가 곧 agent 였음. + label: c.roleKey, + noChar: false, + }); + }) + .filter((c): c is OfficeDeskCell => c !== null); + + const objs = Array.isArray(r.objs) + ? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null) + : []; + + return { schema: 2, cells, objs }; +} + +function _normalizeCell(raw: unknown): OfficeDeskCell | null { + if (!raw || typeof raw !== 'object') return null; + const c = raw as Record; + if (typeof c.roleKey !== 'string' || !c.roleKey) return null; + const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet).has(c.face) + ? (c.face as OfficeDeskCell['face']) + : 'R'; + return { + roleKey: c.roleKey, + agentKey: typeof c.agentKey === 'string' ? c.agentKey : '', + label: typeof c.label === 'string' ? c.label : c.roleKey, + charRow: _num(c.charRow, 0), + deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main', + face, + boss: !!c.boss, + dock: _pair(c.dock), + roam: Array.isArray(c.roam) + ? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>) + : undefined, + deskX: _num(c.deskX, 0), + deskY: _num(c.deskY, 0), + deskW: _num(c.deskW, 112), + deskRot: _num(c.deskRot, 0), + deskZ: _num(c.deskZ, 0), + seatX: _num(c.seatX, 0), + seatY: _num(c.seatY, 0), + charRot: _num(c.charRot, 0), + charZ: _num(c.charZ, 0), + noChar: !!c.noChar, + }; +} + +function _normalizeProp(raw: unknown): OfficeProp | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.name !== 'string') return null; + return { + id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`, + name: o.name, + x: _num(o.x, 0), + y: _num(o.y, 0), + w: typeof o.w === 'number' ? o.w : undefined, + rot: _num(o.rot, 0), + z: _num(o.z, 0), + }; +} + +function _num(v: unknown, fallback: number): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +function _pair(v: unknown): [number, number] | undefined { + if (!Array.isArray(v) || v.length !== 2) return undefined; + const a = typeof v[0] === 'number' ? v[0] : NaN; + const b = typeof v[1] === 'number' ? v[1] : NaN; + if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined; + return [a, b]; +} diff --git a/src/features/astraOffice/view/officeBody.ts b/src/features/astraOffice/view/officeBody.ts new file mode 100644 index 0000000..dc5dfbc --- /dev/null +++ b/src/features/astraOffice/view/officeBody.ts @@ -0,0 +1,21 @@ +// 자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등. +export const OFFICE_BODY = ` + +
🏢 ASTRA OFFICE
Astra
idle
+ + +
작업 단계
+
+ +
+`; diff --git a/src/features/astraOffice/view/officeStyles.ts b/src/features/astraOffice/view/officeStyles.ts new file mode 100644 index 0000000..12d9788 --- /dev/null +++ b/src/features/astraOffice/view/officeStyles.ts @@ -0,0 +1,121 @@ +// 자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등. +// design doc: docs/ASTRA_OFFICE_REFACTOR.md +export const OFFICE_CSS = ` + + +${OFFICE_BODY} +${officeRuntimeJs(assets.derivedBase)}`; +} diff --git a/src/features/astraOffice/view/runtime.ts b/src/features/astraOffice/view/runtime.ts new file mode 100644 index 0000000..18cb117 --- /dev/null +++ b/src/features/astraOffice/view/runtime.ts @@ -0,0 +1,1254 @@ +// 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. +// `${assets.derivedBase}` placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. +// 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정. + +const OFFICE_RUNTIME_JS_TEMPLATE = ` +`; -} +// Astra Office full-panel HTML 은 src/features/astraOffice/ 로 이전됨 (refactor #1). +// _buildPixelOfficeHtml() 가 renderAstraOfficePanelHtml() 을 직접 호출한다. diff --git a/src/utils.ts b/src/utils.ts index 85aac49..3fef62e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -253,6 +253,87 @@ If neither condition is met, give a definitive answer and stop. [ACTION 8: WEB SEARCH] https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY + [ACTION 9: CREATE CALENDAR EVENT] + Use only when the user shares meeting notes / agenda / due dates and a real event + should land on their Google Calendar. Requires the user to have run + "Astra: Google Calendar OAuth 연결 (쓰기)" — if not connected the tag will fail + cleanly (reported in the action log). + + + 설명 (선택) — 회의록 요약 / 안건 등 + + + Attributes: + title (required) — 한 줄 제목 + start (required) — 'YYYY-MM-DDTHH:MM' 로컬, 또는 timezone 포함 ISO + end | duration — end 없으면 duration(분, default 60) 으로 자동 계산 + location (optional) + all_day="true" — DTSTART 만 'YYYY-MM-DD' 형식으로 + + Emit *one tag per event*. Never invent times the user didn't mention — if + unclear, ask first. Do not emit tags for vague phrases like "다음주에" without + a concrete time. + + [ACTION 10: READ SHEET] + Google Sheets 의 셀 범위를 읽어 chat 컨텍스트에 마크다운 테이블로 주입한다. + 같은 OAuth 권한 (Calendar 연결 시 Sheets 권한도 함께 발급) 필요. + + + + - spreadsheet_id: Google Sheets URL 의 /d/<여기>/edit 부분 + - range: A1 notation. 시트명 포함 가능. 예: 'Sheet1!A1:E50', '데이터!B:B' + + [ACTION 11: WRITE SHEET] + Range 의 좌상단부터 값을 *덮어쓴다*. 본문은 TSV (탭 구분, 줄바꿈 = 행). + 탭이 한 칸도 없으면 ' | ' 파이프 구분으로 자동 fallback. + + + 이름\t나이\t직책 + 민지\t29\t디자이너 + + + [ACTION 12: APPEND SHEET] + Range 안에서 *가장 마지막 데이터 행 아래* 에 새 행으로 append. 로그·일지에 유용. + + + 2026-05-21\t새 항목\t완료 + + + ⚠ Sheets 사용 규칙: + - spreadsheet_id 는 사용자가 알려준 것만. 추측·생성 금지. + - 사용자가 "내 시트" 같이 추상적으로 지칭하면 *URL 을 받아온 뒤* 사용. + - 쓰기 전에는 반드시 "이 시트에 이런 데이터를 쓰겠다" 한 줄 미리 알리기 (실수 방지). + + [ACTION 13: ADD TASK] + 회의록·요청·계획 분석 중 *명확한 할일* 이 발견되면 작업 추적기에 등록. + 추적기는 모든 agent 가 다음 turn 부터 자동으로 보게 됨 → 진척 가시화 + 누락 방지. + + + + Attributes (title 만 필수): + title — 한 줄 요약 (required) + owner — @me / @planner / @qa 등 자유 형식 + due — 'YYYY-MM-DDTHH:MM' (없으면 마감 없는 task) + notes — 한 줄 부가 설명 + status — open(default) / in_progress / blocked + + [ACTION 14: UPDATE TASK] + 진척·blocker·due 변경. id 는 추적기에 표시된 t_001 같은 식별자. + 바꿀 필드만 attribute 로 주면 됨 (다른 값은 보존). + + + + [ACTION 15: COMPLETE TASK] + task 가 끝났을 때. active 에서 빼고 done 으로 이동, completedAt 자동 기록. + + + + ⚠ Task 사용 규칙: + - 사용자가 *명시적으로* 할일이라고 언급한 것만 add — 추측·확장 금지. + - 회의록에 할일이 여러 개면 각각 *별도 add_task* (한 태그에 욱여넣지 말 것). + - 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것. + - due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다). + [OPERATIONAL RULES] 1. Reply in the same language as the user. 2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity. diff --git a/tests/calendarApi.test.ts b/tests/calendarApi.test.ts new file mode 100644 index 0000000..c2eadcd --- /dev/null +++ b/tests/calendarApi.test.ts @@ -0,0 +1,131 @@ +import { + _buildEventBody, + _addMinutesIso, + _addDaysDate, +} from '../src/features/calendar/calendarApi'; +import { _parseCalEventAttrs } from '../src/agent'; + +describe('_addMinutesIso', () => { + test('adds minutes to local ISO without timezone, preserves no-tz format', () => { + const out = _addMinutesIso('2026-05-21T14:00', 60); + expect(out).toBe('2026-05-21T15:00:00'); + }); + + test('handles ISO with seconds', () => { + const out = _addMinutesIso('2026-05-21T14:30:00', 30); + expect(out).toBe('2026-05-21T15:00:00'); + }); + + test('handles UTC marker Z', () => { + const out = _addMinutesIso('2026-05-21T14:00:00Z', 60); + expect(out).toBe('2026-05-21T15:00:00.000Z'); + }); + + test('returns null on malformed input', () => { + expect(_addMinutesIso('not-a-date', 30)).toBeNull(); + expect(_addMinutesIso('', 30)).toBeNull(); + }); + + test('handles day-rollover correctly', () => { + const out = _addMinutesIso('2026-05-21T23:30', 60); + expect(out).toBe('2026-05-22T00:30:00'); + }); +}); + +describe('_addDaysDate', () => { + test('adds days to YYYY-MM-DD', () => { + expect(_addDaysDate('2026-05-21', 1)).toBe('2026-05-22'); + expect(_addDaysDate('2026-12-31', 1)).toBe('2027-01-01'); + }); +}); + +describe('_buildEventBody', () => { + test('rejects empty title or start', () => { + const r1 = _buildEventBody({ title: '', start: '2026-05-21T14:00' }, 60); + expect(r1.ok).toBe(false); + const r2 = _buildEventBody({ title: 'x', start: '' }, 60); + expect(r2.ok).toBe(false); + }); + + test('builds basic timed event with default duration', () => { + const r = _buildEventBody({ title: '회의', start: '2026-05-21T14:00' }, 60); + if (!r.ok) throw new Error('expected ok'); + expect(r.event.summary).toBe('회의'); + expect(r.event.start.dateTime).toBe('2026-05-21T14:00'); + expect(r.event.end.dateTime).toBe('2026-05-21T15:00:00'); + // 로컬 timezone 자동 포함 — Intl 결과 (값은 시스템 의존이라 존재만 확인) + expect(r.event.start.timeZone).toBeTruthy(); + expect(r.event.reminders.overrides).toHaveLength(2); + }); + + test('respects explicit duration', () => { + const r = _buildEventBody({ + title: '미팅', start: '2026-05-21T14:00', durationMinutes: 90, + }, 60); + if (!r.ok) throw new Error('expected ok'); + expect(r.event.end.dateTime).toBe('2026-05-21T15:30:00'); + }); + + test('respects explicit end over duration', () => { + const r = _buildEventBody({ + title: '미팅', start: '2026-05-21T14:00', end: '2026-05-21T16:00', durationMinutes: 30, + }, 60); + if (!r.ok) throw new Error('expected ok'); + expect(r.event.end.dateTime).toBe('2026-05-21T16:00'); + }); + + test('builds all-day event with exclusive end (+1 day)', () => { + const r = _buildEventBody({ + title: '생일', start: '2026-06-15', allDay: true, + }, 60); + if (!r.ok) throw new Error('expected ok'); + expect(r.event.start.date).toBe('2026-06-15'); + expect(r.event.end.date).toBe('2026-06-16'); + expect(r.event.start.dateTime).toBeUndefined(); + }); + + test('omits timeZone when input has explicit offset', () => { + const r = _buildEventBody({ + title: '회의', start: '2026-05-21T14:00:00+09:00', + }, 60); + if (!r.ok) throw new Error('expected ok'); + expect(r.event.start.timeZone).toBeUndefined(); + }); +}); + +describe('_parseCalEventAttrs', () => { + test('parses double-quoted attrs', () => { + const a = _parseCalEventAttrs(' title="팀 미팅" start="2026-05-21T14:00" duration="60" '); + expect(a.title).toBe('팀 미팅'); + expect(a.start).toBe('2026-05-21T14:00'); + expect(a.duration).toBe(60); + }); + + test('parses single-quoted + bare attrs', () => { + const a = _parseCalEventAttrs(`title='간단' start=2026-05-21T14:00 duration=30`); + expect(a.title).toBe('간단'); + expect(a.start).toBe('2026-05-21T14:00'); + expect(a.duration).toBe(30); + }); + + test('parses all_day variants', () => { + const a1 = _parseCalEventAttrs('title="x" start="2026-05-21" all_day="true"'); + expect(a1.allDay).toBe(true); + const a2 = _parseCalEventAttrs('title="x" start="2026-05-21" allday="1"'); + expect(a2.allDay).toBe(true); + const a3 = _parseCalEventAttrs('title="x" start="2026-05-21" all-day="yes"'); + expect(a3.allDay).toBe(true); + const a4 = _parseCalEventAttrs('title="x" start="2026-05-21" all_day="false"'); + expect(a4.allDay).toBe(false); + }); + + test('ignores invalid duration value', () => { + const a = _parseCalEventAttrs('title="x" start="t" duration="abc"'); + expect(a.duration).toBeUndefined(); + }); + + test('returns empty when attrs are missing', () => { + expect(_parseCalEventAttrs('')).toEqual({}); + expect(_parseCalEventAttrs(' ')).toEqual({}); + }); +}); diff --git a/tests/icsParser.test.ts b/tests/icsParser.test.ts new file mode 100644 index 0000000..2e9680e --- /dev/null +++ b/tests/icsParser.test.ts @@ -0,0 +1,134 @@ +import { parseIcs, selectUpcoming } from '../src/features/calendar/icsParser'; + +describe('parseIcs', () => { + test('returns empty array on invalid / empty input', () => { + expect(parseIcs('')).toEqual([]); + expect(parseIcs(null as any)).toEqual([]); + expect(parseIcs(undefined as any)).toEqual([]); + expect(parseIcs('random text no VEVENT')).toEqual([]); + }); + + test('parses single timed event', () => { + const ics = [ + 'BEGIN:VCALENDAR', + 'BEGIN:VEVENT', + 'SUMMARY:팀 미팅', + 'DTSTART:20260520T130000Z', + 'DTEND:20260520T140000Z', + 'LOCATION:회의실 A', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + const events = parseIcs(ics); + expect(events).toHaveLength(1); + expect(events[0].summary).toBe('팀 미팅'); + expect(events[0].location).toBe('회의실 A'); + expect(events[0].allDay).toBe(false); + // 2026-05-20 13:00 UTC + expect(events[0].start.getUTCFullYear()).toBe(2026); + expect(events[0].start.getUTCMonth()).toBe(4); + expect(events[0].start.getUTCDate()).toBe(20); + expect(events[0].start.getUTCHours()).toBe(13); + expect(events[0].end?.getUTCHours()).toBe(14); + }); + + test('parses all-day event via VALUE=DATE', () => { + const ics = [ + 'BEGIN:VEVENT', + 'SUMMARY:생일', + 'DTSTART;VALUE=DATE:20260615', + 'DTEND;VALUE=DATE:20260616', + 'END:VEVENT', + ].join('\n'); + const events = parseIcs(ics); + expect(events).toHaveLength(1); + expect(events[0].allDay).toBe(true); + expect(events[0].start.getFullYear()).toBe(2026); + expect(events[0].start.getMonth()).toBe(5); + expect(events[0].start.getDate()).toBe(15); + }); + + test('unfolds line continuations (RFC 5545)', () => { + // ICS 75자 wrap — 다음 줄이 공백/탭으로 시작하면 같은 필드의 연속. + const ics = [ + 'BEGIN:VEVENT', + 'SUMMARY:긴 제목 첫 부분', + ' 두번째 줄 이어짐', + 'DTSTART:20260520T130000Z', + 'END:VEVENT', + ].join('\r\n'); + const events = parseIcs(ics); + expect(events[0].summary).toBe('긴 제목 첫 부분두번째 줄 이어짐'); + }); + + test('unescapes ICS escape sequences in summary / location', () => { + const ics = [ + 'BEGIN:VEVENT', + 'SUMMARY:1\\, 2\\, 3 — 회의\\nFollow-up', + 'LOCATION:Zoom\\, 본관 3층', + 'DTSTART:20260520T130000Z', + 'END:VEVENT', + ].join('\n'); + const events = parseIcs(ics); + expect(events[0].summary).toBe('1, 2, 3 — 회의 Follow-up'); + expect(events[0].location).toBe('Zoom, 본관 3층'); + }); + + test('parses multiple VEVENTs and gives default summary when missing', () => { + const ics = [ + 'BEGIN:VEVENT', + 'DTSTART:20260520T130000Z', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'SUMMARY:두번째', + 'DTSTART:20260521T130000Z', + 'END:VEVENT', + ].join('\n'); + const events = parseIcs(ics); + expect(events).toHaveLength(2); + expect(events[0].summary).toBe('(제목 없음)'); + expect(events[1].summary).toBe('두번째'); + }); + + test('drops VEVENT without DTSTART', () => { + const ics = [ + 'BEGIN:VEVENT', + 'SUMMARY:DTSTART 누락', + 'END:VEVENT', + ].join('\n'); + expect(parseIcs(ics)).toEqual([]); + }); +}); + +describe('selectUpcoming', () => { + const now = new Date('2026-05-20T12:00:00Z'); + const mkEv = (offsetMin: number, label = 'evt'): any => ({ + start: new Date(now.getTime() + offsetMin * 60 * 1000), + end: undefined, summary: label, location: '', description: '', allDay: false, + }); + + test('filters past events older than 1 hour', () => { + const events = [ + mkEv(-120, 'old'), // 2 hours ago — dropped + mkEv(-30, 'recent'), // 30 min ago — kept (within 1h window) + mkEv(60, 'soon'), // 1 hour future — kept + ]; + const upcoming = selectUpcoming(events, 14, now); + expect(upcoming.map((e) => e.summary)).toEqual(['recent', 'soon']); + }); + + test('filters events beyond cutoff days', () => { + const events = [ + mkEv(60, 'soon'), + mkEv(60 * 24 * 15, 'too-far'), // 15 days — beyond 14-day cutoff + ]; + const upcoming = selectUpcoming(events, 14, now); + expect(upcoming.map((e) => e.summary)).toEqual(['soon']); + }); + + test('sorts upcoming events by start time', () => { + const events = [mkEv(180, 'c'), mkEv(60, 'a'), mkEv(120, 'b')]; + const upcoming = selectUpcoming(events, 14, now); + expect(upcoming.map((e) => e.summary)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/tests/officeSchema.test.ts b/tests/officeSchema.test.ts new file mode 100644 index 0000000..edf90cd --- /dev/null +++ b/tests/officeSchema.test.ts @@ -0,0 +1,241 @@ +import { + validateOfficeSnapshot, + makeEmptyOfficeSnapshot, +} from '../src/features/astraOffice/schema'; +import { + validateLayout, + migrateLayout, +} from '../src/features/astraOffice/view/layoutSchema'; +import { presentOfficeSnapshot, normalizeAgentId } from '../src/features/astraOffice/presenter'; + +describe('OfficeSnapshot schema', () => { + test('makeEmptyOfficeSnapshot returns idle / null active', () => { + const s = makeEmptyOfficeSnapshot(); + expect(s.phase).toBe('idle'); + expect(s.activeAgentId).toBeNull(); + expect(s.roster).toEqual([]); + expect(s.activity).toEqual([]); + expect(s.newBubbles).toEqual([]); + }); + + test('validateOfficeSnapshot rejects null / non-object', () => { + expect(validateOfficeSnapshot(null)).toBeNull(); + expect(validateOfficeSnapshot(undefined)).toBeNull(); + expect(validateOfficeSnapshot('idle')).toBeNull(); + expect(validateOfficeSnapshot(42)).toBeNull(); + }); + + test('validateOfficeSnapshot fills defaults for sparse input', () => { + const s = validateOfficeSnapshot({}); + expect(s).not.toBeNull(); + expect(s!.phase).toBe('idle'); + expect(s!.activeAgentId).toBeNull(); + expect(s!.roster).toEqual([]); + expect(typeof s!.updatedAt).toBe('number'); + }); + + test('validateOfficeSnapshot rejects invalid enums quietly', () => { + const s = validateOfficeSnapshot({ + phase: 'totally_made_up_phase', + activeAgentId: 'ceo', + roster: [{ agentId: 'ceo', agentName: 'CEO', roleCategory: 'BOGUS', status: 'NOT_REAL', lastActivityAt: 100 }], + }); + expect(s!.phase).toBe('idle'); // fell back + expect(s!.roster[0].roleCategory).toBe('support'); // fell back + expect(s!.roster[0].status).toBe('idle'); // fell back + }); + + test('validateOfficeSnapshot keeps valid roster + bubbles', () => { + const s = validateOfficeSnapshot({ + phase: 'executing', + activeAgentId: 'developer', + roster: [ + { agentId: 'developer', agentName: '개발', roleCategory: 'developer', status: 'executing', lastActivityAt: 100 }, + { agentId: 'inspector', agentName: '감리', roleCategory: 'inspector', status: 'idle', lastActivityAt: 90 }, + ], + newBubbles: [ + { agentId: 'developer', text: '코드 들어간다', type: 'event' }, + { agentId: 'inspector', text: 'should_be_dropped', type: 'wrong_type' }, // type 잘못 → 'status' 로 폴백 + ], + }); + expect(s!.phase).toBe('executing'); + expect(s!.activeAgentId).toBe('developer'); + expect(s!.roster).toHaveLength(2); + expect(s!.newBubbles).toHaveLength(2); + expect(s!.newBubbles[1].type).toBe('status'); // fell back + }); + + test('validateOfficeSnapshot drops malformed roster entries', () => { + const s = validateOfficeSnapshot({ + phase: 'idle', + roster: [ + { agentId: 'ok', roleCategory: 'ceo', status: 'idle', lastActivityAt: 0 }, + { /* no agentId */ roleCategory: 'ceo' }, + null, + 'string', + ], + }); + expect(s!.roster).toHaveLength(1); + expect(s!.roster[0].agentId).toBe('ok'); + }); +}); + +describe('Layout schema', () => { + test('validateLayout rejects non-v2 raw', () => { + expect(validateLayout(null)).toBeNull(); + expect(validateLayout({ cells: [] })).toBeNull(); // empty cells, no schema marker → null + expect(validateLayout({ cells: [{ roleKey: 'x', deskX: 0, deskY: 0 }] })).toBeNull(); // missing v2 markers + }); + + test('validateLayout accepts explicit schema:2', () => { + const v = validateLayout({ + schema: 2, + cells: [ + { + roleKey: 'ceo', + agentKey: 'ceo', + label: 'CEO', + charRow: 0, + deskSprite: 'desk-boss', + face: 'R', + boss: true, + deskX: 100, deskY: 50, deskW: 136, + seatX: 130, seatY: 80, + deskRot: 0, deskZ: 0, charRot: 0, charZ: 0, + noChar: false, + }, + ], + objs: [{ id: 'obj_0', name: 'plant', x: 10, y: 20, w: 32, rot: 0, z: 0 }], + }); + expect(v).not.toBeNull(); + expect(v!.cells).toHaveLength(1); + expect(v!.cells[0].deskSprite).toBe('desk-boss'); + expect(v!.objs[0].name).toBe('plant'); + }); + + test('validateLayout normalizes invalid face to R', () => { + const v = validateLayout({ + schema: 2, + cells: [{ + roleKey: 'x', deskSprite: 'desk-main', face: 'XYZ', charRow: 0, + deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0, + deskRot: 0, deskZ: 0, charRot: 0, charZ: 0, + }], + }); + expect(v!.cells[0].face).toBe('R'); + }); + + test('validateLayout detects v2 via field presence (no explicit schema)', () => { + const v = validateLayout({ + cells: [{ + roleKey: 'a', deskSprite: 'desk-main', charRow: 2, + deskX: 0, deskY: 0, deskW: 100, seatX: 0, seatY: 0, + }], + }); + expect(v).not.toBeNull(); + expect(v!.cells[0].charRow).toBe(2); + }); + + test('migrateLayout upgrades v1 (coord-only) cells', () => { + const v1 = { + cells: [{ roleKey: 'ceo', deskX: 100, deskY: 50, deskW: 136, seatX: 130, seatY: 80, deskRot: 0, deskZ: 0, charRot: 0, charZ: 0 }], + objs: [], + }; + const v = migrateLayout(v1); + expect(v).not.toBeNull(); + expect(v!.schema).toBe(2); + expect(v!.cells[0].agentKey).toBe('ceo'); // fallback: roleKey == agentKey + expect(v!.cells[0].deskSprite).toBe('desk-main'); + expect(v!.cells[0].charRow).toBe(0); + }); +}); + +describe('presenter', () => { + test('normalizeAgentId resolves aliases', () => { + expect(normalizeAgentId('writer')).toBe('writer'); + expect(normalizeAgentId('Editor')).toBe('designer'); + expect(normalizeAgentId('Secretary')).toBe('support'); + expect(normalizeAgentId('business')).toBe('inspector'); + expect(normalizeAgentId(undefined)).toBeNull(); + }); + + test('presentOfficeSnapshot builds single-agent roster from old AgentWorkState', () => { + const snap = presentOfficeSnapshot({ + activeState: { + agentId: 'inspector', + agentName: '감리', + status: 'reviewing', + currentStep: '라운드 2/3', + currentTask: '타입 정합성 검수', + recentLogs: ['타입 누락 발견'], + updatedAt: 1000, + } as any, + }); + expect(snap.phase).toBe('reviewing'); + expect(snap.activeAgentId).toBe('inspector'); + expect(snap.roster).toHaveLength(1); + expect(snap.roster[0].roleCategory).toBe('inspector'); + expect(snap.roster[0].lastLog).toBe('타입 누락 발견'); + expect(snap.task?.goal).toBe('타입 정합성 검수'); + }); + + test('presentOfficeSnapshot returns empty when no input', () => { + const snap = presentOfficeSnapshot({}); + expect(snap.phase).toBe('idle'); + expect(snap.activeAgentId).toBeNull(); + expect(snap.roster).toEqual([]); + }); + + test('presentOfficeSnapshot maps need_clarification to awaiting-approval phase', () => { + const snap = presentOfficeSnapshot({ + activeState: { + agentId: 'ceo', + agentName: 'CEO', + status: 'need_clarification', + needUserInput: ['배포 대상 환경은?'], + updatedAt: 0, + } as any, + }); + expect(snap.phase).toBe('awaiting-approval'); + expect(snap.awaiting?.kind).toBe('clarification'); + expect(snap.awaiting?.questions).toEqual(['배포 대상 환경은?']); + }); + + test('presentOfficeSnapshot with roster places active agent in working state and others idle', () => { + const snap = presentOfficeSnapshot({ + activeState: { + agentId: 'developer', + agentName: '개발', + status: 'executing', + currentStep: '함수 추출', + recentLogs: ['파일 수정 완료'], + updatedAt: 1000, + } as any, + roster: [ + { agentId: 'ceo', agentName: 'CEO', roleCategory: 'ceo' }, + { agentId: 'developer', agentName: '개발', roleCategory: 'developer' }, + { agentId: 'inspector', agentName: '감리', roleCategory: 'inspector' }, + ], + }); + expect(snap.roster).toHaveLength(3); + const active = snap.roster.find((a) => a.agentId === 'developer'); + const idle = snap.roster.find((a) => a.agentId === 'ceo'); + expect(active?.status).toBe('executing'); + expect(active?.lastLog).toBe('파일 수정 완료'); + expect(idle?.status).toBe('idle'); + expect(idle?.lastLog).toBeUndefined(); + }); + + test('presentOfficeSnapshot folds recentActivity into snapshot.activity (ring buffer)', () => { + const activity = Array.from({ length: 40 }, (_, i) => ({ + ts: i * 100, + agentId: 'developer', + text: `step ${i}`, + })); + const snap = presentOfficeSnapshot({ recentActivity: activity }); + // presenter 가 32개로 잘라야 함. + expect(snap.activity).toHaveLength(32); + expect(snap.activity[0].text).toBe('step 8'); // 처음 8개 dropped + expect(snap.activity[31].text).toBe('step 39'); + }); +}); diff --git a/tests/pipelineTemplates.test.ts b/tests/pipelineTemplates.test.ts new file mode 100644 index 0000000..8ece959 --- /dev/null +++ b/tests/pipelineTemplates.test.ts @@ -0,0 +1,69 @@ +import { + PIPELINE_TEMPLATES, + getPipelineTemplate, + SCOPE_PRESETS, +} from '../src/features/company/pipelineTemplates'; + +describe('Pipeline templates registry', () => { + test('exposes plan-only / dev-only / full-product-dev in expected order', () => { + const ids = PIPELINE_TEMPLATES.map((t) => t.templateId); + expect(ids).toEqual(['plan-only', 'dev-only', 'full-product-dev']); + }); + + test('getPipelineTemplate returns each by id', () => { + expect(getPipelineTemplate('plan-only')?.suggestedPipelineId).toBe('plan-only'); + expect(getPipelineTemplate('dev-only')?.suggestedPipelineId).toBe('dev-only'); + expect(getPipelineTemplate('full-product-dev')?.suggestedPipelineId).toBe('product-dev'); + expect(getPipelineTemplate('does-not-exist')).toBeUndefined(); + }); + + test('SCOPE_PRESETS keys are 1:1 with template ids', () => { + const presetIds = SCOPE_PRESETS.map((p) => p.templateId); + for (const id of presetIds) { + expect(getPipelineTemplate(id)).toBeDefined(); + } + }); +}); + +describe('DEV_ONLY template stage shape', () => { + test('has 10 stages and ends at dev-impl', () => { + const tpl = getPipelineTemplate('dev-only'); + expect(tpl).toBeDefined(); + expect(tpl!.stages).toHaveLength(10); + const last = tpl!.stages[tpl!.stages.length - 1]; + expect(last.id).toBe('dev-impl'); + }); + + test('does NOT include qa / deploy stages', () => { + const tpl = getPipelineTemplate('dev-only')!; + const stageIds = tpl.stages.map((s) => s.id); + expect(stageIds).not.toContain('qa'); + expect(stageIds).not.toContain('deploy'); + }); + + test('keeps planner → design-review chain intact', () => { + const tpl = getPipelineTemplate('dev-only')!; + const stageIds = tpl.stages.map((s) => s.id); + expect(stageIds).toEqual(expect.arrayContaining([ + 'plan-discuss', 'plan-draft', 'plan-final', 'dev-design', 'design-review', 'dev-impl', + ])); + }); + + test('shares stage objects with FULL_PRODUCT_DEV (read-only template safety)', () => { + // Slice(0, 10) — full 의 stages 배열에서 자른 references. + // 사용자가 dev-only template 을 stamp 하면 _normalizePipeline 이 deep-copy 하므로 + // 본 template 의 stage 객체 자체는 절대 mutate 되지 않아야 함. + const dev = getPipelineTemplate('dev-only')!; + const full = getPipelineTemplate('full-product-dev')!; + expect(dev.stages[0]).toBe(full.stages[0]); // shared reference (intentional) + expect(dev.stages.length).toBeLessThan(full.stages.length); + }); +}); + +describe('PLAN_ONLY template (existing) sanity', () => { + test('still has 3 stages ending at plan-doc', () => { + const tpl = getPipelineTemplate('plan-only')!; + expect(tpl.stages).toHaveLength(3); + expect(tpl.stages[tpl.stages.length - 1].id).toBe('plan-doc'); + }); +}); diff --git a/tests/sheetsApi.test.ts b/tests/sheetsApi.test.ts new file mode 100644 index 0000000..32382b7 --- /dev/null +++ b/tests/sheetsApi.test.ts @@ -0,0 +1,113 @@ +import { + parseTsvBody, + valuesToMarkdownTable, +} from '../src/features/sheets/sheetsApi'; +import { _parseSheetAttrs } from '../src/agent'; + +describe('parseTsvBody', () => { + test('returns [] for empty / whitespace input', () => { + expect(parseTsvBody('')).toEqual([]); + expect(parseTsvBody(' ')).toEqual([]); + expect(parseTsvBody('\n\n')).toEqual([]); + }); + + test('parses tab-separated rows', () => { + const body = '이름\t나이\t직책\n민지\t29\t디자이너\n준호\t31\t개발자'; + expect(parseTsvBody(body)).toEqual([ + ['이름', '나이', '직책'], + ['민지', '29', '디자이너'], + ['준호', '31', '개발자'], + ]); + }); + + test('falls back to pipe-separated when no tab present', () => { + const body = '이름 | 나이\n민지 | 29\n준호 | 31'; + expect(parseTsvBody(body)).toEqual([ + ['이름', '나이'], + ['민지', '29'], + ['준호', '31'], + ]); + }); + + test('strips leading and trailing blank lines (LLM artifact)', () => { + const body = '\n\n이름\t나이\n민지\t29\n\n'; + expect(parseTsvBody(body)).toEqual([ + ['이름', '나이'], + ['민지', '29'], + ]); + }); + + test('preserves empty cells when tabs are present', () => { + const body = 'A\t\tC'; + expect(parseTsvBody(body)).toEqual([['A', '', 'C']]); + }); +}); + +describe('valuesToMarkdownTable', () => { + test('empty → placeholder', () => { + expect(valuesToMarkdownTable([])).toBe('_(empty)_'); + }); + + test('renders header + separator + rows', () => { + const out = valuesToMarkdownTable([ + ['이름', '나이'], + ['민지', 29], + ['준호', 31], + ]); + const lines = out.split('\n'); + expect(lines[0]).toBe('| 이름 | 나이 |'); + expect(lines[1]).toBe('|---|---|'); + expect(lines[2]).toBe('| 민지 | 29 |'); + expect(lines[3]).toBe('| 준호 | 31 |'); + }); + + test('truncates beyond maxRows + adds note', () => { + const big: any[][] = [['col']]; + for (let i = 0; i < 60; i++) big.push([`row${i}`]); + const out = valuesToMarkdownTable(big, 10); + expect(out).toContain('| col |'); + expect(out).toContain('| row0 |'); + expect(out).toContain('| row8 |'); // 10 rows total = header + 9 data + expect(out).not.toContain('| row9 |'); + expect(out).toContain('51 more rows truncated'); + }); + + test('escapes pipe characters inside cell values', () => { + const out = valuesToMarkdownTable([ + ['a|b', 'c'], + ['d', 'e|f'], + ]); + expect(out).toContain('| a\\|b | c |'); + expect(out).toContain('| d | e\\|f |'); + }); +}); + +describe('_parseSheetAttrs', () => { + test('parses spreadsheet_id + range with double quotes', () => { + const a = _parseSheetAttrs(' spreadsheet_id="1abc" range="Sheet1!A1:D20" '); + expect(a.spreadsheetId).toBe('1abc'); + expect(a.range).toBe('Sheet1!A1:D20'); + }); + + test('accepts camelCase alias spreadsheetId', () => { + const a = _parseSheetAttrs('spreadsheetId="1xyz" range="A:B"'); + expect(a.spreadsheetId).toBe('1xyz'); + }); + + test('accepts sheet_id alias', () => { + const a = _parseSheetAttrs(`sheet_id='1qrs' range='Tab1'`); + expect(a.spreadsheetId).toBe('1qrs'); + expect(a.range).toBe('Tab1'); + }); + + test('parses bare (unquoted) values', () => { + const a = _parseSheetAttrs('spreadsheet_id=1simple range=Sheet1!A1'); + expect(a.spreadsheetId).toBe('1simple'); + expect(a.range).toBe('Sheet1!A1'); + }); + + test('returns empty for missing attrs', () => { + expect(_parseSheetAttrs('')).toEqual({}); + expect(_parseSheetAttrs('foo="bar"')).toEqual({}); + }); +}); diff --git a/tests/taskStore.test.ts b/tests/taskStore.test.ts new file mode 100644 index 0000000..fc8ba74 --- /dev/null +++ b/tests/taskStore.test.ts @@ -0,0 +1,185 @@ +import { + parseTaskStore, + renderTaskStore, + addTask, + updateTask, + completeTask, + summarizeActiveTasks, + TaskStore, +} from '../src/features/tasks/taskStore'; +import { _parseTaskAttrs } from '../src/agent'; + +describe('parseTaskStore', () => { + test('returns empty store on empty markdown', () => { + const s = parseTaskStore(''); + expect(s).toEqual({ active: [], done: [] }); + }); + + test('parses round-tripped output', () => { + const original: TaskStore = { + active: [ + { id: 't_001', title: '광고주 자료', owner: '@me', due: '2026-05-24T18:00', status: 'in_progress', notes: '자료 대기' }, + { id: 't_002', title: '디자인 리뷰', owner: '@planner', due: '', status: 'open', notes: '' }, + ], + done: [ + { id: 't_000', title: '셋업', owner: '@me', due: '', status: 'done', notes: '', completedAt: '2026-05-20T10:00' }, + ], + }; + const md = renderTaskStore(original); + const parsed = parseTaskStore(md); + expect(parsed.active).toHaveLength(2); + expect(parsed.active[0].id).toBe('t_001'); + expect(parsed.active[0].status).toBe('in_progress'); + expect(parsed.done).toHaveLength(1); + expect(parsed.done[0].id).toBe('t_000'); + expect(parsed.done[0].completedAt).toBe('2026-05-20T10:00'); + }); + + test('normalizes status variants', () => { + const md = `# Tasks +## Active +| ID | Title | Owner | Due | Status | Notes | +|---|---|---|---|---|---| +| t_001 | a | @me | | In Progress | | +| t_002 | b | @me | | INPROGRESS | | +| t_003 | c | @me | | blocked | | +| t_004 | d | @me | | unknown_status | |`; + const s = parseTaskStore(md); + expect(s.active.map((t) => t.status)).toEqual(['in_progress', 'in_progress', 'blocked', 'open']); + }); + + test('ignores malformed rows', () => { + const md = `# Tasks +## Active +| ID | Title | Owner | Due | Status | Notes | +|---|---|---|---|---|---| +| t_001 | ok | @me | | open | | +| no_t_prefix | bad | +| just text not a row +| t_003 | ok2 | @me | | open | |`; + const s = parseTaskStore(md); + expect(s.active.map((t) => t.id)).toEqual(['t_001', 't_003']); + }); +}); + +describe('addTask', () => { + test('assigns incremental t_NNN ids based on max across active + done', () => { + const store: TaskStore = { + active: [{ id: 't_003', title: 'a', owner: '', due: '', status: 'open', notes: '' }], + done: [{ id: 't_010', title: 'b', owner: '', due: '', status: 'done', notes: '', completedAt: '' }], + }; + const t = addTask(store, { title: '신규' }); + expect(t.id).toBe('t_011'); + }); + + test('starts at t_001 for empty store', () => { + const store: TaskStore = { active: [], done: [] }; + const t = addTask(store, { title: '첫 task' }); + expect(t.id).toBe('t_001'); + }); + + test('trims input fields and defaults status to open', () => { + const store: TaskStore = { active: [], done: [] }; + const t = addTask(store, { title: ' 광고주 ', owner: ' @me ', notes: '' }); + expect(t.title).toBe('광고주'); + expect(t.owner).toBe('@me'); + expect(t.status).toBe('open'); + expect(t.notes).toBe(''); + }); +}); + +describe('updateTask', () => { + test('patches only provided fields, preserves id', () => { + const store: TaskStore = { + active: [{ id: 't_001', title: 'a', owner: '@me', due: '', status: 'open', notes: '' }], + done: [], + }; + const r = updateTask(store, 't_001', { status: 'in_progress', notes: '시작' }); + expect(r?.title).toBe('a'); + expect(r?.status).toBe('in_progress'); + expect(r?.notes).toBe('시작'); + }); + + test('returns null for unknown id', () => { + const store: TaskStore = { active: [], done: [] }; + expect(updateTask(store, 't_999', { notes: 'x' })).toBeNull(); + }); +}); + +describe('completeTask', () => { + test('moves active task to done with completedAt', () => { + const store: TaskStore = { + active: [{ id: 't_001', title: 'a', owner: '', due: '', status: 'open', notes: '' }], + done: [], + }; + const r = completeTask(store, 't_001', '2026-05-21T15:00'); + expect(r?.status).toBe('done'); + expect(store.active).toHaveLength(0); + expect(store.done).toHaveLength(1); + expect(store.done[0].completedAt).toBe('2026-05-21T15:00'); + }); + + test('returns null when already done or unknown', () => { + const store: TaskStore = { active: [], done: [] }; + expect(completeTask(store, 't_001')).toBeNull(); + }); +}); + +describe('summarizeActiveTasks', () => { + test('returns empty string when no active', () => { + expect(summarizeActiveTasks({ active: [], done: [] })).toBe(''); + }); + + test('sorts due-set tasks before unset, asc by due', () => { + const store: TaskStore = { + active: [ + { id: 't_001', title: '늦은', owner: '', due: '2026-06-01T10:00', status: 'open', notes: '' }, + { id: 't_002', title: '미정', owner: '', due: '', status: 'open', notes: '' }, + { id: 't_003', title: '빠른', owner: '', due: '2026-05-21T10:00', status: 'open', notes: '' }, + ], + done: [], + }; + const summary = summarizeActiveTasks(store); + const lines = summary.split('\n'); + // 빠른 → 늦은 → 미정 순 + expect(lines[0]).toContain('빠른'); + expect(lines[1]).toContain('늦은'); + expect(lines[2]).toContain('미정'); + }); + + test('truncates beyond max', () => { + const active = Array.from({ length: 20 }, (_, i) => ({ + id: `t_${String(i).padStart(3, '0')}`, + title: `task ${i}`, + owner: '', due: '', status: 'open' as const, notes: '', + })); + const summary = summarizeActiveTasks({ active, done: [] }, 5); + expect(summary).toContain('task 0'); + expect(summary).toContain('task 4'); + expect(summary).not.toContain('task 5'); + expect(summary).toContain('15 more'); + }); +}); + +describe('_parseTaskAttrs', () => { + test('parses standard task attrs', () => { + const a = _parseTaskAttrs('id="t_001" title="광고주 자료" owner="@me" due="2026-05-24T18:00"'); + expect(a.id).toBe('t_001'); + expect(a.title).toBe('광고주 자료'); + expect(a.owner).toBe('@me'); + expect(a.due).toBe('2026-05-24T18:00'); + }); + + test('normalizes status variants', () => { + expect(_parseTaskAttrs('status="In Progress"').status).toBe('in_progress'); + expect(_parseTaskAttrs('status="INPROGRESS"').status).toBe('in_progress'); + expect(_parseTaskAttrs('status="blocked"').status).toBe('blocked'); + expect(_parseTaskAttrs('status="done"').status).toBe('done'); + expect(_parseTaskAttrs('status="garbage"').status).toBe('open'); + }); + + test('returns empty object when nothing parseable', () => { + expect(_parseTaskAttrs('')).toEqual({}); + expect(_parseTaskAttrs('garbage')).toEqual({}); + }); +});