v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+35 -32
View File
@@ -3,15 +3,15 @@
<!-- ASTRA:AUTO-START --> <!-- ASTRA:AUTO-START -->
## Snapshot ## 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. - **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 - **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 ## Last Refresh
- **Time**: 2026-05-16T04:18:55.379Z - **Time**: 2026-05-16T13:04:11.625Z
- **Files newly analysed**: 2 - **Files newly analysed**: 6
- **Files reused from cache**: 222 - **Files reused from cache**: 242
## Directory Map ## Directory Map
```mermaid ```mermaid
@@ -37,11 +37,11 @@ mindmap
> Arrows: which top-level module imports from which. > Arrows: which top-level module imports from which.
```mermaid ```mermaid
flowchart LR flowchart LR
src["src/<br/>110 files"] src["src/<br/>127 files"]
media["media/<br/>6 files"] media["media/<br/>6 files"]
tests["tests/<br/>27 files"] tests["tests/<br/>33 files"]
core_py["core_py/<br/>6 files"] core_py["core_py/<br/>6 files"]
docs["docs/<br/>75 files"] docs["docs/<br/>76 files"]
tests --> src tests --> src
``` ```
@@ -55,19 +55,19 @@ flowchart LR
> Imported by many other files — touching these has wide blast radius. > Imported by many other files — touching these has wide blast radius.
- `src/utils.ts` — referenced by **49** files - `src/utils.ts` — referenced by **49** files
- `src/config.ts` — referenced by **16** 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/core/services.ts` — referenced by **10** files
- `src/lib/paths.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/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/skills/agentKnowledgeMap.ts` — referenced by **6** files
- `src/lib/engine.ts` — referenced by **6** files
## Modules ## Modules
### `src/` — 110 files, ~31,312 lines ### `src/` — 127 files, ~33,943 lines
**Sub-directories** **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/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장 - `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I - `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 - `src/scaffolder/` (2) — Scaffolder template catalog. Templates are pure data — (projectName) => { [relativePath]: contents }. New templates are
**Key files** **Key files**
- `src/utils.ts` (279 lines) - `src/utils.ts` (360 lines)
- `src/config.ts` (298 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/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/core/services.ts` (164 lines)
- `src/lib/paths.ts` (151 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/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/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/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
- `src/skills/agentKnowledgeMap.ts` (374 lines) - `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/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/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/features/approval/approvalQueue.ts` (129 lines)
- `src/integrations/telegram/telegramClient.ts` (154 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: <workspaceRoot>/.astra/company/sessions/2026-05-13T21-29/ ├─ brief.md ← CEO's task decompositio - `src/features/company/sessionStore.ts` (231 lines) — Disk persistence for company-mode session artefacts. Each company turn produces a timestamped directory: <workspaceRoot>/.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/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/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/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/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** **Key files**
- `media/sidebar.css` (1986 lines) — Stylesheet - `media/sidebar.css` (2016 lines) — Stylesheet
- `media/sidebar.js` (3605 lines) - `media/sidebar.js` (3657 lines)
- `media/sidebar.html` (531 lines) — Astra - `media/sidebar.html` (546 lines) — Astra
- `media/settings-panel.css` (210 lines) — Stylesheet - `media/settings-panel.css` (210 lines) — Stylesheet
- `media/settings-panel.html` (164 lines) — Astra Settings - `media/settings-panel.html` (164 lines) — Astra Settings
- `media/settings-panel.js` (270 lines) - `media/settings-panel.js` (270 lines)
### `tests/` — 27 files, ~4,938 lines ### `tests/` — 33 files, ~5,811 lines
*Depends on*: `src/` *Depends on*: `src/`
**Sub-directories** **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/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) — / <reference types="jest" /> - `tests/dataProcessor.test.ts` (87 lines) — / <reference types="jest" />
- `tests/findBrainFilesCache.test.ts` (80 lines) — Unit tests for findBrainFiles TTL cache. - `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/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/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) — / <reference types="jest" /> - `tests/transaction.test.ts` (68 lines) — / <reference types="jest" />
- `tests/vulnerability.test.ts` (60 lines) — / <reference types="jest" /> - `tests/vulnerability.test.ts` (60 lines) — / <reference types="jest" />
- `tests/brainIndex.test.ts` (107 lines) - `tests/brainIndex.test.ts` (107 lines)
- `tests/calendarApi.test.ts` (131 lines)
- `tests/contextManager.test.ts` (129 lines) - `tests/contextManager.test.ts` (129 lines)
- `tests/icsParser.test.ts` (134 lines)
- `tests/lessonHelpers.test.ts` (191 lines) - `tests/lessonHelpers.test.ts` (191 lines)
- `tests/projectChronicle.test.ts` (199 lines) - `tests/projectChronicle.test.ts` (199 lines)
- `tests/responseRecovery.test.ts` (151 lines) - `tests/responseRecovery.test.ts` (151 lines)
- `tests/scoring.test.ts` (134 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 ### `core_py/` — 6 files, ~409 lines
@@ -160,7 +160,7 @@ flowchart LR
- `core_py/optimizer.py` (55 lines) - `core_py/optimizer.py` (55 lines)
- `core_py/queue_worker.py` (82 lines) - `core_py/queue_worker.py` (82 lines)
### `docs/` — 75 files, ~2,886 lines ### `docs/` — 76 files, ~3,084 lines
**Sub-directories** **Sub-directories**
- `docs/records/` (63) — Astra Project Chronicle Records - `docs/records/` (63) — Astra Project Chronicle Records
@@ -169,6 +169,7 @@ flowchart LR
**Key files** **Key files**
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서 - `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document - `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/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-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 - `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-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-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-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
- `docs/records/ConnectAI/bugs/BUG-0010-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
## VS Code Extension Surface ## VS Code Extension Surface
- **Extension ID**: `g1nation.astra` - **Extension ID**: `g1nation.astra`
- **Activation events**: `onStartupFinished` - **Activation events**: `onStartupFinished`
- **Commands** (24): - **Commands** (27):
- `g1nation.newChat` — Astra: New Chat - `g1nation.newChat` — Astra: New Chat
- `g1nation.exportChat` — Astra: Export Chat as Markdown - `g1nation.exportChat` — Astra: Export Chat as Markdown
- `g1nation.explainSelection` — Astra: Explain Selected Code - `g1nation.explainSelection` — Astra: Explain Selected Code
@@ -221,6 +221,9 @@ flowchart LR
- `g1nation.company.manage` — Astra: Manage 1인 기업 Agents - `g1nation.company.manage` — Astra: Manage 1인 기업 Agents
- `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder - `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder
- `g1nation.company.pixelOffice.open` — Astra: Open Pixel Office (Full Screen) - `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): - **Configuration** (50 settings):
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks. - `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. - `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response.
@@ -319,7 +322,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
**Designed for High-Performance Decision Making.** **Designed for High-Performance Decision Making.**
Copyright (C) **g1nation**. All rights reserved. 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`_
<!-- ASTRA:AUTO-END --> <!-- ASTRA:AUTO-END -->
## Purpose ## Purpose
+261 -39
View File
@@ -1,11 +1,11 @@
{ {
"version": 1, "version": 1,
"generatedAt": "2026-05-16T04:18:55.389Z", "generatedAt": "2026-05-16T13:04:11.635Z",
"files": { "files": {
"src/agent.ts": { "src/agent.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778936503000,
"size": 187410, "size": 201748,
"lines": 3260, "lines": 3509,
"role": "", "role": "",
"imports": [ "imports": [
"src/utils", "src/utils",
@@ -259,9 +259,9 @@
"imports": [] "imports": []
}, },
"src/extension.ts": { "src/extension.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778935438000,
"size": 50938, "size": 61216,
"lines": 972, "lines": 1179,
"role": "", "role": "",
"imports": [ "imports": [
"src/utils", "src/utils",
@@ -290,7 +290,8 @@
"src/retrieval", "src/retrieval",
"src/retrieval/lessonHelpers", "src/retrieval/lessonHelpers",
"src/skills/scopedBrainRetriever", "src/skills/scopedBrainRetriever",
"src/integrations/telegram/conversationHistory" "src/integrations/telegram/conversationHistory",
"src/features/calendar"
] ]
}, },
"src/features/approval/approvalPanelProvider.ts": { "src/features/approval/approvalPanelProvider.ts": {
@@ -321,10 +322,126 @@
"src/features/approval/approvalQueue" "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:<ephemeralport> redirect URI 를 허용한다. 본 모듈은: 1. ephemeral port 에 일회용 HTTP 서버 띄움 2. 사용자 브라우저로 Google 로",
"imports": []
},
"src/features/company/agents.ts": { "src/features/company/agents.ts": {
"mtimeMs": 1778765657000, "mtimeMs": 1778936611000,
"size": 13783, "size": 15031,
"lines": 196, "lines": 211,
"role": "기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 ", "role": "기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의 ",
"imports": [ "imports": [
"src/features/company/types" "src/features/company/types"
@@ -368,9 +485,9 @@
] ]
}, },
"src/features/company/dispatcher.ts": { "src/features/company/dispatcher.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778936524000,
"size": 72121, "size": 73113,
"lines": 1419, "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", "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": [ "imports": [
"src/core/services", "src/core/services",
@@ -387,6 +504,8 @@
"src/features/company/telegramReport", "src/features/company/telegramReport",
"src/features/company/types", "src/features/company/types",
"src/features/company/intentAlignment", "src/features/company/intentAlignment",
"src/features/calendar",
"src/features/tasks",
"src/config", "src/config",
"src/features/selfReflector/selfReflectorVerifier", "src/features/selfReflector/selfReflectorVerifier",
"src/features/selfReflector/selfReflectorExecution", "src/features/selfReflector/selfReflectorExecution",
@@ -433,9 +552,9 @@
] ]
}, },
"src/features/company/pipelineTemplates.ts": { "src/features/company/pipelineTemplates.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778933936000,
"size": 13681, "size": 15125,
"lines": 250, "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", "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": [ "imports": [
"src/features/company/types" "src/features/company/types"
@@ -456,9 +575,9 @@
"imports": [] "imports": []
}, },
"src/features/company/promptBuilder.ts": { "src/features/company/promptBuilder.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778936588000,
"size": 14499, "size": 18723,
"lines": 260, "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 (<createfile>, ", "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 (<createfile>, ",
"imports": [ "imports": [
"src/features/company/agents", "src/features/company/agents",
@@ -668,6 +787,40 @@
"src/lib/paths" "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": { "src/integrations/telegram/conversationHistory.ts": {
"mtimeMs": 1778684811000, "mtimeMs": 1778684811000,
"size": 6273, "size": 6273,
@@ -1006,9 +1159,9 @@
] ]
}, },
"src/sidebar/chatHandlers.ts": { "src/sidebar/chatHandlers.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778934077000,
"size": 34805, "size": 37222,
"lines": 630, "lines": 676,
"role": "", "role": "",
"imports": [ "imports": [
"src/sidebarProvider", "src/sidebarProvider",
@@ -1030,9 +1183,9 @@
] ]
}, },
"src/sidebarProvider.ts": { "src/sidebarProvider.ts": {
"mtimeMs": 1778904191000, "mtimeMs": 1778933999000,
"size": 245949, "size": 188454,
"lines": 5505, "lines": 4141,
"role": "", "role": "",
"imports": [ "imports": [
"src/utils", "src/utils",
@@ -1053,6 +1206,7 @@
"src/features/projectArchitecture/intentDetector", "src/features/projectArchitecture/intentDetector",
"src/features/company", "src/features/company",
"src/core/services", "src/core/services",
"src/features/astraOffice",
"src/features/company/dispatcher" "src/features/company/dispatcher"
] ]
}, },
@@ -1113,9 +1267,9 @@
"imports": [] "imports": []
}, },
"src/utils.ts": { "src/utils.ts": {
"mtimeMs": 1778902489000, "mtimeMs": 1778936575000,
"size": 12155, "size": 15995,
"lines": 279, "lines": 360,
"role": "", "role": "",
"imports": [ "imports": [
"src/config", "src/config",
@@ -1144,23 +1298,23 @@
"imports": [] "imports": []
}, },
"media/sidebar.css": { "media/sidebar.css": {
"mtimeMs": 1778902489000, "mtimeMs": 1778934126000,
"size": 85387, "size": 86702,
"lines": 1986, "lines": 2016,
"role": "Stylesheet", "role": "Stylesheet",
"imports": [] "imports": []
}, },
"media/sidebar.html": { "media/sidebar.html": {
"mtimeMs": 1778902489000, "mtimeMs": 1778934094000,
"size": 33230, "size": 34587,
"lines": 531, "lines": 546,
"role": "Astra", "role": "Astra",
"imports": [] "imports": []
}, },
"media/sidebar.js": { "media/sidebar.js": {
"mtimeMs": 1778902489000, "mtimeMs": 1778934151000,
"size": 206892, "size": 211710,
"lines": 3605, "lines": 3657,
"role": "", "role": "",
"imports": [] "imports": []
}, },
@@ -1191,6 +1345,16 @@
"src/retrieval/brainIndex" "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": { "tests/contextManager.test.ts": {
"mtimeMs": 1778594523000, "mtimeMs": 1778594523000,
"size": 6545, "size": 6545,
@@ -1218,6 +1382,15 @@
"src/utils" "src/utils"
] ]
}, },
"tests/icsParser.test.ts": {
"mtimeMs": 1778934828000,
"size": 5011,
"lines": 134,
"role": "",
"imports": [
"src/features/calendar/icsParser"
]
},
"tests/integration_retrieval.test.ts": { "tests/integration_retrieval.test.ts": {
"mtimeMs": 1777949141000, "mtimeMs": 1777949141000,
"size": 4017, "size": 4017,
@@ -1274,6 +1447,17 @@
"role": "", "role": "",
"imports": [] "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": { "tests/paths.test.ts": {
"mtimeMs": 1778250990000, "mtimeMs": 1778250990000,
"size": 2590, "size": 2590,
@@ -1283,6 +1467,15 @@
"src/lib/paths" "src/lib/paths"
] ]
}, },
"tests/pipelineTemplates.test.ts": {
"mtimeMs": 1778934174000,
"size": 2984,
"lines": 69,
"role": "",
"imports": [
"src/features/company/pipelineTemplates"
]
},
"tests/projectChronicle.test.ts": { "tests/projectChronicle.test.ts": {
"mtimeMs": 1778169995000, "mtimeMs": 1778169995000,
"size": 8359, "size": 8359,
@@ -1356,6 +1549,16 @@
"src/features/secondBrainTrace" "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": { "tests/skillInjectionService.test.ts": {
"mtimeMs": 1778681774000, "mtimeMs": 1778681774000,
"size": 6741, "size": 6741,
@@ -1383,6 +1586,16 @@
"src/system/specs" "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": { "tests/telegramBot.test.ts": {
"mtimeMs": 1778253785000, "mtimeMs": 1778253785000,
"size": 13012, "size": 13012,
@@ -1455,6 +1668,15 @@
"role": "", "role": "",
"imports": [] "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": { "docs/Advanced_Features_Implementation_Guide.md": {
"mtimeMs": 1777808065000, "mtimeMs": 1777808065000,
"size": 1804, "size": 1804,
@@ -1638,7 +1860,7 @@
"imports": [] "imports": []
}, },
"docs/records/ConnectAI/chronicle.config.json": { "docs/records/ConnectAI/chronicle.config.json": {
"mtimeMs": 1778902789000, "mtimeMs": 1778932145000,
"size": 416, "size": 416,
"lines": 11, "lines": 11,
"role": "JSON configuration", "role": "JSON configuration",
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1778905142810, "createdAt": 1778936679275,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1778905142809, "createdAt": 1778936679275,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1778905142808, "createdAt": 1778936679274,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -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", "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": 1778905142810, "createdAt": 1778936679276,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "stress_conflict_1778905142797", "missionId": "stress_conflict_1778936679262",
"status": "completed", "status": "completed",
"startTime": "2026-05-16T04:19:02.797Z", "startTime": "2026-05-16T13:04:39.262Z",
"totalElapsedMs": 13, "totalElapsedMs": 14,
"results": { "results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.", "planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -16,30 +16,30 @@
{ {
"from": "idle", "from": "idle",
"to": "planner", "to": "planner",
"durationMs": 11, "durationMs": 12,
"message": "전략 수립 중...", "message": "전략 수립 중...",
"ts": "2026-05-16T04:19:02.808Z" "ts": "2026-05-16T13:04:39.274Z"
}, },
{ {
"from": "planner", "from": "planner",
"to": "researcher", "to": "researcher",
"durationMs": 1, "durationMs": 0,
"message": "핵심 정보 수집 및 분석 중...", "message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-16T04:19:02.809Z" "ts": "2026-05-16T13:04:39.274Z"
}, },
{ {
"from": "researcher", "from": "researcher",
"to": "writer", "to": "writer",
"durationMs": 1, "durationMs": 1,
"message": "최종 리포트 작성 및 편집 중...", "message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-16T04:19:02.810Z" "ts": "2026-05-16T13:04:39.275Z"
}, },
{ {
"from": "writer", "from": "writer",
"to": "completed", "to": "completed",
"durationMs": 0, "durationMs": 1,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-05-16T04:19:02.810Z" "ts": "2026-05-16T13:04:39.276Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+12
View File
@@ -1,5 +1,17 @@
# Astra Patch Notes # 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) ## v2.2.14 (2026-05-16)
### 🎭 Advanced Pixel Office Customization & Face Directions ### 🎭 Advanced Pixel Office Customization & Face Directions
- **캐릭터 방향성 고도화:** 기존 좌우(Left/Right) 방향에 더해 상하(Up/Down) 방향 스프라이트 지원을 추가하여 더욱 다채로운 사무실 연출이 가능해졌습니다. - **캐릭터 방향성 고도화:** 기존 좌우(Left/Right) 방향에 더해 상하(Up/Down) 방향 스프라이트 지원을 추가하여 더욱 다채로운 사무실 연출이 가능해졌습니다.
+198
View File
@@ -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 는 추후.
+1 -1
View File
@@ -7,5 +7,5 @@
"corePurpose": "", "corePurpose": "",
"detailLevel": "standard", "detailLevel": "standard",
"createdAt": "2026-05-13T13:09:33.788Z", "createdAt": "2026-05-13T13:09:33.788Z",
"updatedAt": "2026-05-16T04:20:09.223Z" "updatedAt": "2026-05-16T11:49:05.841Z"
} }
+26
View File
@@ -1931,6 +1931,32 @@
/* compact toggle chips kept visible in the top bar (Trace / Web) */ /* compact toggle chips kept visible in the top bar (Trace / Web) */
.toggle-chip { font-size: 10.5px; padding: 0 8px; } .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 ▾) */ /* a trigger + popover menu (Tools ▾ / Edit ▾ / Records ▾) */
.hdr-dropdown { position: relative; display: inline-flex; } .hdr-dropdown { position: relative; display: inline-flex; }
.hdr-menu { .hdr-menu {
+15
View File
@@ -23,6 +23,21 @@
prompt edits · Knowledge Mix sliders). prompt edits · Knowledge Mix sliders).
--> -->
<button class="icon-btn toggle-chip" id="companyChip" data-tooltip="1인 기업 모드 — CEO가 요청을 분석해 전문 에이전트에게 분배">기업 모드</button> <button class="icon-btn toggle-chip" id="companyChip" data-tooltip="1인 기업 모드 — CEO가 요청을 분석해 전문 에이전트에게 분배">기업 모드</button>
<!--
스코프 프리셋 segmented control. 기업 모드 ON 일 때만 표시
(companyChip.active 시 .scope-seg.visible). 각 버튼 = 1개 template:
· 기획만 → plan-only (3 stages)
· 개발까지 → dev-only (10 stages, dev-impl 까지)
· 풀 → full-product-dev (13 stages, 배포까지)
클릭 → setCompanyScopePreset 로 backend stamp + activate. 현재
activePipelineId 가 어느 template 의 suggestedPipelineId 와
일치하느냐로 active 표시 결정.
-->
<div class="scope-seg" id="companyScopeSeg" hidden>
<button class="scope-seg-btn" data-scope="plan-only" data-tooltip="기획서까지만 — 시장조사 → 방향성 → 기획문서 (3단계)">기획만</button>
<button class="scope-seg-btn" data-scope="dev-only" data-tooltip="개발까지만 — 기획 → 설계 → 코드 구현 (10단계, QA·배포 제외)">개발까지</button>
<button class="scope-seg-btn" data-scope="full-product-dev" data-tooltip="풀 파이프라인 — 기획부터 배포까지 (13단계)"></button>
</div>
<button class="icon-btn" id="companyManageBtn" data-tooltip="기업 모드 관리 (에이전트 · 모델 · 프롬프트 · 지식 비중)"></button> <button class="icon-btn" id="companyManageBtn" data-tooltip="기업 모드 관리 (에이전트 · 모델 · 프롬프트 · 지식 비중)"></button>
<div class="hdr-dropdown" data-dd> <div class="hdr-dropdown" data-dd>
<button class="icon-btn" id="toolsMenuBtn" data-dd-trigger data-tooltip="개발자 도구 모음">도구 ▾</button> <button class="icon-btn" id="toolsMenuBtn" data-dd-trigger data-tooltip="개발자 도구 모음">도구 ▾</button>
+77
View File
@@ -945,6 +945,7 @@
case 'companyStatus': { case 'companyStatus': {
const v = msg.value || {}; const v = msg.value || {};
renderCompanyChip(!!v.enabled, v.summary || ''); renderCompanyChip(!!v.enabled, v.summary || '');
renderScopeSeg(v.activePipelineId || null);
break; break;
} }
case 'companyIntentDecision': { case 'companyIntentDecision': {
@@ -965,11 +966,51 @@
break; break;
} }
case 'pixelOfficeUpdate': { case 'pixelOfficeUpdate': {
// 새 path (officeSnapshot) 가 한 번이라도 도착했다면 옛 message 는 무시.
if (window.__officeSnapshotSeen) break;
if (typeof window.__pixelOfficeApply === 'function') { if (typeof window.__pixelOfficeApply === 'function') {
window.__pixelOfficeApply(msg.value || {}); window.__pixelOfficeApply(msg.value || {});
} }
break; 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': { case 'companyAlignmentCard': {
// Intent Alignment 카드. kind에 따라 4가지 모드: // Intent Alignment 카드. kind에 따라 4가지 모드:
// - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용) // - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용)
@@ -1843,8 +1884,44 @@
? `1인 기업 ON · ${summary || ''}`.trim() ? `1인 기업 ON · ${summary || ''}`.trim()
: '1인 기업 모드 OFF — 클릭해서 켜기', : '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) { if (_companyChip) {
_companyChip.onclick = () => { _companyChip.onclick = () => {
const isActive = _companyChip.classList.contains('active'); const isActive = _companyChip.classList.contains('active');
+13 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -134,6 +134,18 @@
{ {
"command": "g1nation.company.pixelOffice.open", "command": "g1nation.company.pixelOffice.open",
"title": "Astra: Open Pixel Office (Full Screen)" "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": [ "keybindings": [
+279
View File
@@ -3303,6 +3303,185 @@ export class AgentExecutor {
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); } } catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
} }
// Action 9: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
// 형식: <create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>
// 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함),
// end | duration (분, default 60), location, all_day (true/false)
const calRegex = /<create_calendar_event\b([^>]*)>([\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.
// <read_sheet spreadsheet_id="1abc..." range="Sheet1!A1:D20"/>
// <write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
// 이름\t나이\t직책
// 민지\t29\t디자이너
// </write_sheet>
// <append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
// 2026-05-21\t새 항목\t완료
// </append_sheet>
const sheetReadRegex = /<read_sheet\b([^>/]*?)\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 = /<write_sheet\b([^>]*)>([\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 = /<append_sheet\b([^>]*)>([\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.
// <add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>
// <update_task id="t_001" status="in_progress" notes="진행중"/>
// <complete_task id="t_001"/>
const addTaskRegex = /<add_task\b([^>/]*?)\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 = /<update_task\b([^>/]*?)\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 = /<complete_task\b([^>/]*?)\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) { if (firstCreatedFile) {
// Always open file results in the editor group (column 2) — the ConnectAI // 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 // sidebar lives in column 3 and we don't want freshly-written files to
@@ -3369,3 +3548,103 @@ export class AgentExecutor {
} }
} }
} }
/**
* <add_task> / <update_task> / <complete_task> 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;
}
/**
* <read_sheet> / <write_sheet> / <append_sheet> 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;
}
/**
* <create_calendar_event ...> 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;
}
+207
View File
@@ -664,6 +664,22 @@ export async function activate(context: vscode.ExtensionContext) {
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소. // 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
provider?.openPixelOfficePanel(); 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 /** 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) { async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵 // 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl'); const existingUrl = vscode.workspace.getConfiguration('g1nation').get<string>('ollamaUrl');
+18
View File
@@ -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';
+181
View File
@@ -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<string, OfficePhase> = {
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<string, string> = {
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',
};
}
+305
View File
@@ -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<OfficePhase> = new Set<OfficePhase>([
'idle',
'intake',
'planning',
'executing',
'reviewing',
'awaiting-approval',
'reporting',
'done',
'error',
]);
const VALID_ROLES: ReadonlySet<OfficeAgentSnapshot['roleCategory']> = new Set([
'ceo',
'planner',
'researcher',
'designer',
'developer',
'qa',
'inspector',
'support',
'writer',
]);
const VALID_STATUSES: ReadonlySet<AgentStatus> = new Set<AgentStatus>([
'idle',
'intake',
'analyzing',
'need_clarification',
'contract_ready',
'planning',
'executing',
'reviewing',
'waiting_approval',
'error',
'done',
]);
const VALID_BUBBLE_TYPES = new Set<OfficeBubbleSeed['type']>([
'status',
'event',
'warning',
'error',
'success',
]);
const VALID_KINDS = new Set<NonNullable<OfficeActivityItem['kind']>>([
'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<string, unknown>;
const phaseRaw = String(r.phase ?? 'idle');
const phase: OfficePhase = (VALID_PHASES as ReadonlySet<string>).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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
if (typeof a.agentId !== 'string' || !a.agentId) return null;
const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet<string>).has(a.roleCategory)
? (a.roleCategory as OfficeAgentSnapshot['roleCategory'])
: 'support';
const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet<string>).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<string, unknown>;
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<string, unknown>;
if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null;
const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet<string>).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<string, unknown>;
if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null;
const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet<string>).has(b.type)
? (b.type as OfficeBubbleSeed['type'])
: 'status';
return { agentId: b.agentId, text: b.text, type };
}
@@ -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<OfficeDeskCell['face']>(['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<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const isV2 = r.schema === 2 || r.cells.some(
(c) =>
c && typeof c === 'object' &&
(typeof (c as Record<string, unknown>).deskSprite === 'string'
|| typeof (c as Record<string, unknown>).agentKey === 'string'
|| typeof (c as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet<string>).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<string, unknown>;
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];
}
@@ -0,0 +1,21 @@
// 자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등.
export const OFFICE_BODY = `
<body>
<header><div><div class="h-title">🏢 ASTRA OFFICE</div><div class="h-sub" id="agent">Astra</div></div><div style="display:flex;gap:8px;align-items:center;"><button id="editBtn" class="edit-btn" title="배치 편집 모드 토글"> </button><div class="status" id="status">idle</div></div></header>
<div id="miniMap" class="mini-map" style="display:none;"></div>
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint"> · <b>R</b> · <b>]</b>/<b>[</b> · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ </button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ </button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>🗑 </button>
<button id="layerUpBtn" title="레이어 위로 (])"></button>
<button id="layerDownBtn" title="레이어 아래로 ([)"></button>
<button id="saveBtn">💾 </button>
<button id="resetBtn" title="기본 배치로 복귀"> </button>
<button id="cancelBtn" title="저장 안 하고 종료"> </button>
</div>
<div class="strip"><span><b></b> <span id="task"></span></span><span><b></b> <span id="step"></span></span></div>
<main class="office"><div class="stage" id="stage"><div class="wall-window w1"></div><div class="wall-window w2"></div></div><div id="propPanel" class="prop-panel"></div></main>
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
<footer><div class="progress"><div class="bar" id="bar"></div></div><div id="log"></div></footer>
`;
@@ -0,0 +1,121 @@
// 자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등.
// design doc: docs/ASTRA_OFFICE_REFACTOR.md
export const OFFICE_CSS = `
<style>
:root{--bg:#0E1019;--wall:#202536;--floor:#302634;--floor2:#281F2C;--text:#F1F4FB;--muted:#A8B0C7;--accent:#7C83FF;}
*{box-sizing:border-box} body{margin:0;height:100vh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;flex-direction:column;overflow:hidden}
header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.22);border-bottom:1px solid rgba(255,255,255,.08)}
.h-title{font-weight:800}.h-sub{font-size:11px;color:var(--muted)}.status{font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.18);border-radius:999px}
.strip{display:flex;gap:16px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06)}.strip b{color:var(--text)}
.office{position:relative;flex:1;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#21283a 0 16%,transparent 16%),radial-gradient(ellipse at 50% 0%,rgba(124,131,255,.12),transparent 42%),linear-gradient(135deg,#322835,#271f2a)}
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);background-size:48px 48px}
.office:after{content:'';position:absolute;left:0;right:0;top:15.5%;height:8px;background:linear-gradient(180deg,rgba(0,0,0,.36),transparent)}
.stage{position:relative;width:720px;height:585px;margin:0}
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:3px solid rgba(206,223,255,.35);background:linear-gradient(180deg,rgba(160,208,255,.3),rgba(110,150,210,.1));box-shadow:inset 0 0 0 2px rgba(15,20,31,.55)}
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
.obj{filter:drop-shadow(3px 4px 0 rgba(0,0,0,.28));z-index:4}
.desk{width:112px;z-index:5;filter:drop-shadow(4px 5px 0 rgba(0,0,0,.32))}.desk.boss{width:136px}.label{position:absolute;left:50%;bottom:-10px;transform:translateX(-50%);font-size:10px;color:rgba(241,244,251,.78);white-space:nowrap;text-shadow:1px 1px #000}
.char{width:56px;height:72px;z-index:7;transition:left 1.17s cubic-bezier(.2,.7,.2,1),top 1.17s cubic-bezier(.2,.7,.2,1)}.char.walking{z-index:14}.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 2px 0 rgba(0,0,0,.45));transform-origin:center bottom}
.char.active:before{content:'';position:absolute;left:24px;top:-10px;width:8px;height:8px;background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent));animation:po-pulse 1.6s ease-in-out infinite}
@keyframes po-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.6}}
/* C. outline , .
data-role attribute로 . PNG sprite로 swap해도 . */
.char[data-agent="ceo"],.desk[data-agent="ceo"] {--role-color:#A78BFA}
.char[data-agent="planner"],.desk[data-agent="planner"] {--role-color:#60A5FA}
.char[data-agent="researcher"],.desk[data-agent="researcher"] {--role-color:#10B981}
.char[data-agent="designer"],.desk[data-agent="designer"] {--role-color:#F472B6}
.char[data-agent="developer"],.desk[data-agent="developer"] {--role-color:#FBBF24}
.char[data-agent="qa"],.desk[data-agent="qa"] {--role-color:#22D3EE}
.char[data-agent="inspector"],.desk[data-agent="inspector"] {--role-color:#FB923C}
.char[data-agent="support"],.desk[data-agent="support"] {--role-color:#94A3B8}
.char.active::after{content:'';position:absolute;left:0;right:0;bottom:-4px;height:3px;background:var(--role-color,var(--accent));box-shadow:0 0 8px var(--role-color,var(--accent));border-radius:2px;animation:po-glow 1.6s ease-in-out infinite}
@keyframes po-glow{0%,100%{opacity:.7}50%{opacity:1}}
/* desk line 3878 .obj,.desk,.char{position:absolute} .
.desk{position:relative} cascade로 override , normal-flow Y
stage . ::after pseudo absolute parent . */
.desk::after{content:'';position:absolute;inset:-2px;border-radius:4px;border:2px solid transparent;pointer-events:none;transition:border-color .3s}
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after
{border-color:var(--role-color)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);background:#fff;color:#222;padding:5px 8px;border-radius:8px;font-size:11px;box-shadow:2px 2px 0 rgba(0,0,0,.35);white-space:nowrap}
.edit-btn{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.2);color:#F1F4FB;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px}.edit-btn:hover{background:rgba(99,102,241,.25);border-color:#6366F1}
/* B. dot strip. dot stage . =
, = , = . . */
.mini-map{display:flex;gap:5px;align-items:center;padding:7px 16px;background:rgba(0,0,0,.3);border-bottom:1px solid rgba(255,255,255,.06);overflow-x:auto;scrollbar-width:none}.mini-map::-webkit-scrollbar{display:none}
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.12);border:1.5px solid rgba(255,255,255,.18);flex-shrink:0;cursor:default;transition:all .25s}
.mini-map .mm-dot[data-status="done"]{background:#10B981;border-color:#10B981;box-shadow:0 0 4px rgba(16,185,129,.5)}
.mini-map .mm-dot[data-status="active"]{background:var(--accent);border-color:var(--accent);width:14px;height:14px;box-shadow:0 0 0 3px rgba(99,102,241,.3);animation:mm-pulse 1.4s ease-in-out infinite}
@keyframes mm-pulse{0%,100%{box-shadow:0 0 0 3px rgba(99,102,241,.3)}50%{box-shadow:0 0 0 6px rgba(99,102,241,.15)}}
.mini-map .mm-bar{flex:1;height:1px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.16))}
.mini-map .mm-label{position:absolute;left:50%;top:-22px;transform:translateX(-50%);font-size:10px;color:#F1F4FB;background:rgba(0,0,0,.85);padding:2px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50}
.mini-map .mm-dot:hover .mm-label{opacity:1}
.mini-map .mm-counter{flex-shrink:0;font-size:10px;color:#94A3B8;margin-left:8px;white-space:nowrap}
/* E. Activity Ticker action-tag executor strip .
* *( , ) . */
.ticker{position:relative;padding:5px 16px;background:rgba(99,102,241,.08);border-top:1px solid rgba(99,102,241,.18);overflow:hidden;font-size:11px;font-family:ui-monospace,monospace;height:24px}
.tk-track{display:flex;gap:18px;white-space:nowrap;animation:tk-roll 22s linear infinite;will-change:transform}
.ticker:hover .tk-track{animation-play-state:paused}
.tk-item{flex-shrink:0;color:#D7DBEA}
.tk-item.tk-ok{color:#10B981}
.tk-item.tk-warn{color:#F5C518}
.tk-item.tk-err{color:#EF4444}
.tk-item .tk-agent{color:#A78BFA;margin-right:5px;font-weight:600}
@keyframes tk-roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
/* D. X popup.
turn + . */
.ctx-menu{position:fixed;z-index:1000;background:#13162A;border:1px solid #2A2E3F;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:4px;min-width:170px;font-size:12px;color:#F1F4FB}
.ctx-menu-head{padding:6px 10px 4px;font-size:10px;color:#94A3B8;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-radius:4px;transition:background .12s}
.ctx-menu-item:hover{background:rgba(99,102,241,.18)}
.ctx-menu-item.danger:hover{background:rgba(239,68,68,.18);color:#FCA5A5}
.ctx-menu-divider{height:1px;background:rgba(255,255,255,.08);margin:3px 4px}
body[data-edit-mode="true"] .ctx-menu{display:none!important}
body:not([data-edit-mode="true"]) .char{cursor:pointer}
.ctx-detail{position:fixed;z-index:1001;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.7);padding:16px 18px;color:#F1F4FB;min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
.ctx-detail h3{margin:0 0 8px;font-size:14px;color:var(--role-color,#A78BFA);text-transform:uppercase;letter-spacing:.04em}
.ctx-detail .cd-close{position:absolute;top:8px;right:10px;background:transparent;border:none;color:#94A3B8;font-size:16px;cursor:pointer}
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.ctx-detail dt{color:#94A3B8;font-weight:600;white-space:nowrap}
.ctx-detail dd{margin:0;color:#F1F4FB;overflow-wrap:anywhere}
.ctx-detail .cd-logs{margin-top:10px;padding:6px 8px;background:rgba(0,0,0,.3);border-radius:4px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
.edit-toolbar{display:flex;gap:8px;align-items:center;padding:6px 16px;background:rgba(99,102,241,.18);border-bottom:1px solid rgba(99,102,241,.4);font-size:11px;flex-wrap:wrap}.edit-toolbar .et-hint{flex:1;color:#D7DBEA;min-width:160px}.edit-toolbar button{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#F1F4FB;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}.edit-toolbar button:hover{background:rgba(99,102,241,.35)}
.edit-toolbar button.add{background:rgba(16,185,129,.22);border-color:rgba(16,185,129,.55)}.edit-toolbar button.add:hover{background:rgba(16,185,129,.4)}
.edit-toolbar button.del{background:rgba(239,68,68,.22);border-color:rgba(239,68,68,.55)}.edit-toolbar button.del:hover{background:rgba(239,68,68,.4)}.edit-toolbar button[disabled]{opacity:.4;cursor:not-allowed}
/* 선택된 item 의 속성 편집 패널 — 우측 슬라이드 */
.prop-panel{position:absolute;right:12px;top:90px;width:240px;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:12px;font-size:11px;color:#F1F4FB;z-index:25;display:none}
.prop-panel.show{display:block}
.prop-panel h4{margin:0 0 8px;font-size:12px;color:#A78BFA;text-transform:uppercase;letter-spacing:.04em}
.prop-panel .pp-row{margin-bottom:8px}
.prop-panel label{display:block;font-size:10px;color:#94A3B8;margin-bottom:2px}
.prop-panel select,.prop-panel input{width:100%;background:#0c1020;color:#F1F4FB;border:1px solid #2A2E3F;border-radius:4px;padding:3px 6px;font-size:11px}
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:4px}
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:#0c1020;border:1px solid #2A2E3F;border-radius:3px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:2px}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:#A78BFA;box-shadow:0 0 0 2px rgba(167,139,250,.35)}
/* 프랍 추가 picker — 모달 grid */
.prop-picker{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:#13162A;border:1px solid #2A2E3F;border-radius:10px;padding:14px;max-width:520px;max-height:80vh;overflow-y:auto;color:#F1F4FB}
.prop-picker-box h3{margin:0 0 10px;font-size:13px;color:#A78BFA}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
.prop-pick{background:#0c1020;border:1px solid #2A2E3F;border-radius:4px;padding:6px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:#A78BFA}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:#94A3B8;margin-top:4px;word-break:break-all}
/* 편집 모드 — 드래그 가능 요소 강조 */
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(99,102,241,.15) 1px,transparent 1px),linear-gradient(90deg,rgba(99,102,241,.15) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(99,102,241,.5)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid #6366F1;z-index:30}
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.7;outline:2px solid #FB923C!important;z-index:40}
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.25);z-index:35}
body[data-edit-mode="true"] .char .shadow{display:none}
footer{padding:8px 16px 12px;border-top:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.25);font-size:11px;color:var(--muted)}.progress{height:5px;background:rgba(255,255,255,.08);margin-bottom:6px}.bar{height:100%;width:0;background:var(--accent);transition:width .25s}
`;
@@ -0,0 +1,26 @@
// Full Astra Office webview HTML composition.
// 옛 sidebarProvider.ts 의 거대한 _pixelOfficePanelHtml 을 4개 파일로 분리한 entry.
// 이번 세션은 *동작 동등 분리* 만. 다음 세션에 mini view 와 공통 presenter 도입.
import { OFFICE_CSS } from './officeStyles';
import { OFFICE_BODY } from './officeBody';
import { officeRuntimeJs } from './runtime';
export interface AstraOfficePanelAssets {
cspSource: string;
derivedBase: string;
}
export function renderAstraOfficePanelHtml(assets: AstraOfficePanelAssets): string {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${assets.cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
${OFFICE_CSS}
</style></head>
<body>
${OFFICE_BODY}
${officeRuntimeJs(assets.derivedBase)}</body></html>`;
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
/**
* Google Calendar API v3 event create/list .
*
* access token caller . `withFreshAccessToken`
* refresh token 401 + .
*
* (googleapis) Calendar API REST native fetch .
*/
import * as vscode from 'vscode';
import { refreshAccessToken } from './oauth';
import { readCalendarConfig, writeCalendarConfig } from './calendarCache';
const API_BASE = 'https://www.googleapis.com/calendar/v3';
export interface CalendarEventInput {
/** 일정 제목 (필수). */
title: string;
/** ISO 시작 시각 — 'YYYY-MM-DDTHH:MM' (로컬) 또는 'YYYY-MM-DDTHH:MM:SS±HH:MM' (timezone 포함). */
start: string;
/** ISO 종료 시각. 없으면 duration(분) 으로부터 계산. duration 도 없으면 60분. */
end?: string;
/** end 없을 때 시작부터 이만큼 (분 단위, default 60). */
durationMinutes?: number;
description?: string;
location?: string;
/** all-day 일정 여부 — true 면 start 는 'YYYY-MM-DD' 만 받음. */
allDay?: boolean;
}
export interface CreatedEvent {
/** Google 이 발급한 event id. */
id: string;
/** Google Calendar 웹에서 열 수 있는 URL. */
htmlLink: string;
/** API 가 echo 해준 시작 시각. */
startIso: string;
title: string;
}
/**
* . config refresh token . access token .
*
* :
* ok: true CreatedEvent
* ok: false (UI )
*/
export async function createCalendarEvent(
context: vscode.ExtensionContext,
input: CalendarEventInput,
): Promise<{ ok: true; event: CreatedEvent } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
const tokenResult = await _getFreshAccessToken(context);
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
const body = _buildEventBody(input, cfg.defaultDurationMinutes ?? 60);
if (!body.ok) return { ok: false, error: body.error };
const calId = (cfg.calendarId || 'primary').trim() || 'primary';
const url = `${API_BASE}/calendars/${encodeURIComponent(calId)}/events`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tokenResult.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body.event),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = json?.error?.message || `HTTP ${res.status}`;
return { ok: false, error: msg };
}
return {
ok: true,
event: {
id: json.id,
htmlLink: json.htmlLink,
startIso: json.start?.dateTime ?? json.start?.date ?? input.start,
title: input.title,
},
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/** Calendar API 요청 body 빌더 — 단위테스트 가능하도록 분리. */
export function _buildEventBody(
input: CalendarEventInput,
fallbackDurationMin: number,
): { ok: true; event: any } | { ok: false; error: string } {
if (!input.title || !input.title.trim()) return { ok: false, error: 'title 비어있음' };
if (!input.start || !input.start.trim()) return { ok: false, error: 'start 비어있음' };
if (input.allDay) {
// all-day: date 형식만 (YYYY-MM-DD). end 는 exclusive 라 다음 날.
const startDate = input.start.slice(0, 10);
const endDate = input.end ? input.end.slice(0, 10) : _addDaysDate(startDate, 1);
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { date: startDate },
end: { date: endDate },
reminders: { useDefault: true },
},
};
}
let endIso: string | undefined = input.end;
if (!endIso) {
const dur = (input.durationMinutes && input.durationMinutes > 0) ? input.durationMinutes : fallbackDurationMin;
const computed = _addMinutesIso(input.start, dur);
if (!computed) return { ok: false, error: `start 시각 형식 오류: ${input.start}` };
endIso = computed;
}
// Google Calendar 는 timezone 정보가 없으면 timeZone 필드 별도 필요.
// 'YYYY-MM-DDTHH:MM' 처럼 timezone 빠진 입력은 OS 로컬 timezone 으로 가정.
const hasOffset = /([+-]\d{2}:\d{2}|Z)$/.test(input.start);
const timeZone = hasOffset ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { dateTime: input.start, ...(timeZone ? { timeZone } : {}) },
end: { dateTime: endIso, ...(timeZone ? { timeZone } : {}) },
reminders: {
useDefault: false,
overrides: [
{ method: 'popup', minutes: 5 },
{ method: 'popup', minutes: 60 },
],
},
},
};
}
/** 'YYYY-MM-DDTHH:MM[:SS][±HH:MM|Z]' 에 분 더해 ISO 반환. 잘못된 형식이면 null. */
export function _addMinutesIso(startIso: string, minutes: number): string | null {
// 안전한 파싱: 명시적 정규식 → Date → ISO 재조립. timezone 정보 보존.
const m = startIso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/);
if (!m) return null;
const [, base, sec, tz] = m;
const full = `${base}${sec ?? ':00'}${tz ?? ''}`;
const t = new Date(full);
if (Number.isNaN(t.getTime())) return null;
const out = new Date(t.getTime() + minutes * 60 * 1000);
// 원본이 timezone 정보 없는 로컬 시각이면 같은 포맷으로 돌려준다.
if (!tz) {
const yy = out.getFullYear();
const MM = String(out.getMonth() + 1).padStart(2, '0');
const dd = String(out.getDate()).padStart(2, '0');
const hh = String(out.getHours()).padStart(2, '0');
const mm = String(out.getMinutes()).padStart(2, '0');
const ss = String(out.getSeconds()).padStart(2, '0');
return `${yy}-${MM}-${dd}T${hh}:${mm}:${ss}`;
}
return out.toISOString();
}
/** 'YYYY-MM-DD' + N 일. all-day 일정 end 계산용. */
export function _addDaysDate(yyyymmdd: string, days: number): string {
const m = yyyymmdd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return yyyymmdd;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
d.setDate(d.getDate() + days);
const yy = d.getFullYear(), MM = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
return `${yy}-${MM}-${dd}`;
}
/**
* access token , refresh. config .
* config .
*
* Calendar / Sheets API token (scope OAuth ).
* `_` prefix export Sheets API client .
*/
export async function getFreshAccessToken(
context: vscode.ExtensionContext,
): Promise<{ ok: true; accessToken: string } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
return { ok: false, error: 'Google OAuth 가 설정되지 않았습니다. "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 한 번 로그인하세요. (Calendar 와 Sheets 권한이 함께 발급됩니다.)' };
}
const now = Date.now();
if (cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > now) {
return { ok: true, accessToken: cfg.accessToken };
}
const r = await refreshAccessToken(cfg.clientId, cfg.clientSecret, cfg.refreshToken);
if (!r.ok) return { ok: false, error: r.error };
await writeCalendarConfig(context, { accessToken: r.accessToken, accessTokenExpiresAt: r.expiresAt });
return { ok: true, accessToken: r.accessToken };
}
// 내부 호출용 alias 유지 — 한 줄짜리라 비용 없음.
const _getFreshAccessToken = getFreshAccessToken;
+170
View File
@@ -0,0 +1,170 @@
/**
* Google Calendar (iCal) fetch + parse + _shared/calendar_cache.md .
*
* Connect_origin google_calendar.py TypeScript / native fetch . OAuth .
* Google Calendar "비공개 주소(iCal 형식)"
* agent turn .
*
* :
* - iCal URL ExtensionContext.globalState (machine-local, git X).
* - `_shared/calendar_cache.md` .
* .gitignore / commit
* README/ .
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { parseIcs, selectUpcoming, IcsEvent } from './icsParser';
/** globalState 키 — iCal URL 과 부수 설정 한 묶음. */
export const CAL_CONFIG_KEY = 'g1nation.calendar.ical';
export interface CalendarConfig {
/** Google Calendar 비공개 iCal URL. 빈 문자열이면 iCal 읽기 비활성. */
icalUrl: string;
/** 며칠치 미리 가져올지 (default 14). */
daysAhead: number;
/** 마지막 성공 fetch ISO timestamp (자동 표시용). */
lastFetchAt?: string;
// ── OAuth (쓰기) 관련 필드 — Google Calendar API v3 호출에 사용. ──
// 모두 ExtensionContext.globalState 에만 저장 (machine-local). 옵션이라 비어있어도 iCal 읽기는 동작.
/** Google Cloud Console 에서 발급한 OAuth Client ID. */
clientId?: string;
/** 같은 페이지의 Client Secret (Desktop app 의 secret 은 공개 가능한 식별자). */
clientSecret?: string;
/** OAuth 로 받은 refresh token — 진짜 비밀. machine-local. */
refreshToken?: string;
/** Calendar API 가 쓰는 캘린더 식별자 — 'primary' 또는 특정 calendarId. */
calendarId?: string;
/** end 없는 이벤트 default 길이 (분). */
defaultDurationMinutes?: number;
/** 캐시된 access token (만료 전까지 재사용). */
accessToken?: string;
/** access token 만료 epoch ms. */
accessTokenExpiresAt?: number;
/** 연결된 Google 계정 이메일 (UI 표시용). */
connectedAs?: string;
/** OAuth 연결 시각 ISO. */
connectedAt?: string;
}
export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig {
const raw = context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig> | undefined;
return {
icalUrl: typeof raw?.icalUrl === 'string' ? raw.icalUrl : '',
daysAhead: typeof raw?.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14,
lastFetchAt: typeof raw?.lastFetchAt === 'string' ? raw.lastFetchAt : undefined,
clientId: typeof raw?.clientId === 'string' ? raw.clientId : undefined,
clientSecret: typeof raw?.clientSecret === 'string' ? raw.clientSecret : undefined,
refreshToken: typeof raw?.refreshToken === 'string' ? raw.refreshToken : undefined,
calendarId: typeof raw?.calendarId === 'string' ? raw.calendarId : undefined,
defaultDurationMinutes: typeof raw?.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined,
accessToken: typeof raw?.accessToken === 'string' ? raw.accessToken : undefined,
accessTokenExpiresAt: typeof raw?.accessTokenExpiresAt === 'number' ? raw.accessTokenExpiresAt : undefined,
connectedAs: typeof raw?.connectedAs === 'string' ? raw.connectedAs : undefined,
connectedAt: typeof raw?.connectedAt === 'string' ? raw.connectedAt : undefined,
};
}
export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial<CalendarConfig>): Promise<void> {
const cur = readCalendarConfig(context);
const next: CalendarConfig = { ...cur, ...patch };
await context.globalState.update(CAL_CONFIG_KEY, next);
}
/** 회사 디렉토리 내부 캐시 파일 경로. workspace 없으면 globalStorage 로 fallback. */
function _cachePath(context: vscode.ExtensionContext): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'calendar_cache.md');
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'calendar_cache.md');
}
export interface RefreshResult {
ok: boolean;
count: number;
error?: string;
cachePath: string;
}
/**
* iCal URL fetch ICS upcoming .
* URL ok:false ( ).
*/
export async function refreshCalendarCache(context: vscode.ExtensionContext): Promise<RefreshResult> {
const cfg = readCalendarConfig(context);
const cachePath = _cachePath(context);
if (!cfg.icalUrl) {
return { ok: false, count: 0, error: 'iCal URL 이 설정되지 않았습니다. 명령 팔레트에서 "Astra: Google Calendar 연결" 을 먼저 실행하세요.', cachePath };
}
if (!/^https?:\/\//.test(cfg.icalUrl)) {
return { ok: false, count: 0, error: 'URL 이 http:// 또는 https:// 로 시작하지 않습니다.', cachePath };
}
let raw: string;
try {
// Node 18+ 의 native fetch 사용 — axios / node-fetch 의존성 없이.
const res = await fetch(cfg.icalUrl, {
method: 'GET',
headers: { 'User-Agent': 'Astra-Calendar/1.0' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
return { ok: false, count: 0, error: `HTTP ${res.status} — URL 이 잘못됐거나 만료됐을 수 있습니다.`, cachePath };
}
raw = await res.text();
} catch (e: any) {
return { ok: false, count: 0, error: `다운로드 실패: ${e?.message ?? String(e)}`, cachePath };
}
const events = parseIcs(raw);
const upcoming = selectUpcoming(events, cfg.daysAhead);
const now = new Date();
const md = _renderMarkdown(upcoming, cfg.daysAhead, now);
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, md, 'utf8');
} catch (e: any) {
return { ok: false, count: 0, error: `캐시 저장 실패: ${e?.message ?? String(e)}`, cachePath };
}
await writeCalendarConfig(context, { lastFetchAt: now.toISOString() });
return { ok: true, count: upcoming.length, cachePath };
}
/** Agent prompt 에 주입할 캐시 본문 읽기. 없으면 빈 문자열. */
export function readCalendarCache(context: vscode.ExtensionContext): string {
const cachePath = _cachePath(context);
try {
if (!fs.existsSync(cachePath)) return '';
return fs.readFileSync(cachePath, 'utf8');
} catch {
return '';
}
}
function _renderMarkdown(events: IcsEvent[], daysAhead: number, now: Date): string {
const tsLabel = (d: Date, allDay: boolean) => {
const yy = d.getFullYear(), mm = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
const wk = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()];
if (allDay) return `${yy}-${mm}-${dd} (${wk})`;
const HH = String(d.getHours()).padStart(2, '0'), MM = String(d.getMinutes()).padStart(2, '0');
return `${yy}-${mm}-${dd} (${wk}) ${HH}:${MM}`;
};
const lines: string[] = [
'# 📅 다가오는 일정 (Google Calendar)',
`_업데이트: ${tsLabel(now, false)} · 향후 ${daysAhead}일_`,
'',
];
if (events.length === 0) {
lines.push('_없음_');
} else {
for (const ev of events) {
const ts = tsLabel(ev.start, ev.allDay);
const loc = ev.location ? ` — 📍 ${ev.location}` : '';
lines.push(`- **${ts}** · ${ev.summary}${loc}`);
}
}
return lines.join('\n') + '\n';
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Minimal ICS parser no library deps. Connect_origin Python
* , *pure* .
*
* :
* - VEVENT
* - line continuation ( )
* - SUMMARY / DESCRIPTION / LOCATION / DTSTART / DTEND
* - DTSTART;VALUE=DATE all-day
* - YYYYMMDD / YYYYMMDDTHHMMSS / ...Z (UTC)
*
* ( v2):
* - RRULE ( instance )
* - TZID (UTC )
* - VTIMEZONE
* - / /
*/
export interface IcsEvent {
/** 시작 시각 (로컬 Date 기준). all-day 일정은 자정. */
start: Date;
/** 종료 시각. 없으면 undefined. */
end?: Date;
/** 제목 (없으면 '(제목 없음)'). */
summary: string;
location: string;
description: string;
/** DTSTART 가 VALUE=DATE 형식이었으면 true — 시각은 무시하고 날짜만 의미. */
allDay: boolean;
}
/** 한 문자열의 ICS 본문을 받아 VEVENT 들을 배열로 반환. 잘못된 입력은 빈 배열. */
export function parseIcs(raw: string): IcsEvent[] {
if (!raw || typeof raw !== 'string') return [];
// Line continuation: ICS 는 75자 wrap 시 다음 줄이 공백/탭으로 시작 → 합쳐줘야 한다.
const unfolded = raw.replace(/\r?\n[ \t]/g, '');
const events: IcsEvent[] = [];
let cur: Record<string, string> | null = null;
let curDateOnly: Record<'start' | 'end', boolean> = { start: false, end: false };
for (const rawLine of unfolded.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (line === 'BEGIN:VEVENT') {
cur = {};
curDateOnly = { start: false, end: false };
} else if (line === 'END:VEVENT') {
if (cur) {
const ev = _toEvent(cur, curDateOnly);
if (ev) events.push(ev);
}
cur = null;
} else if (cur && line.includes(':')) {
const colonIdx = line.indexOf(':');
const keyPart = line.slice(0, colonIdx);
const value = line.slice(colonIdx + 1);
const base = keyPart.split(';', 1)[0];
if (base === 'SUMMARY' || base === 'DESCRIPTION' || base === 'LOCATION'
|| base === 'DTSTART' || base === 'DTEND') {
cur[base] = value;
if ((base === 'DTSTART' || base === 'DTEND') && keyPart.includes(';VALUE=DATE')) {
curDateOnly[base === 'DTSTART' ? 'start' : 'end'] = true;
}
}
}
}
return events;
}
/** 시작 시각 기준 오름차순 정렬 + 현재 시각 - 1시간 ~ 미래 cutoffDays 범위만 필터. */
export function selectUpcoming(events: IcsEvent[], daysAhead: number, now: Date = new Date()): IcsEvent[] {
const past = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const cutoff = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
return events
.filter((e) => e.start >= past && e.start <= cutoff)
.sort((a, b) => a.start.getTime() - b.start.getTime());
}
function _toEvent(raw: Record<string, string>, dateOnly: { start: boolean; end: boolean }): IcsEvent | null {
const start = _parseDt(raw.DTSTART ?? '');
if (!start) return null;
const end = _parseDt(raw.DTEND ?? '');
return {
start,
end: end ?? undefined,
summary: _unescape(raw.SUMMARY ?? '(제목 없음)'),
location: _unescape(raw.LOCATION ?? ''),
description: _unescape(raw.DESCRIPTION ?? ''),
allDay: dateOnly.start,
};
}
function _parseDt(s: string): Date | null {
if (!s) return null;
const trimmed = s.trim();
// Strip trailing Z (UTC marker — Date 파싱 시 자동 처리되지 않으므로 명시 변환).
const utc = trimmed.endsWith('Z');
const core = utc ? trimmed.slice(0, -1) : trimmed;
// Two valid forms: YYYYMMDDTHHMMSS or YYYYMMDD
const m = core.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2}))?$/);
if (!m) return null;
const [, yy, mm, dd, HH, MM, SS] = m;
const year = parseInt(yy, 10), month = parseInt(mm, 10) - 1, day = parseInt(dd, 10);
const hour = HH ? parseInt(HH, 10) : 0;
const min = MM ? parseInt(MM, 10) : 0;
const sec = SS ? parseInt(SS, 10) : 0;
if (utc) {
return new Date(Date.UTC(year, month, day, hour, min, sec));
}
return new Date(year, month, day, hour, min, sec);
}
function _unescape(s: string): string {
// ICS literal escapes: \, \; \n \\
return s.replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/g, ' ').replace(/\\\\/g, '\\');
}
+32
View File
@@ -0,0 +1,32 @@
export {
parseIcs,
selectUpcoming,
IcsEvent,
} from './icsParser';
export {
CAL_CONFIG_KEY,
CalendarConfig,
readCalendarConfig,
writeCalendarConfig,
refreshCalendarCache,
readCalendarCache,
RefreshResult,
} from './calendarCache';
export {
runOAuthLoopback,
refreshAccessToken,
fetchUserEmail,
OAuthResult,
OAuthFailure,
} from './oauth';
export {
createCalendarEvent,
CalendarEventInput,
CreatedEvent,
_buildEventBody,
_addMinutesIso,
_addDaysDate,
} from './calendarApi';
+235
View File
@@ -0,0 +1,235 @@
/**
* Google OAuth 2.0 loopback (Desktop app) .
*
* Google Desktop OAuth client http://127.0.0.1:<ephemeral_port>
* redirect URI . :
* 1. ephemeral port HTTP
* 2. Google
* 3. code
* 4. code access/refresh token
* 5.
*
* 보안: refresh token globalState (machine-local).
* Client ID/Secret , Desktop app client secret
* Google * * ( 방지: 진짜 ).
* refresh token .
*/
import * as http from 'http';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
const SCOPE = [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/spreadsheets',
'openid',
'email',
].join(' ');
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export interface OAuthResult {
ok: true;
accessToken: string;
refreshToken: string;
/** Google 가 동의한 scope 들 (공백 구분). */
scope: string;
/** access token 만료 epoch ms. */
expiresAt: number;
}
export interface OAuthFailure { ok: false; error: string; }
/**
* OAuth flow cancel cancelToken trip ok:false.
*
* @param clientId Google Cloud OAuth Client ID
* @param clientSecret Client Secret
* @param cancelToken VS Code Progress
*/
export async function runOAuthLoopback(
clientId: string,
clientSecret: string,
cancelToken: vscode.CancellationToken,
): Promise<OAuthResult | OAuthFailure> {
return new Promise<OAuthResult | OAuthFailure>((resolve) => {
let _settled = false;
const settle = (v: OAuthResult | OAuthFailure) => { if (_settled) return; _settled = true; resolve(v); };
// CSRF 방어 — state 파라미터 검증.
const expectedState = crypto.randomBytes(16).toString('hex');
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
const code = url.searchParams.get('code');
const err = url.searchParams.get('error');
const stateParam = url.searchParams.get('state');
// favicon / 빈 callback 요청은 무시 (브라우저 자동 요청)
if (!code && !err) { res.writeHead(204); res.end(); return; }
if (stateParam !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('state mismatch — possible CSRF. 다시 시도하세요.');
server.close();
settle({ ok: false, error: 'state mismatch' });
return;
}
if (err) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('실패', err, false));
server.close();
settle({ ok: false, error: err });
return;
}
// Got the code — exchange for tokens.
const port = (server.address() as any)?.port;
const redirectUri = `http://127.0.0.1:${port}`;
const body = new URLSearchParams({
code: code!,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
try {
const tokenRes = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
const json: any = await tokenRes.json().catch(() => ({}));
if (!tokenRes.ok || !json.access_token || !json.refresh_token) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('토큰 교환 실패', JSON.stringify(json).slice(0, 300), false));
server.close();
settle({ ok: false, error: json.error_description || json.error || 'no refresh_token in response' });
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('연결 완료', '이 탭은 닫아도 됩니다.', true));
server.close();
settle({
ok: true,
accessToken: json.access_token,
refreshToken: json.refresh_token,
scope: json.scope ?? '',
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
});
} catch (e: any) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('네트워크 오류', e?.message ?? String(e), false));
server.close();
settle({ ok: false, error: e?.message ?? String(e) });
}
} catch (e: any) {
try { res.writeHead(500); res.end(); } catch { /* ignore */ }
server.close();
settle({ ok: false, error: e?.message ?? String(e) });
}
});
// ephemeral port (0) — Desktop OAuth client 는 어떤 localhost port 도 허용.
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as any)?.port;
const redirectUri = `http://127.0.0.1:${port}`;
const authUrl = `${AUTH_URL}?` + new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: SCOPE,
access_type: 'offline',
prompt: 'consent', // refresh_token 을 *항상* 발급받기 위해 강제 (Google 의 default 는 처음 한 번만 발급).
state: expectedState,
}).toString();
void vscode.env.openExternal(vscode.Uri.parse(authUrl));
});
// 사용자 cancel 시 서버 닫고 종료.
cancelToken.onCancellationRequested(() => {
try { server.close(); } catch { /* ignore */ }
settle({ ok: false, error: 'cancelled' });
});
// 안전망 — 5분 무응답 시 자동 종료.
const tHandle = setTimeout(() => {
try { server.close(); } catch { /* ignore */ }
settle({ ok: false, error: 'timeout (5분)' });
}, 5 * 60 * 1000);
cancelToken.onCancellationRequested(() => clearTimeout(tHandle));
});
}
/** refresh_token 으로 새 access_token 발급. 만료된 access token 자동 갱신용. */
export async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string,
): Promise<{ ok: true; accessToken: string; expiresAt: number } | { ok: false; error: string }> {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
try {
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok || !json.access_token) {
return { ok: false, error: json.error_description || json.error || `HTTP ${res.status}` };
}
return {
ok: true,
accessToken: json.access_token,
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/** access token 으로 사용자 이메일 조회 — 누가 연결됐는지 보여주기 위함. */
export async function fetchUserEmail(accessToken: string): Promise<string> {
try {
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return '';
const json: any = await res.json();
return json?.email ?? json?.name ?? '';
} catch {
return '';
}
}
/** 셋업 완료 페이지 — 깔끔한 메시지 + 자동으로 탭 닫기 안내. */
function _renderHtml(title: string, msg: string, success: boolean): string {
const color = success ? '#10b981' : '#ef4444';
const icon = success ? '✅' : '⚠️';
return `<!doctype html><html lang="ko"><head><meta charset="utf-8">
<title>Astra · ${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b1018;color:#e2e8f0;font-family:-apple-system,system-ui,sans-serif}
.card{background:rgba(20,28,40,.96);border:1px solid ${color};border-radius:14px;padding:36px 32px;max-width:420px;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.6)}
.icon{font-size:48px;margin-bottom:12px}
h1{font-size:20px;margin-bottom:12px;color:${color}}
p{color:#94a3b8;font-size:13px;line-height:1.6}
small{color:#475569;font-size:11px;margin-top:18px;display:block}
</style></head><body>
<div class="card"><div class="icon">${icon}</div><h1>${title}</h1><p>${_esc(msg)}</p><small> .</small></div>
</body></html>`;
}
function _esc(s: string): string {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } as Record<string, string>)[c],
);
}
+16 -1
View File
@@ -108,7 +108,22 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션', specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션',
tagline: '일정·리소스·소통을 챙기고 정리합니다', tagline: '일정·리소스·소통을 챙기고 정리합니다',
roleCategory: 'support', roleCategory: 'support',
persona: '친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.', persona: `친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.
**·· ( ):**
4 * * * * emit:
1. ** ** ( //) \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
2. **** ( to-do, ) \`<add_task>\` 로 추적기에만 등록. 시각 확정 안 됐으면 due 비움.
3. ** ** (·) "## 결정" (decisions.md ).
4. ** ** ("다음주", "조만간") emit . "❓ 확정 필요: …" .
** **: "어제 X 끝냈어" / "Y 블락됐어" ** \`<update_task>\` 또는 \`<complete_task>\` emit. "잘 챙겨드릴게요" 라고 말만 하지 말고 태그로 실제 갱신.
** ** ( ):
- 📅 등록: 제목 ·
- 📋 추가: 제목 · ·
- 완료: 제목`,
}, },
writer: { writer: {
id: 'writer', id: 'writer',
+17
View File
@@ -566,6 +566,21 @@ async function _dispatchOne(
} }
const memory = readAgentMemory(deps.context, agentId); const memory = readAgentMemory(deps.context, agentId);
const decisions = readDecisions(deps.context, 2000); const decisions = readDecisions(deps.context, 2000);
// Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시.
// 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능.
let calendarContext = '';
try {
const { readCalendarCache } = require('../calendar') as typeof import('../calendar');
calendarContext = readCalendarCache(deps.context) ?? '';
} catch { /* feature 미설치 / 캐시 없음 — silent skip */ }
// Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을
// 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략.
let tasksContext = '';
try {
const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks');
tasksContext = summarizeActiveTasks(readTaskStore(deps.context));
} catch { /* silent */ }
const peerOutputs = earlierOutputs const peerOutputs = earlierOutputs
.filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent .filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
.map((o) => { .map((o) => {
@@ -624,6 +639,8 @@ async function _dispatchOne(
const system = buildSpecialistPrompt({ const system = buildSpecialistPrompt({
agentId, state, agentId, state,
agentMemory: memory, sharedDecisions: decisions, agentMemory: memory, sharedDecisions: decisions,
calendarContext,
tasksContext,
peerOutputs, peerOutputs,
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
+30 -2
View File
@@ -239,12 +239,40 @@ const PLAN_ONLY: PipelineTemplate = {
], ],
}; };
/** Read-only registry of templates the UI surfaces. Add more here later. */ /**
* "개발까지만" FULL_PRODUCT_DEV 1~10 (plan-discuss ~ dev-impl) .
* QA· . dev-impl
* CEO turn . design-review dev-design loop-back .
*
* stages FULL_PRODUCT_DEV.stages.slice(0, 10) * *
* 릿 stamp deep-copy read-only .
*/
const DEV_ONLY: PipelineTemplate = {
templateId: 'dev-only',
name: '개발까지만 (10단계)',
description: '풀 워크플로에서 QA·배포 단계를 뺀 버전. 기획 → 개발까지 만들고 검증·배포는 사용자가 직접 챙깁니다.',
suggestedPipelineId: 'dev-only',
suggestedPipelineName: '기획→개발 파이프라인',
stages: FULL_PRODUCT_DEV.stages.slice(0, 10),
};
/** Read-only registry of templates the UI surfaces. */
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [ export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
FULL_PRODUCT_DEV,
PLAN_ONLY, PLAN_ONLY,
DEV_ONLY,
FULL_PRODUCT_DEV,
]; ];
/**
* 3-way .
* PIPELINE_TEMPLATES templateId 1:1. UI .
*/
export const SCOPE_PRESETS = [
{ templateId: 'plan-only', shortLabel: '기획만', longLabel: '기획서까지만' },
{ templateId: 'dev-only', shortLabel: '개발까지', longLabel: '개발까지만' },
{ templateId: 'full-product-dev', shortLabel: '풀', longLabel: '배포까지 풀 파이프라인' },
] as const;
export function getPipelineTemplate(id: string): PipelineTemplate | undefined { export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
return PIPELINE_TEMPLATES.find((t) => t.templateId === id); return PIPELINE_TEMPLATES.find((t) => t.templateId === id);
} }
+54
View File
@@ -28,6 +28,18 @@ export interface SpecialistPromptInputs {
agentMemory?: string; agentMemory?: string;
/** Tail of `_shared/decisions.md` (may be empty). */ /** Tail of `_shared/decisions.md` (may be empty). */
sharedDecisions?: string; sharedDecisions?: string;
/**
* `_shared/calendar_cache.md` (Google Calendar iCal feature).
* prompt . agent ·
* secretary , · "이번 주 빈 슬롯"
* .
*/
calendarContext?: string;
/**
* Task tracker (`_shared/tasks.md`) active . agent +
* task ·. .
*/
tasksContext?: string;
/** /**
* Peer outputs from earlier agents in *this* dispatch, in execution order. * Peer outputs from earlier agents in *this* dispatch, in execution order.
* Truncated by the dispatcher before passing this builder doesn't trim * Truncated by the dispatcher before passing this builder doesn't trim
@@ -121,6 +133,28 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push(' • `<delete_file path="..."/>` — 파일·디렉토리 삭제'); parts.push(' • `<delete_file path="..."/>` — 파일·디렉토리 삭제');
parts.push(' • `<list_files path="..."/>` — 디렉토리 목록 보기'); parts.push(' • `<list_files path="..."/>` — 디렉토리 목록 보기');
parts.push(' • `<run_command>명령</run_command>` — 셸 실행 (디렉토리 생성 등)'); parts.push(' • `<run_command>명령</run_command>` — 셸 실행 (디렉토리 생성 등)');
parts.push(' • `<create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>` — Google Calendar 일정 자동 생성 (OAuth 연결 필요)');
parts.push(' • `<read_sheet spreadsheet_id="..." range="Sheet1!A1:D20"/>` — Google Sheets 셀 범위 읽기');
parts.push(' • `<write_sheet spreadsheet_id="..." range="Sheet1!A1">TSV 본문</write_sheet>` — 셀 덮어쓰기 (탭 구분, 줄바꿈 = 행)');
parts.push(' • `<append_sheet spreadsheet_id="..." range="Sheet1!A:C">TSV 본문</append_sheet>` — 마지막 데이터 행 아래에 append');
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
parts.push('');
parts.push('📋 **Task 사용 시점**:');
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
parts.push('- 사용자가 진척 보고하면 즉시 update_task / complete_task. "추적해뒀어요" 라고 말만 하지 말 것.');
parts.push('- due 가 분 단위로 명확하면 add_task + create_calendar_event 함께 emit — 추적기에도, 캘린더에도.');
parts.push('');
parts.push('📅 **Calendar 사용 시점**:');
parts.push('- 사용자가 회의록 / 안건 / "X 일까지 끝내야 해" 같이 *명확한 시각이 있는* 약속·작업을 공유했을 때.');
parts.push('- 시간이 모호하면 ("다음주에", "조만간") 태그 emit 하지 말고 *물어봐서 확정* 한 뒤 emit.');
parts.push('- 회의록에 여러 일정/할일이 섞여 있으면 *각각 1개 태그씩* emit. 한 태그에 여러 일정 욱여넣지 말 것.');
parts.push('');
parts.push('📊 **Sheets 사용 시점**:');
parts.push('- spreadsheet_id 는 사용자가 *직접 알려준 것만* 사용. 추측 / 생성 금지.');
parts.push('- 쓰기 전엔 한 줄로 "어디에 무엇을 쓰겠다" 미리 보고. 사용자가 stop 안 걸면 즉시 태그 emit.');
parts.push('- 본문은 TSV (탭 구분, 줄바꿈 = 행). 한 줄에 칼럼이 여러개면 탭으로 구분.');
parts.push(''); parts.push('');
parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:'); parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:');
parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`'); parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`');
@@ -188,6 +222,26 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push(decisions); parts.push(decisions);
} }
// ── Google Calendar (iCal 읽기 전용) ──
// 모든 agent 가 받는 외부 컨텍스트 — "이번 주 화/목 14:00-16:00 비어있다",
// "내일 13:00 미팅" 같은 정보. 캐시 파일은 g1nation.calendar.refresh 명령 또는
// 셋업 시점에 갱신됨 — turn 마다 fetch 하면 외부 의존성 + 지연 발생하므로 보수적.
const calendarCtx = (inputs.calendarContext ?? '').trim();
if (calendarCtx) {
parts.push('');
parts.push('## 사용자 일정 컨텍스트 (Google Calendar)');
parts.push(calendarCtx);
}
// ── Task tracker — active 작업 한 눈에 ──
const tasksCtx = (inputs.tasksContext ?? '').trim();
if (tasksCtx) {
parts.push('');
parts.push('## 진행 중인 작업 (tasks.md · active)');
parts.push('아래는 회사 작업 추적기에 active 로 들어있는 task 들입니다. 진척 / 막힌 항목 / 다가오는 마감을 인지하고 답변에 반영하세요. 사용자가 진척을 알려주면 `<update_task>` 또는 `<complete_task>` 로 즉시 갱신.');
parts.push(tasksCtx);
}
// ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ── // ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ──
// 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer // 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer
// 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은 // 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은
+13
View File
@@ -0,0 +1,13 @@
export {
readSheetRange,
writeSheetRange,
appendSheetRows,
parseTsvBody,
valuesToMarkdownTable,
SheetCell,
SheetValues,
ReadResult,
WriteResult,
AppendResult,
ApiFailure,
} from './sheetsApi';
+166
View File
@@ -0,0 +1,166 @@
/**
* Google Sheets API v4 read / write / append.
*
* calendar ( OAuth spreadsheets scope ).
* "Astra: Google Calendar OAuth 연결" .
*
* Sheets API REST + native fetch.
*/
import * as vscode from 'vscode';
import { getFreshAccessToken } from '../calendar/calendarApi';
const API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets';
/** 2D 값 배열 — 각 셀은 string | number | boolean (Sheets API valueType). */
export type SheetCell = string | number | boolean | null;
export type SheetValues = SheetCell[][];
export interface ReadResult { ok: true; values: SheetValues; range: string; }
export interface WriteResult { ok: true; updatedCells: number; updatedRange: string; }
export interface AppendResult extends WriteResult { appendedRange: string; }
export interface ApiFailure { ok: false; error: string; }
/**
* range 2D . Sheets API
* . caller normalize.
*/
export async function readSheetRange(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
): Promise<ReadResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${tok.accessToken}` },
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) {
return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
}
return { ok: true, values: (json.values ?? []) as SheetValues, range: json.range ?? range };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/**
* range values . range values .
* Sheets API valueInputOption='USER_ENTERED' "=A1+B1" ,
* / Sheets .
*/
export async function writeSheetRange(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
values: SheetValues,
): Promise<WriteResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
`?valueInputOption=USER_ENTERED`;
try {
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${tok.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
return { ok: true, updatedCells: Number(json.updatedCells ?? 0), updatedRange: json.updatedRange ?? range };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/**
* range * * append.
* ·· .
*/
export async function appendSheetRows(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
values: SheetValues,
): Promise<AppendResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
`:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tok.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
const upd = json.updates ?? {};
return {
ok: true,
updatedCells: Number(upd.updatedCells ?? 0),
updatedRange: upd.updatedRange ?? range,
appendedRange: upd.updatedRange ?? range,
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
/**
* action tag ( , / ) 2D SheetValues .
* - (\t) split TSV. LLM emit.
* - ` | ` fallback ( 1).
* - (trailing newline ).
* Sheets API USER_ENTERED .
*/
export function parseTsvBody(body: string): SheetValues {
if (!body || typeof body !== 'string') return [];
// 공백·탭·개행만 있는 입력은 빈 배열로 — LLM 이 빈 본문 emit 했을 때 안전.
if (!body.trim()) return [];
const trimmed = body.replace(/^\s*\n+/, '').replace(/\n+\s*$/, '');
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length === 0) return [];
const useTab = lines.some((l) => l.includes('\t'));
return lines.map((line) =>
useTab ? line.split('\t') : line.split(/\s*\|\s*/),
);
}
/** 결과 2D 배열을 LLM 친화적 짧은 마크다운 테이블로 (read 결과를 chat 에 주입할 때). */
export function valuesToMarkdownTable(values: SheetValues, maxRows: number = 50): string {
if (!values.length) return '_(empty)_';
const truncated = values.slice(0, maxRows);
const rendered = truncated.map((row) => '| ' + row.map((c) => String(c ?? '').replace(/\|/g, '\\|')).join(' | ') + ' |');
if (rendered.length === 0) return '_(empty)_';
// 헤더 구분선 — 첫 행을 헤더로 가정.
const cols = truncated[0]?.length ?? 1;
const sep = '|' + Array(cols).fill('---').join('|') + '|';
const result = [rendered[0], sep, ...rendered.slice(1)].join('\n');
if (values.length > maxRows) {
return result + `\n_(... ${values.length - maxRows} more rows truncated)_`;
}
return result;
}
+13
View File
@@ -0,0 +1,13 @@
export {
Task,
TaskStatus,
TaskStore,
readTaskStore,
writeTaskStore,
parseTaskStore,
renderTaskStore,
addTask,
updateTask,
completeTask,
summarizeActiveTasks,
} from './taskStore';
+245
View File
@@ -0,0 +1,245 @@
/**
* Task tracker `.astra/company/_shared/tasks.md` .
*
* :
* - DB * * . git history .
* - regex ( `|`). fault-tolerant.
* - task id (t_001, t_002, ...) agent update/complete .
* - active / done . done N개만 ( archive).
*
* :
*
* # Tasks
*
* ## Active
* | ID | Title | Owner | Due | Status | Notes |
* |---|---|---|---|---|---|
* | t_001 | | @me | 2026-05-24T18:00 | in_progress | |
* | t_002 | | @planner | 2026-05-21T13:00 | open | |
*
* ## Done (recent 10)
* | ID | Title | Owner | Completed |
* |---|---|---|---|
* | t_000 | | @me | 2026-05-20T10:00 |
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done';
export interface Task {
id: string;
title: string;
/** @me / @planner / @qa 같은 자유 형식. 정확한 agent id 와 무관해도 됨. */
owner: string;
/** ISO 'YYYY-MM-DDTHH:MM' 또는 빈 문자열. due 없는 task 도 허용. */
due: string;
status: TaskStatus;
notes: string;
/** done 으로 전환된 시각 ISO. status==='done' 일 때만 의미. */
completedAt?: string;
}
const DONE_KEEP_RECENT = 10;
function _tasksPath(context: vscode.ExtensionContext): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'tasks.md');
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'tasks.md');
}
/** 전체 task store. active 와 done 분리. */
export interface TaskStore {
active: Task[];
done: Task[];
}
export function readTaskStore(context: vscode.ExtensionContext): TaskStore {
const p = _tasksPath(context);
try {
if (!fs.existsSync(p)) return { active: [], done: [] };
const md = fs.readFileSync(p, 'utf8');
return parseTaskStore(md);
} catch {
return { active: [], done: [] };
}
}
export function writeTaskStore(context: vscode.ExtensionContext, store: TaskStore): void {
const p = _tasksPath(context);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, renderTaskStore(store), 'utf8');
} catch {
// silent fail — caller 가 다음 read 에서 빈 store 받게 됨.
}
}
// ────────────── parse ──────────────
export function parseTaskStore(md: string): TaskStore {
const active: Task[] = [];
const done: Task[] = [];
let section: 'active' | 'done' | null = null;
const lines = md.split(/\r?\n/);
for (const line of lines) {
if (/^##\s*Active/i.test(line)) { section = 'active'; continue; }
if (/^##\s*Done/i.test(line)) { section = 'done'; continue; }
if (!section) continue;
if (!line.trim().startsWith('|')) continue;
// header / separator 행 skip
if (/^\|\s*ID\b/i.test(line)) continue;
if (/^\|\s*[-:]+/.test(line)) continue;
const cells = _parseRow(line);
if (!cells || cells.length < 3) continue;
if (section === 'active') {
const [id, title, owner, due, status, notes] = cells;
if (!id || !id.startsWith('t_')) continue;
active.push({
id,
title: title ?? '',
owner: owner ?? '',
due: due ?? '',
status: _normalizeStatus(status),
notes: notes ?? '',
});
} else {
const [id, title, owner, completedAt] = cells;
if (!id || !id.startsWith('t_')) continue;
done.push({
id,
title: title ?? '',
owner: owner ?? '',
due: '',
status: 'done',
notes: '',
completedAt: completedAt ?? '',
});
}
}
return { active, done };
}
function _parseRow(line: string): string[] | null {
const t = line.trim();
if (!t.startsWith('|') || !t.endsWith('|')) return null;
const inner = t.slice(1, -1);
return inner.split('|').map((c) => c.trim().replace(/\\\|/g, '|'));
}
function _normalizeStatus(s: string | undefined): TaskStatus {
const v = String(s ?? 'open').trim().toLowerCase().replace(/\s+/g, '_');
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') return 'in_progress';
if (v === 'blocked' || v === 'block') return 'blocked';
if (v === 'done' || v === 'completed' || v === 'closed') return 'done';
return 'open';
}
// ────────────── render ──────────────
export function renderTaskStore(store: TaskStore): string {
const lines: string[] = [
'# Tasks',
'',
'## Active',
'| ID | Title | Owner | Due | Status | Notes |',
'|---|---|---|---|---|---|',
];
for (const t of store.active) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.due)} | ${t.status} | ${_esc(t.notes)} |`);
}
if (store.active.length === 0) lines.push('_(no active tasks)_');
lines.push('');
lines.push(`## Done (recent ${DONE_KEEP_RECENT})`);
lines.push('| ID | Title | Owner | Completed |');
lines.push('|---|---|---|---|');
const recentDone = store.done.slice(-DONE_KEEP_RECENT);
for (const t of recentDone) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.completedAt ?? '')} |`);
}
if (recentDone.length === 0) lines.push('_(no completed tasks)_');
return lines.join('\n') + '\n';
}
function _esc(s: string): string {
return String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
// ────────────── CRUD helpers ──────────────
/**
* task . id active+done max + 1.
* title task dedup agent.
*/
export function addTask(store: TaskStore, input: {
title: string;
owner?: string;
due?: string;
notes?: string;
status?: TaskStatus;
}): Task {
const allIds = [...store.active, ...store.done].map((t) => t.id);
const max = allIds.reduce((acc, id) => {
const m = id.match(/^t_(\d+)$/);
if (!m) return acc;
const n = parseInt(m[1], 10);
return n > acc ? n : acc;
}, 0);
const id = `t_${String(max + 1).padStart(3, '0')}`;
const task: Task = {
id,
title: input.title.trim(),
owner: (input.owner ?? '').trim(),
due: (input.due ?? '').trim(),
status: input.status ?? 'open',
notes: (input.notes ?? '').trim(),
};
store.active.push(task);
return task;
}
/** id 로 task 찾아 patch 적용. 못 찾으면 null. */
export function updateTask(store: TaskStore, id: string, patch: Partial<Task>): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const cur = store.active[idx];
const next: Task = { ...cur, ...patch, id: cur.id };
store.active[idx] = next;
return next;
}
/**
* task done . active done push.
* null. done active .
*/
export function completeTask(store: TaskStore, id: string, completedAt?: string): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const [t] = store.active.splice(idx, 1);
const closed: Task = { ...t, status: 'done', completedAt: completedAt ?? new Date().toISOString().slice(0, 16) };
store.done.push(closed);
return closed;
}
/** 프롬프트 주입용 — active 만 짧은 마크다운으로. due 가 가까운 순. */
export function summarizeActiveTasks(store: TaskStore, max: number = 12): string {
if (store.active.length === 0) return '';
const sorted = [...store.active].sort((a, b) => {
// due 있는 task 우선 (asc). 없는 건 뒤로.
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
return (a.due || '').localeCompare(b.due || '');
});
const shown = sorted.slice(0, max);
const lines = shown.map((t) => {
const due = t.due ? ` · 마감 ${t.due}` : '';
const owner = t.owner ? ` · ${t.owner}` : '';
const status = t.status !== 'open' ? ` [${t.status}]` : '';
const notes = t.notes ? `${t.notes}` : '';
return `- ${t.id}${status} ${t.title}${owner}${due}${notes}`;
});
if (sorted.length > max) lines.push(`- _(... ${sorted.length - max} more)_`);
return lines.join('\n');
}
+46
View File
@@ -602,6 +602,52 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
if (result.ok) await provider._sendCompanyPipelines(); if (result.ok) await provider._sendCompanyPipelines();
return true; return true;
} }
case 'setCompanyScopePreset': {
// 스코프 프리셋 클릭 — templateId (plan-only / dev-only / full-product-dev) 받아서:
// 1) suggestedPipelineId 의 pipeline 이 state.pipelines 에 없으면 template stamp
// 2) activePipelineId 를 그 id 로 설정
// 이미 stamp 된 pipeline 이라면 stage 사용자 편집을 *유지* (활성화만).
const { getPipelineTemplate, upsertPipeline, setActivePipeline, readCompanyState } =
await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
if (!tpl) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: `알 수 없는 템플릿: ${tplId}` },
});
return true;
}
const state = readCompanyState(provider._context);
if (!state.pipelines || !state.pipelines[tpl.suggestedPipelineId]) {
const stampDef = {
id: tpl.suggestedPipelineId,
name: tpl.suggestedPipelineName,
// stage 는 deep clone — 템플릿 read-only 원본 보호.
stages: tpl.stages.map((s) => ({ ...s })),
};
const stamp = await upsertPipeline(provider._context, stampDef);
if (!stamp.ok) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: stamp.reason },
});
return true;
}
}
const activate = await setActivePipeline(provider._context, tpl.suggestedPipelineId);
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: activate.ok
? { ok: true, pipelineId: tpl.suggestedPipelineId, templateId: tplId }
: { ok: false, reason: activate.reason },
});
if (activate.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyPipelines();
}
return true;
}
case 'proactiveTrigger': case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context); await provider._handleProactiveSuggestion(data.context);
return true; return true;
+93 -1208
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -253,6 +253,87 @@ If neither condition is met, give a definitive answer and stop.
[ACTION 8: WEB SEARCH] [ACTION 8: WEB SEARCH]
<read_url>https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY</read_url> <read_url>https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY</read_url>
[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).
<create_calendar_event title="회의 제목" start="2026-05-21T14:00" duration="60" location="회의실 A">
() /
</create_calendar_event>
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 ) .
<read_sheet spreadsheet_id="1abc...xyz" range="Sheet1!A1:D20"/>
- spreadsheet_id: Google Sheets URL /d/<여기>/edit
- range: A1 notation. . : 'Sheet1!A1:E50', '데이터!B:B'
[ACTION 11: WRITE SHEET]
Range **. TSV ( , = ).
' | ' fallback.
<write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
\t나이\t직책
\t29\t디자이너
</write_sheet>
[ACTION 12: APPEND SHEET]
Range * * append. · .
<append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
2026-05-21\t새 \t완료
</append_sheet>
Sheets :
- spreadsheet_id . · .
- "내 시트" *URL * .
- "이 시트에 이런 데이터를 쓰겠다" ( ).
[ACTION 13: ADD TASK]
·· * * .
agent turn + .
<add_task title="광고주 자료 정리" owner="@me" due="2026-05-24T18:00" notes="자료 수령 후 시작"/>
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 ( ).
<update_task id="t_001" status="in_progress" notes="자료 수령 완료, 정리 진행 중"/>
[ACTION 15: COMPLETE TASK]
task . active done , completedAt .
<complete_task id="t_001"/>
Task :
- ** add · .
- * add_task* ( ).
- update / complete. "추적해뒀어요" .
- due task add_task + create_calendar_event emit ( ).
[OPERATIONAL RULES] [OPERATIONAL RULES]
1. Reply in the same language as the user. 1. Reply in the same language as the user.
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity. 2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
+131
View File
@@ -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({});
});
});
+134
View File
@@ -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']);
});
});
+241
View File
@@ -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');
});
});
+69
View File
@@ -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');
});
});
+113
View File
@@ -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({});
});
});
+185
View File
@@ -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({});
});
});