import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { 
    _getBrainDir, 
    findBrainFiles, 
    buildApiUrl,
    getActiveBrainProfile,
    getBrainProfiles,
    invalidateBrainFilesCache,
    logError,
    logInfo,
    resolveEngine,
    summarizeText,
    openInEditorGroup
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
import { isThinFollowUp } from './lib/contextBuilders/thinFollowUp';
import type { ModelLifecycleManager } from './lmstudio/lifecycleManager';
import type { IActivityTracker } from './lmstudio/activityTracker';
import { handleChatMessage } from './sidebar/chatHandlers';
import { handleBrainMessage } from './sidebar/brainHandlers';
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
import { handleAgentMessage } from './sidebar/agentHandlers';
import { registerSidebarHandler, dispatchSidebarMessage } from './sidebar/registry';
import { ApprovalGateManager } from './sidebar/managers/approvalGates';
import { ArchitectureWatchManager } from './sidebar/managers/architectureWatch';
import { CompanyTurnRuntime, AlignmentSession } from './sidebar/managers/companyTurn';
import { PixelOfficeState } from './sidebar/managers/pixelOfficeState';
import {
    mergeAgentWorkState,
    summariseContract,
    buildOfficeRoster,
    buildPixelOfficeUpdatePayload,
    buildPixelOfficeHtml,
} from './sidebar/managers/pixelOfficeHelpers';
import { ChatSessionStore, ChatSession } from './sidebar/managers/chatSessionStore';
import { ModelDiscovery } from './sidebar/managers/modelDiscovery';
import { PixelOfficeLayoutStore } from './sidebar/managers/pixelOfficeLayoutStore';
import {
    buildBrainStatusPayload,
    buildBrainProfilesPayload,
    buildReadyStatusPayload,
} from './sidebar/builders/statusPayloads';
import {
    buildCompanyPipelinesPayload,
    buildCompanyStatusPayload,
    buildCompanyAgentsPayload,
    buildCompanyResumablePayload,
} from './sidebar/builders/companyPayloads';
import { slugify, summarizeForTitle, summarizeTextForWiki } from './sidebar/builders/textHelpers';
import { ChronicleProjectStore } from './sidebar/managers/chronicleProjectStore';
import { buildChronicleProjectsPayload, buildChronicleRecordsPayload } from './sidebar/builders/chroniclePayloads';
import { buildWikiRawMarkdown, formatTimestampForFile } from './sidebar/builders/wikiRaw';
import { AgentSkillStore } from './sidebar/managers/agentSkillStore';
import { proactiveSuggestionFor } from './sidebar/builders/proactiveSuggestions';
import { generateUniqueBrainId } from './sidebar/builders/brainProfileHelpers';
import {
    extractLocalProjectPath,
    inferAutoChronicleRecordType,
} from './sidebar/builders/autoChronicleClassifier';
import {
    buildAutoBugRecord,
    buildAutoPlanningRecord,
    buildAutoDecisionRecord,
    buildAutoDevelopmentRecord,
    buildAutoDiscussionRecord,
} from './sidebar/builders/autoChroniclePayloads';
import {
    buildArchitectureStatusPayload,
    buildArchitectureHiddenPayload,
    buildArchitectureRefreshFailedPayload,
    buildArchitectureRefreshResultPayload,
    buildProjectArchitectureContext,
} from './sidebar/builders/architecturePayloads';
import {
    workspaceSynthesizedProfile,
    architectureActivationStub,
    normalizePathForCompare,
} from './sidebar/builders/projectProfileFactories';
import { processAttachments } from './sidebar/builders/promptAttachments';
import { resolveAgentSkill } from './sidebar/builders/agentSkillResolver';
import { SessionStateStore, LastVisibleChatSnapshot } from './sidebar/managers/sessionStateStore';
import {
    buildSessionTitleFromHistory,
    buildSessionTitleFromText,
    buildSessionListPayload,
    buildSessionLoadedPayload,
} from './sidebar/builders/sessionPayloads';
import {
    buildAlignmentAutoProceedPayload,
    buildAlignmentAskPayload,
    buildAlignmentCancelledPayload,
} from './sidebar/builders/alignmentCardPayloads';
import { buildDispatcherDeps } from './sidebar/builders/dispatcherDepsBuilder';
import { createCompanyTurnEmit } from './sidebar/builders/companyTurnEmitter';
import { shouldAutoProceedAlignment, extractActiveRoleCategories } from './sidebar/builders/alignmentRouting';

// 기본 도메인 핸들러 4개를 모듈 load 시점에 한 번 registry 에 등록.
// 외부 플러그인 / 새 도메인은 자기 모듈 init 시점에 registerSidebarHandler() 호출하면
// 별도 sidebarProvider 수정 없이 dispatch loop 에 합류.
registerSidebarHandler(handleChatMessage);
registerSidebarHandler(handleBrainMessage);
registerSidebarHandler(handleChronicleMessage);
registerSidebarHandler(handleAgentMessage);
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { clearBrainTokenIndex } from './retrieval/brainIndex';
import { extractUrls } from './features/web/webFetch';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
import { estimateModelParamsB } from './lib/contextManager';
import {
    buildOrRefreshArchitectureDoc,
    architectureDocPathFor,
    resolveActiveSubprojectRoot,
    scanProject,
} from './features/projectArchitecture';
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
import {
    readCompanyState,
    runCompanyTurn,
    resumeCompanyTurn,
    listResumableSessions,
    analyzeIntent,
    resolveActivePipeline,
    gatherEvidenceForQuestions,
    selfAnswerQuestions,
    saveAlignmentKnowledge,
    SELF_RESEARCH_PREFIX,
} from './features/company';
import { AIService } from './core/services';
import { HealthCheckMonitor } from './core/health';
import { presentOfficeSnapshot } from './features/astraOffice';

export interface SidebarLmStudioDeps {
    lifecycle: ModelLifecycleManager;
    activity: IActivityTracker;
    /** Returns the list of model identifiers currently loaded in LM Studio (cached). */
    loadedModels: () => Promise<string[]>;
    /**
     * Returns every model downloaded into LM Studio (modelKey form). Used by the
     * dropdown so it does not depend on LM Studio's JIT setting — REST
     * `/v1/models` only lists loaded models when JIT is off, which on macOS
     * commonly leaves the dropdown with just the fallback `defaultModel`.
     */
    downloadedModels: () => Promise<string[]>;
}

// LastVisibleChatSnapshot 은 `src/sidebar/managers/sessionStateStore.ts` 로 이관.

// ChatSession 정의는 `src/sidebar/managers/chatSessionStore.ts` 로 이관.

/**
 * Sidebar UI Provider implementing BridgeInterface for BridgeServer
 */
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
    public static readonly viewType = 'g1nation-v2-view';
    // activeSessionId / blankChatActive / lastVisibleChat 세 키는 `SessionStateStore` 가 소유.
    static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
    // chronicleProjectsStateKey 는 ChronicleProjectStore (managers/) 가 소유.
    static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
    static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
    _view?: vscode.WebviewView;
    _panel?: vscode.WebviewPanel;
    /**
     * Pixel Office 의 모든 raw state slot 을 한 컨테이너로 통합.
     *   - panel: 전체보기 webview panel reference (닫히면 undefined)
     *   - current: 누적된 AgentWorkState (broadcast 직전 patch merge 결과)
     *   - lastBubble Map: 같은 텍스트 연속 출력 회피
     *   - activity ring buffer: officeSnapshot 의 action-tag 히스토리
     * Broadcast 메서드는 provider 에 남아 webview send 와 직접 통신.
     */
    private readonly _pixelOffice = new PixelOfficeState();
    public brainEnabled = true;
    _currentSessionBrainId: string | null = null;
    _currentNegativePrompt: string = '';
    readonly _chronicle = new ProjectChronicleManager();
    /** 모델 dropdown 의 cache + inflight + SDK/REST discovery 를 한 manager 로 통합. TTL 30s. */
    private readonly _modelDiscovery = new ModelDiscovery(30000);

    /**
     * AbortController for the currently-running 1인 기업 turn. Cleared when
     * the turn ends (success or fail). The webview's Stop button routes
     * through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
     * — the dispatcher's `signal` then short-circuits between phases.
     */
    /** 옛 _companyAbort + _lastCompanyTurnSummary 두 slot 을 CompanyTurnRuntime 으로 통합. */
    private readonly _companyTurn = new CompanyTurnRuntime();

    /**
     * Open approval gates. The dispatcher emits `phase: 'awaiting-approval'`
     * for stages with `requiresApproval`, and waits on a Promise this map
     * stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks
     * route through `chatHandlers.respondCompanyApproval` which calls
     * `resolveApprovalGate(stageId, decision)` here.
     *
     * Keyed by stageId — only one approval may be pending per stage at a
     * time (sequential dispatch), but multiple stages across the same turn
     * each get their own entry as they hit their gate. On turn abort we
     * resolve all outstanding entries with `{ kind: 'abort' }` so the
     * dispatcher unblocks cleanly.
     */
    // 옛 _pendingApprovals Map 을 ApprovalGateManager 로 격리. 사용처는 그대로 동작
    // (등록 / resolve / abortAll 패턴 동일), 단 책임이 한 클래스로 모임.
    private _approvalGates = new ApprovalGateManager();

    /**
     * Snapshot of the last completed company turn — fed into the intent
     * classifier so it can distinguish "follow-up on previous round" from
     * "brand-new task". Reset to undefined when the chat is cleared / new
     * session loaded. Only completed (non-aborted) reports populate this.
     */
    // _lastCompanyTurnSummary 는 _companyTurn (CompanyTurnRuntime) 으로 통합.

    /**
     * Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고,
     * confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에
     * 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다.
     *
     * 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시
     * 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면
     * 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져
     * 나갈 수 있게 하는 게 흐름상 안전.
     */
    /** 옛 _pendingAlignment slot 을 AlignmentSession 으로 통합. */
    private readonly _alignment = new AlignmentSession();

    /**
     * Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에
     * 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단
     * 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름.
     */
    // _pixelOfficeState / _pixelOfficeLastBubble / _pixelOfficeActivity / PIXEL_OFFICE_ACTIVITY_MAX
    // 는 _pixelOffice (PixelOfficeState) 컨테이너로 통합.

    /**
     * Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별
     * 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용.
     * 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 →
     * webview에 다시 broadcast.
     *
     * 데이터 shape:
     * {
     *   cells:  [{ roleKey, deskX, deskY, charX, charY }, ...],
     *   decos:  [{ id, type, x, y }, ...]
     * }
     */
    // Pixel Office layout 영구 저장 — `src/sidebar/managers/pixelOfficeLayoutStore.ts` 의
    // PixelOfficeLayoutStore 로 이관. constructor 에서 초기화.

    /** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
    isAlignmentPending(): boolean {
        return this._alignment.isPending();
    }

    // ─────────────────────── Pixel Office collector ───────────────────────
    //
    // 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로
    // 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을
    // 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도
    // 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는
    // 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다.

    /** webview로 보낼 직전 patch와 가벼운 reset/유틸. */
    private _pixelOfficeBroadcast(patch: Partial<import('./features/company').AgentWorkState>, opts?: {
        bubbleStatus?: import('./features/company').AgentStatus;
        bubbleEvent?: import('./features/company').AgentEvent;
        bubbleAgentId?: string;
    }): void {
        const cfg = getConfig();
        if (!cfg.companyPixelOfficeEnabled) return;
        const next = mergeAgentWorkState(this._pixelOffice.current, patch);
        this._pixelOffice.current = next;

        // 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트
        // 회피해서 같은 말 연달아 안 나오게.
        const bubbles: import('./features/company').AgentBubble[] = [];
        if (cfg.companyPixelOfficeBubbles) {
            const aid = opts?.bubbleAgentId ?? next.agentId;
            if (opts?.bubbleStatus) {
                const lastKey = `status:${opts.bubbleStatus}`;
                // 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게.
                const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company');
                const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOffice.getLastBubble(lastKey));
                if (text) {
                    this._pixelOffice.setLastBubble(lastKey, text);
                    bubbles.push(makeBubble({ agentId: aid, text, type: 'status' }));
                }
            }
            if (opts?.bubbleEvent) {
                const lastKey = `event:${opts.bubbleEvent}`;
                const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company');
                const text = getEventBubbleText(opts.bubbleEvent, this._pixelOffice.getLastBubble(lastKey));
                if (text) {
                    this._pixelOffice.setLastBubble(lastKey, text);
                    bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) }));
                }
            }
        }

        const payload = {
            type: 'pixelOfficeUpdate' as const,
            value: {
                state: next,
                bubbles,
                config: {
                    enabled: cfg.companyPixelOfficeEnabled,
                    bubblesEnabled: cfg.companyPixelOfficeBubbles,
                    maxVisibleBubbles: 3,
                    bubbleDurationMs: 4500,
                },
            },
        };
        // 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음.
        this._view?.webview.postMessage(payload);
        this._pixelOffice.panel?.webview.postMessage(payload);

        // ── refactor #4 단계적 마이그레이션 ──
        // 옛 `pixelOfficeUpdate` 와 함께 새 `officeSnapshot` 도 emit. 어느 webview 가
        // 새 message 를 listen 하기 시작하면 그때부터 자연스럽게 OfficeSnapshot 기반으로
        // 동작. 양쪽 다 listen 안 해도 옛 동작 유지. 다음 세션에 webview 측 리시버 추가.
        try {
            // Roster derive (#G): 현재 turn 의 active agents 전체를 OfficeSnapshot 에 포함.
            // listActiveAgentsByCategory 는 CEO 를 항상 포함하고 사용자 activeAgentIds 만
            // 통과시킨다. presenter 가 active agent 만 실제 상태로 표시, 나머지는 idle.
            const roster = buildOfficeRoster(readCompanyState(this._context));
            const snapshot = presentOfficeSnapshot({
                activeState: next,
                recentBubbles: bubbles,
                recentActivity: this._pixelOffice.getActivity(),
                roster,
            });
            const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
            this._view?.webview.postMessage(snapMsg);
            this._pixelOffice.panel?.webview.postMessage(snapMsg);
        } catch (e) {
            // presenter 가 throw 해도 옛 동작에는 영향 없도록 swallow.
            // 디버깅 필요 시 console.warn 으로 띄울 수 있음.
        }
    }

    /**
     * 현재 CompanyState 에서 active agents 를 평탄화 → presenter 의 roster 입력 모양으로.
     * read 실패 / company off → 빈 배열 (presenter 가 옛 single-agent fallback 사용).
     */
    // _buildOfficeRoster / _pixelOfficeAppendLog → `src/sidebar/managers/pixelOfficeHelpers.ts`
    //   + PixelOfficeState.appendLog. Activity push 도 PixelOfficeState.pushActivity 직접 호출.

    /** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */
    pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void {
        if (intent !== 'new_task') {
            // 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀.
            this._pixelOfficeBroadcast({
                status: 'idle',
                currentTask: userPrompt,
                currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답',
                message: undefined,
                recentLogs: this._pixelOffice.appendLog(`💬 분류: ${intent}`),
            }, { bubbleStatus: 'idle' });
            return;
        }
        // new_task — intake → analyzing 흐름의 첫 신호.
        this._pixelOfficeBroadcast({
            status: 'intake',
            currentTask: userPrompt,
            currentStep: '요청 접수',
            recentLogs: this._pixelOffice.appendLog(`📨 새 작업 요청 접수`),
        }, { bubbleStatus: 'intake' });
    }

    /** Intent Alignment 분석 시작 (LLM 콜 직전). */
    pixelOfficeOnAlignmentStart(userPrompt: string): void {
        this._pixelOfficeBroadcast({
            status: 'analyzing',
            currentTask: this._pixelOffice.current?.currentTask || userPrompt,
            currentStep: '요청 분석 중 (C·G·C·F 추출)',
            message: undefined,
            recentLogs: this._pixelOffice.appendLog('🔍 의도 분석 시작'),
        }, { bubbleStatus: 'analyzing' });
    }

    /** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */
    pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void {
        if (kind === 'auto-proceed') {
            this._pixelOfficeBroadcast({
                status: 'contract_ready',
                currentStep: 'Requirement Contract 확정 (자동 진행)',
                requirementContract: summariseContract(contract),
                needUserInput: undefined,
                recentLogs: this._pixelOffice.appendLog('✅ 계약 자동 확정'),
            }, {
                bubbleStatus: 'contract_ready',
                bubbleEvent: 'requirement_contract_created',
            });
            return;
        }
        if (kind === 'questions') {
            this._pixelOfficeBroadcast({
                status: 'need_clarification',
                currentStep: '확인 질문 대기 중',
                requirementContract: summariseContract(contract),
                needUserInput: contract.openQuestions,
                recentLogs: this._pixelOffice.appendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`),
            }, {
                bubbleStatus: 'need_clarification',
                bubbleEvent: 'clarification_needed',
            });
            return;
        }
        // confirm — 질문 없음, 사용자 OK만 받으면 됨.
        this._pixelOfficeBroadcast({
            status: 'waiting_approval',
            currentStep: '계약 확인 대기',
            requirementContract: summariseContract(contract),
            needUserInput: undefined,
            awaitingApproval: '사용자 확인 — 그대로 진행할지 여부',
            recentLogs: this._pixelOffice.appendLog('🧭 계약 확인 카드 표시'),
        }, { bubbleStatus: 'waiting_approval' });
    }

    /** Alignment 취소(사용자가 카드 닫음). */
    pixelOfficeOnAlignmentCancelled(): void {
        this._pixelOfficeBroadcast({
            status: 'idle',
            currentStep: '취소됨',
            needUserInput: undefined,
            awaitingApproval: undefined,
            recentLogs: this._pixelOffice.appendLog('🛑 작업 취소'),
        });
    }

    // _summariseContract → `src/sidebar/managers/pixelOfficeHelpers.ts`

    /**
     * Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환.
     * `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다.
     * 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only.
     */
    pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void {
        switch (ev.phase) {
            case 'plan-start':
                this._pixelOfficeBroadcast({
                    status: 'planning',
                    currentStep: '계획 수립',
                    recentLogs: this._pixelOffice.appendLog('📋 plan 작성'),
                }, { bubbleStatus: 'planning' });
                return;
            case 'plan-ready': {
                // B. 미니 맵용 stage 리스트 초기화. 모두 pending.
                const stages = (ev.plan?.tasks ?? []).map((t) => ({
                    label: (t.task || '').slice(0, 24) || '단계',
                    agent: t.agent,
                    status: 'pending' as const,
                }));
                this._pixelOfficeBroadcast({
                    status: 'planning',
                    currentStep: '계획 완료',
                    nextStep: ev.plan?.tasks?.[0]?.task,
                    message: ev.plan?.brief?.slice(0, 120),
                    recentLogs: this._pixelOffice.appendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`),
                    pipelineStages: stages,
                }, { bubbleEvent: 'plan_completed' });
                return;
            }
            case 'agent-start': {
                // B. 현재 stage를 active로, 이전 stage들은 done으로.
                const prev = this._pixelOffice.current?.pipelineStages;
                const stages = prev ? prev.map((s, i) => ({
                    ...s,
                    status: (i < ev.index ? 'done' : i === ev.index ? 'active' : 'pending') as 'done'|'active'|'pending',
                })) : undefined;
                this._pixelOfficeBroadcast({
                    status: 'executing',
                    currentStep: ev.task?.slice(0, 80),
                    nextStep: undefined,
                    message: `${ev.agentId} • ${ev.index + 1}/${ev.total}`,
                    progress: ev.total > 0 ? ev.index / ev.total : undefined,
                    recentLogs: this._pixelOffice.appendLog(`▶ ${ev.agentId} start (${ev.index + 1}/${ev.total})`),
                    pipelineStages: stages,
                }, {
                    bubbleAgentId: ev.agentId,
                    bubbleStatus: 'executing',
                    bubbleEvent: ev.index === 0 ? 'execution_started' : undefined,
                });
                return;
            }
            case 'agent-done':
                // E. action-tag report가 있으면 ticker로 별도 push — 캐릭터가 *무슨
                // 일을 했는지* 실시간 가시화. 사용자가 신뢰도/투명성을 즉시 체감.
                if (Array.isArray(ev.output?.actionReport) && ev.output.actionReport.length > 0) {
                    const items = ev.output.actionReport.map((line: string) => ({
                        agentId: ev.agentId, text: line, ts: Date.now(),
                    }));
                    this._view?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
                    this._pixelOffice.panel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
                    // 새 path 도 동기화 — ring buffer 에 누적 → 다음 officeSnapshot 에 포함.
                    this._pixelOffice.pushActivity(items.map((it) => ({ agentId: it.agentId, text: it.text })));
                }
                this._pixelOfficeBroadcast({
                    status: ev.output?.error ? 'error' : 'executing',
                    progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined,
                    recentLogs: this._pixelOffice.appendLog(
                        ev.output?.error
                            ? `❌ ${ev.agentId} ${ev.output.error}`
                            : `✓ ${ev.agentId} 완료`,
                    ),
                }, {
                    bubbleAgentId: ev.agentId,
                    bubbleEvent: ev.output?.error ? 'error_occurred' : undefined,
                });
                return;
            case 'stage-loop':
                this._pixelOfficeBroadcast({
                    status: 'executing',
                    currentStep: `재시도: ${ev.from} → ${ev.to} (${ev.iteration}회)`,
                    recentLogs: this._pixelOffice.appendLog(`🔁 loop ${ev.from}→${ev.to} #${ev.iteration}`),
                }, { bubbleEvent: 'stage_loop_retry' });
                return;
            case 'review-start':
                this._pixelOfficeBroadcast({
                    status: 'reviewing',
                    currentStep: `검수 사이클 — ${ev.stageLabel}`,
                    message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`,
                    recentLogs: this._pixelOffice.appendLog(`🔍 검수 시작: ${ev.stageLabel}`),
                }, { bubbleStatus: 'reviewing' });
                return;
            case 'review-round':
                this._pixelOfficeBroadcast({
                    status: 'reviewing',
                    currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`,
                    recentLogs: this._pixelOffice.appendLog(
                        `R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`,
                    ),
                }, {
                    bubbleAgentId: ev.inspectorAgentId,
                    bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass'
                        ? 'review_passed'
                        : (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined),
                });
                return;
            case 'review-end':
                this._pixelOfficeBroadcast({
                    status: ev.final === 'aborted' ? 'error' : 'executing',
                    currentStep: ev.final === 'pass'
                        ? `검수 통과 (${ev.rounds}라운드)`
                        : ev.final === 'maxed-out'
                            ? `검수 한도 도달 — 진행 (${ev.rounds}라운드)`
                            : '검수 중단',
                    recentLogs: this._pixelOffice.appendLog(`🔍 검수 종료: ${ev.final}`),
                }, {
                    bubbleEvent: ev.final === 'pass' ? 'review_passed'
                        : ev.final === 'aborted' ? 'risky_change_detected'
                        : undefined,
                });
                return;
            case 'awaiting-approval':
                this._pixelOfficeBroadcast({
                    status: 'waiting_approval',
                    currentStep: `승인 대기 — ${ev.stageLabel}`,
                    awaitingApproval: `${ev.stageLabel} 단계 완료 검토`,
                    recentLogs: this._pixelOffice.appendLog(`✋ 승인 대기: ${ev.stageLabel}`),
                }, {
                    bubbleStatus: 'waiting_approval',
                    bubbleEvent: 'approval_required',
                });
                return;
            case 'approval-resolved':
                this._pixelOfficeBroadcast({
                    status: 'executing',
                    awaitingApproval: undefined,
                    currentStep: `승인 결과: ${ev.decision}`,
                    recentLogs: this._pixelOffice.appendLog(`✋→ ${ev.decision}`),
                });
                return;
            case 'report-start':
                this._pixelOfficeBroadcast({
                    status: 'reviewing',
                    currentStep: 'CEO 종합 보고서 작성 중',
                    recentLogs: this._pixelOffice.appendLog('🧭 보고서 작성'),
                });
                return;
            case 'report-done':
                this._pixelOfficeBroadcast({
                    status: 'done',
                    currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료',
                    progress: 1,
                    recentLogs: this._pixelOffice.appendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'),
                }, {
                    bubbleStatus: 'done',
                    bubbleEvent: 'task_completed',
                });
                return;
            case 'session-saved':
                this._pixelOfficeBroadcast({
                    status: 'done',
                    message: `세션 저장됨`,
                    recentLogs: this._pixelOffice.appendLog('💾 세션 저장'),
                });
                return;
            case 'aborted':
                this._pixelOfficeBroadcast({
                    status: 'error',
                    currentStep: `중단: ${ev.reason}`,
                    recentLogs: this._pixelOffice.appendLog(`🛑 abort: ${ev.reason}`),
                }, {
                    bubbleStatus: 'error',
                    bubbleEvent: 'error_occurred',
                });
                return;
            case 'telegram-mirror':
            default:
                return; // 시각화에 의미 약함 — log skip
        }
    }

    /** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */
    pixelOfficeResend(): void {
        const cfg = getConfig();
        const payload = buildPixelOfficeUpdatePayload(cfg, this._pixelOffice.current);
        this._view?.webview.postMessage(payload);
        this._pixelOffice.panel?.webview.postMessage(payload);
        if (cfg.companyPixelOfficeEnabled) {
            try {
                const snapshot = presentOfficeSnapshot({
                    activeState: payload.value.state ?? undefined,
                    recentBubbles: [],
                    recentActivity: this._pixelOffice.getActivity(),
                    roster: buildOfficeRoster(readCompanyState(this._context)),
                });
                const snapMsg = { type: 'officeSnapshot' as const, value: snapshot };
                this._view?.webview.postMessage(snapMsg);
                this._pixelOffice.panel?.webview.postMessage(snapMsg);
            } catch {
                // Snapshot 재전송 실패가 기존 fallback 경로를 깨면 안 됨.
            }
        }
    }

    /**
     * editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면
     * 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만
     * 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다.
     */
    public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel {
        if (this._pixelOffice.panel) {
            this._pixelOffice.panel.reveal(column);
            return this._pixelOffice.panel;
        }
        const panel = vscode.window.createWebviewPanel(
            'astra.pixelOffice',
            'Astra Office',
            column,
            { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true },
        );
        this._pixelOffice.panel = panel;
        panel.webview.html = buildPixelOfficeHtml(this._extensionUri, panel.webview);
        // panel과 백엔드 사이의 메시지 채널 — 닫기/리프레시 + 레이아웃 편집.
        panel.webview.onDidReceiveMessage((msg: any) => {
            if (!msg || typeof msg !== 'object') return;
            if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend();
            if (msg.type === 'closePixelOfficePanel') panel.dispose();
            if (msg.type === 'getPixelOfficeLayout') {
                // webview가 layout 요청 — 저장된 게 있으면 그것, 없으면 null.
                panel.webview.postMessage({
                    type: 'pixelOfficeLayoutLoaded',
                    value: this._layoutStore.read() ?? null,
                });
            }
            if (msg.type === 'savePixelOfficeLayout') {
                // webview의 편집 모드에서 사용자가 저장 버튼 누름.
                void this._layoutStore.write(msg.value).then(() => {
                    panel.webview.postMessage({
                        type: 'pixelOfficeLayoutSaved', value: { ok: true },
                    });
                });
            }
            if (msg.type === 'resetPixelOfficeLayout') {
                // 디폴트 LAYOUT으로 복귀.
                void this._layoutStore.clear().then(() => {
                    panel.webview.postMessage({
                        type: 'pixelOfficeLayoutSaved', value: { ok: true, reset: true },
                    });
                });
            }
            if (msg.type === 'pixelOfficeCommand') {
                // D. 캐릭터 컨텍스트 메뉴 액션. 현재 abort만 처리 — Stop 버튼과
                // 동일 경로(abortCompanyTurn + alignment 취소 + agent.stop)로
                // 작업 즉시 중단. skip/pause 등은 dispatcher에 별도 API가 없어 미지원.
                if (msg.cmd === 'abort') {
                    this.abortCompanyTurn();
                    this.cancelPendingAlignment();
                    this._agent.stop();
                }
            }
        });
        panel.onDidDispose(() => {
            if (this._pixelOffice.panel === panel) this._pixelOffice.panel = undefined;
        });
        // 열자마자 현재 상태 한 번 push.
        this.pixelOfficeResend();
        return panel;
    }

    // _buildPixelOfficeHtml → `src/sidebar/managers/pixelOfficeHelpers.ts` (buildPixelOfficeHtml)

    /** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */
    clearPendingAlignment(): void {
        this._alignment.clear();
    }

    /**
     * 프로젝트 아키텍처 watcher lifecycle. 옛 3개 state slot 을 manager 로 통합.
     * Callbacks 는 lazy 평가돼야 — provider 초기화 시점엔 `this._getActiveChronicleProject`
     * 가 아직 bind 안 됐을 수 있으므로 arrow 로 캡처.
     */
    private readonly _archWatch = new ArchitectureWatchManager(
        () => this._getActiveChronicleProject(),
        () => this._refreshArchitecture()
    );

    /** chat_sessions globalState 의 read/write/normalize 를 한 클래스로 격리. constructor 에서 초기화. */
    private readonly _sessionStore: ChatSessionStore;
    /** Pixel Office 사용자 정의 layout 의 workspaceState read/write/clear. constructor 에서 초기화. */
    private readonly _layoutStore: PixelOfficeLayoutStore;
    /** Chronicle 프로젝트 목록 globalState read/write + workspace fallback. constructor 에서 초기화. */
    private readonly _chronicleStore: ChronicleProjectStore;
    /** Agent skill .md 파일 + lastSelected + negativePrompt 영구 저장. constructor 에서 초기화. */
    private readonly _agentSkillStore: AgentSkillStore;
    /**
     * Session side-state (activeSessionId / blankChatActive / lastVisibleChat) 3 키 통합.
     * `sidebar/chatHandlers.ts` 같은 다른 핸들러 모듈도 직접 호출하므로 public.
     */
    readonly _sessionState: SessionStateStore;

    constructor(
        readonly _extensionUri: vscode.Uri,
        readonly _context: vscode.ExtensionContext,
        readonly _agent: AgentExecutor,
        readonly _lmStudio?: SidebarLmStudioDeps
    ) {
        this._sessionStore = new ChatSessionStore(_context);
        this._layoutStore = new PixelOfficeLayoutStore(_context);
        this._chronicleStore = new ChronicleProjectStore(_context);
        this._agentSkillStore = new AgentSkillStore(_context);
        this._sessionState = new SessionStateStore(_context);
        this._agent.setHistoryChangeListener((history) => {
            void this._persistLastVisibleChat(history);
        });
    }

    /** Latest LM Studio load/unload error — surfaced as a persistent segment in the readyBar
     * until the next successful model selection clears it. The transient chat toast alone
     * was getting missed (scrolled away, blended into chat noise). */
    private _lmStudioLastError: string | undefined;

    /** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */
    public postLmStudioError(message: string): void {
        this._lmStudioLastError = message;
        this._view?.webview.postMessage({ type: 'lmStudioError', value: message });
        void this._sendReadyStatus();
    }

    /** Clear the persistent LM Studio error segment. Called when the user picks a new
     * model so a stale failure does not haunt the next attempt. */
    public clearLmStudioError(): void {
        if (this._lmStudioLastError === undefined) return;
        this._lmStudioLastError = undefined;
        void this._sendReadyStatus();
    }

    public resolveWebviewView(
        webviewView: vscode.WebviewView,
        context: vscode.WebviewViewResolveContext,
        _token: vscode.CancellationToken,
    ) {
        this._initView(webviewView);
    }

    /**
     * Open the chat as a standalone editor panel (Column 3 by default).
     * Reuses the same view-init logic via a WebviewPanel→WebviewView adapter
     * so the rest of the provider keeps using `this._view` unchanged.
     */
    public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel {
        if (this._panel) {
            this._panel.reveal(column);
            return this._panel;
        }
        const panel = vscode.window.createWebviewPanel(
            SidebarChatProvider.viewType,
            'Astra Chat',
            column,
            { enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }
        );
        this._panel = panel;
        const adapter = wrapPanelAsView(panel);
        panel.onDidDispose(() => {
            if (this._panel === panel) this._panel = undefined;
            if (this._view === adapter) this._view = undefined;
        });
        this._initView(adapter);
        return panel;
    }

    /**
     * Sidebar 가 처음 열리거나 다시 보여질 때 dropdown / chip / readyBar 가 비지 않게
     * 4 종의 status 를 한꺼번에 푸시. 호출 자체는 fire-and-forget — 각 send 는
     * 내부에서 webview 부재면 noop.
     */
    private _pushBootSnapshot(): void {
        void this._sendModels();
        void this._sendBrainProfiles();
        void this._sendAgentsList();
        void this._sendReadyStatus();
    }

    private _initView(webviewView: vscode.WebviewView) {
        this._view = webviewView;

        webviewView.webview.options = {
            enableScripts: true,
            localResourceRoots: [this._extensionUri]
        };

        // Webview event listeners must be disposed — otherwise each re-init of the
        // view leaks a listener (and its captured `this`). We collect every
        // listener disposable here, dispose them when the view itself is disposed,
        // and also register them with the extension subscriptions as a backstop.
        const viewDisposables: vscode.Disposable[] = [];

        // [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
        let _lastVisibilityRefresh = 0;
        viewDisposables.push(webviewView.onDidChangeVisibility(() => {
            if (!webviewView.visible) return;
            const now = Date.now();
            // 5초 이내에 이미 갱신했으면 건너뜀
            if (now - _lastVisibilityRefresh < 5000) return;
            _lastVisibilityRefresh = now;
            logInfo('Astra view became visible, restoring state...');
            this._pushBootSnapshot();
        }));

        webviewView.webview.html = this._getHtml(webviewView.webview);
        this._agent.setWebview(webviewView.webview);

        void this._restoreActiveSessionIntoView();
        // v2.2.66 — initial-load 단계에서도 brain/models/agents 를 한 번 더 푸시한다.
        // 기존엔 webview 의 'ready' 핸드셰이크에만 의존했는데, 그 체인 도중 하나가 throw 하면
        // 나머지 populate 가 통째로 안 돌아 dropdown 이 비는 회귀가 발생할 수 있다. 이중 보장.
        this._pushBootSnapshot();

        viewDisposables.push(webviewView.webview.onDidReceiveMessage(async (data) => {
            // dispatch root 진입 trace — "/benchmark 입력했는데 아무 응답 없음" 같은
            // 보고가 들어왔을 때 webview message가 정말 도착했는지부터 즉시 판별.
            const valuePreview = typeof data?.value === 'string'
                ? JSON.stringify(data.value.slice(0, 80))
                : '(non-string)';
            logInfo(`[DISPATCH] type=${data?.type} value=${valuePreview}`);
            if (await dispatchSidebarMessage(this, data)) return;
            logInfo(`Unhandled sidebar message: ${data?.type}`);
        }));

        webviewView.onDidDispose(() => {
            for (const d of viewDisposables.splice(0)) {
                try { d.dispose(); } catch { /* already disposed */ }
            }
        });
        this._context.subscriptions.push(...viewDisposables);
    }

    _currentSessionId: string | null = null;

    async _restoreActiveSessionIntoView() {
        if (!this._view) return;

        const blankChatActive = this._sessionState.getBlankChatActive();
        const currentHistory = this._agent.getHistory();

        if (blankChatActive && currentHistory.length === 0) {
            return;
        }

        const activeSessionId = this._currentSessionId || this._sessionState.getActiveSessionId();
        if (activeSessionId) {
            const loaded = await this._loadSession(activeSessionId, true);
            if (loaded) return;
            await this._sessionState.setActiveSessionId(null);
        }

        if (currentHistory.length > 0) {
            this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
            await this._persistLastVisibleChat(currentHistory);
            return;
        }

        const snapshot = this._sessionState.getLastVisibleChat();
        if (snapshot?.history?.length) {
            this._currentSessionId = snapshot.sessionId || null;
            this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id;
            this._currentNegativePrompt = snapshot.negativePrompt || '';
            await this._setActiveBrainProfile(this._currentSessionBrainId, true);
            this._agent.setHistory(snapshot.history);
            this._view.webview.postMessage({
                type: 'restoreHistory',
                value: snapshot.history,
                negativePrompt: this._currentNegativePrompt,
            });
        }
    }

    async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) {
        if (history.length === 0) {
            await this._sessionState.setLastVisibleChat(null);
            return;
        }
        await this._sessionState.setLastVisibleChat({
            history,
            brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
            sessionId: this._currentSessionId,
            timestamp: Date.now(),
            negativePrompt: this._currentNegativePrompt,
        });
    }

    async _saveCurrentSession() {
        try {
            await this._saveCurrentSessionInner();
        } catch (err: any) {
            // Never let a persistence failure escape — callers run this from a
            // `finally` block, so a throw here would mask the original error
            // and (worse) is itself the thing that drops chats from 기록.
            logError('Failed to save current chat session.', { error: err?.message ?? String(err) });
        }
    }

    private async _saveCurrentSessionInner() {
        const history = this._agent.getHistory();
        if (history.length === 0) return;

        let sessions = this._sessionStore.getAll();
        const title = buildSessionTitleFromHistory(history);
        const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;

        if (!this._currentSessionId) {
            this._currentSessionId = Date.now().toString();
            sessions.unshift({
                id: this._currentSessionId,
                title,
                timestamp: Date.now(),
                history,
                brainProfileId,
                negativePrompt: this._currentNegativePrompt,
            });
        } else {
            const idx = sessions.findIndex((s) => s.id === this._currentSessionId);
            if (idx >= 0) {
                sessions[idx].history = history;
                sessions[idx].timestamp = Date.now();
                sessions[idx].brainProfileId = brainProfileId;
                sessions[idx].negativePrompt = this._currentNegativePrompt;
                if (!sessions[idx].title || sessions[idx].title === 'New Chat') {
                    sessions[idx].title = title;
                }
            } else {
                sessions.unshift({
                    id: this._currentSessionId,
                    title,
                    timestamp: Date.now(),
                    history,
                    brainProfileId,
                    negativePrompt: this._currentNegativePrompt,
                });
            }
        }

        // ChatSessionStore.putAll() 가 50 cap 적용 — 여기서는 단순 위임.
        await this._sessionStore.putAll(sessions);
        await this._sessionState.setActiveSessionId(this._currentSessionId);
        await this._persistLastVisibleChat(history);
        await this._sendSessionList();
    }

    async _sendSessionList() {
        if (!this._view) return;
        this._view.webview.postMessage(buildSessionListPayload(this._sessionStore.getAll()));
    }

    async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
        if (!id) {
            logError('Session load requested without an id.');
            this._postError('Chat session id is missing.');
            return false;
        }

        const sessions = this._sessionStore.getAll();
        const session = sessions.find((s) => s.id === id) || this._sessionStore.getById(id);
        if (!session) {
            logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
            this._postError('Chat session was not found.');
            return false;
        }

        const history = Array.isArray(session.history) ? session.history : [];
        if (history.length === 0) {
            logError('Session load failed because history is empty or invalid.', { id });
            this._postError('This chat session has no saved messages.');
            return false;
        }

        this._agent.stop();
        this._currentSessionId = id;
        this._currentNegativePrompt = session.negativePrompt || '';
        const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id;
        await this._setActiveBrainProfile(sessionBrainId, true);
        this._agent.setHistory(history);
        await this._sessionState.setActiveSessionId(id);
        await this._sessionState.setBlankChatActive(false);
        await this._persistLastVisibleChat(history);
        this._view?.webview.postMessage(buildSessionLoadedPayload(id, session.title, history, this._currentNegativePrompt));
        if (!skipSessionListRefresh) {
            await this._sendSessionList();
        }
        logInfo('Chat session loaded.', { id, messages: history.length });
        return true;
    }

    async _deleteSession(id: string) {
        let sessions = this._sessionStore.getAll();
        sessions = sessions.filter((s) => s.id !== id);
        await this._sessionStore.putAll(sessions);
        if (this._currentSessionId === id) {
            this._currentSessionId = null;
            this._agent.resetConversation();
            await this._sessionState.setActiveSessionId(null);
            await this._sessionState.setLastVisibleChat(null);
            await this._sessionState.setBlankChatActive(true);
            this.clearChat();
        }
        await this._sendSessionList();
    }

    // _getSessions / _getSessionById / _putSessions → `src/sidebar/managers/chatSessionStore.ts`
    //   (`this._sessionStore.getAll()` / `.getById()` / `.putAll()`)

    async _sendBrainStatus() {
        if (!this._view) return;
        const activeBrain = getActiveBrainProfile();
        const files = findBrainFiles(activeBrain.localBrainPath);
        this._view.webview.postMessage(buildBrainStatusPayload(activeBrain, files.length));
    }

    /**
     * One-line "current readiness" snapshot for the sidebar's status bar:
     * engine online?, model loaded?, Brain file count, active Agent + mapped knowledge
     * folder count, memory on/off, context window. Cheap — no network calls except the
     * already-cached LM Studio loaded-models list and online flag.
     */
    async _sendReadyStatus() {
        if (!this._view) return;
        try {
            const config = getConfig();
            const engineKind = resolveEngine(config.ollamaUrl);
            const activeBrain = getActiveBrainProfile();
            let brainFiles = 0;
            try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ }

            const agentPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
            let agentName: string | null = null;
            let scopeFolders = 0;
            let mapped = false;
            if (agentPath && agentPath !== 'none') {
                agentName = path.basename(agentPath).replace(/\.md$/i, '');
                try {
                    const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || '');
                    scopeFolders = scope.folders.length;
                    if (scope.agent?.name) agentName = scope.agent.name;
                    mapped = scope.source !== 'none';
                } catch { /* ignore */ }
            }

            let modelLoaded: boolean | null = null;
            if (engineKind === 'lmstudio') {
                try {
                    const loaded = (await this._lmStudio?.loadedModels()) || [];
                    modelLoaded = loaded.includes(config.defaultModel);
                } catch { modelLoaded = null; }
            }

            const paramB = estimateModelParamsB(config.defaultModel);
            const cappedForSmallModel = config.smallModelContextCap > 0
                && paramB !== null && paramB <= 4
                && config.contextLength > config.smallModelContextCap;
            const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength;

            const payload = buildReadyStatusPayload({
                engineKind,
                engineOnline: this._modelDiscovery.cachedOnline(config.ollamaUrl),
                modelName: config.defaultModel,
                modelLoaded,
                modelParamB: paramB,
                brainName: activeBrain.name,
                brainFiles,
                agentName,
                agentScopeFolders: scopeFolders,
                agentMapped: mapped,
                memoryEnabled: config.memoryEnabled,
                multiAgentEnabled: config.multiAgentEnabled,
                contextLength: config.contextLength,
                effectiveContextLength,
                cappedForSmallModel,
                lmStudioError: this._lmStudioLastError ?? null,
                healthWarnings: HealthCheckMonitor.lastReports,
            });
            this._view.webview.postMessage(payload);
        } catch (err: any) {
            logError('Failed to build ready status.', { error: err?.message || String(err) });
        }
    }

    async _sendBrainProfiles() {
        if (!this._view) return;
        const activeBrain = getActiveBrainProfile();
        this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
        const profiles = getBrainProfiles();
        // v2.2.66 — dropdown 이 갑자기 비는 회귀가 보고됨. 무엇이 실제로 전송되는지 추적.
        logInfo(`[_sendBrainProfiles] profiles=${profiles.length} activeBrainId=${activeBrain.id} active=${activeBrain.name}`);
        this._view.webview.postMessage(buildBrainProfilesPayload(profiles, activeBrain.id));
        void this._sendReadyStatus();
    }

    _postBrainProfiles(profiles: any[], activeBrainId: string) {
        if (!this._view) return;
        this._view.webview.postMessage(buildBrainProfilesPayload(profiles, activeBrainId));
    }

    async _setActiveBrainProfile(profileId: string, silent: boolean = false) {
        const profiles = getBrainProfiles();
        const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0];
        if (!nextProfile) return;

        await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global);
        this._currentSessionBrainId = nextProfile.id;
        // Drop the in-memory brain token index — the active brain (and its path)
        // may now differ, and the index's `_states` Map is otherwise never cleared.
        // The persisted on-disk index is left intact and reloads lazily on the
        // next query for whichever brain is now active.
        clearBrainTokenIndex();
        await this._sendBrainProfiles();
        await this._sendBrainStatus();

        if (!silent) {
            this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``);
        }
    }

    async _manageBrains() {
        const activeBrain = getActiveBrainProfile();
        const choice = await vscode.window.showQuickPick([
            {
                label: 'Add Brain Folder',
                description: 'Create a new brain profile from a local folder'
            },
            {
                label: 'Open Active Brain Folder',
                description: activeBrain.localBrainPath
            },
            {
                label: 'Open Brain Settings',
                description: 'Edit names, paths, repos, and descriptions'
            }
        ], {
            placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})`
        });

        if (!choice) return;

        if (choice.label === 'Add Brain Folder') {
            await this._addBrainProfile();
            return;
        }

        if (choice.label === 'Open Active Brain Folder') {
            if (!fs.existsSync(activeBrain.localBrainPath)) {
                fs.mkdirSync(activeBrain.localBrainPath, { recursive: true });
            }
            await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath));
            return;
        }

        vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles');
    }

    /**
     * Add/edit/delete 의 공통 *persist + broadcast* 시퀀스.
     *
     *   1) `brainProfiles` 와 `activeBrainId` 두 설정을 globalState 에 동기화.
     *   2) `_currentSessionBrainId` 캐시 업데이트 — 다음 send 가 옳은 brain id 사용.
     *   3) `_postBrainProfiles` 로 freshly-built 목록 직접 송신 — cfg.update() 가
     *      async 라 캐시가 즉시 반영되지 않을 수 있어 raw nextProfiles 를 우선.
     *   4) `_sendBrainStatus` 로 chip 갱신.
     *   5) systemMessage 로 사용자에게 알림.
     */
    private async _commitBrainProfileChange(nextProfiles: any[], nextActiveId: string, systemMessage: string): Promise<void> {
        const cfg = vscode.workspace.getConfiguration('g1nation');
        try {
            await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
            await cfg.update('activeBrainId', nextActiveId, vscode.ConfigurationTarget.Global);
        } catch (err: any) {
            logError('Failed to persist brain profiles.', { error: err?.message || String(err) });
            vscode.window.showErrorMessage(`두뇌 프로필 저장 실패 (settings.json 쓰기 오류): ${err?.message ?? err}`);
            throw err;
        }
        // Read-back 검증 — cfg.update 가 성공처럼 반환해도 effective config 에 반영 안 될 수 있다:
        //   (a) Workspace/Folder scope 의 g1nation.brainProfiles 가 Global 값을 가림,
        //   (b) settings.json 쓰기 권한/프로필 문제.
        // 둘 다 화면상 "추가가 안 됨" 으로만 보였던 silent failure → 이제 명시적으로 알린다.
        const written = vscode.workspace.getConfiguration('g1nation').get<any[]>('brainProfiles', []) || [];
        const landed = written.some((p) => p && p.id === nextActiveId);
        if (!landed) {
            const inspected = vscode.workspace.getConfiguration('g1nation').inspect<any[]>('brainProfiles');
            const hasWorkspace = !!(inspected?.workspaceValue || inspected?.workspaceFolderValue);
            const reason = hasWorkspace
                ? 'Workspace 설정(.vscode/settings.json)의 g1nation.brainProfiles 가 전역 값을 가리고 있습니다. 그 항목을 지우거나 그곳에 추가하세요.'
                : 'settings.json 쓰기가 반영되지 않았습니다 (파일 권한 또는 VS Code 프로필 설정을 확인하세요).';
            logError('Brain profile write did not land in effective config.', { hasWorkspace });
            vscode.window.showErrorMessage(`두뇌 추가 실패: ${reason}`);
        }
        this._currentSessionBrainId = nextActiveId;
        this._postBrainProfiles(nextProfiles, nextActiveId);
        await this._sendBrainStatus();
        this.injectSystemMessage(systemMessage);
    }

    async _addBrainProfile() {
        try {
            const selected = await vscode.window.showOpenDialog({
                canSelectFiles: false,
                canSelectFolders: true,
                canSelectMany: false,
                openLabel: '이 폴더를 두뇌로 사용'
            });

            const folder = selected?.[0]?.fsPath;
            if (!folder) return; // 폴더 선택 취소 — 정상 종료 (에러 아님)

            // 구조 개선: 예전엔 폴더 선택 후 이름·설명·repo 입력창 3개가 연속으로 떴고, '이름' 입력창을
            // Esc/바깥클릭으로 닫으면 `if (!name) return` 으로 전체 추가가 *조용히* 취소됐다. 이것이
            // "추가가 안 된다" 의 주원인. 이제 폴더만 있으면 추가가 보장되고, 이름은 비우거나 취소해도
            // 폴더명으로 진행한다. 설명/repo 는 추가 후 [수정] 에서 채운다 (다이얼로그 체인 최소화).
            const defaultName = path.basename(folder) || 'New Brain';
            const nameInput = await vscode.window.showInputBox({
                prompt: '두뇌 이름 (비워두면 폴더명 사용)',
                value: defaultName
            });
            const name = (nameInput && nameInput.trim()) ? nameInput.trim() : defaultName;

            // getConfig() 가 메모리에 주입하는 가상 default-brain 이 저장되지 않도록 raw 설정을 직접 읽는다.
            const cfg = vscode.workspace.getConfiguration('g1nation');
            const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
            const id = generateUniqueBrainId(name, existingRaw);
            const newProfile = {
                id,
                name,
                localBrainPath: folder,
                secondBrainRepo: '',
                description: ''
            };
            const nextProfiles = [...existingRaw, newProfile];
            await this._commitBrainProfileChange(nextProfiles, id, `**[Brain Added]** ${name}\n\`${folder}\``);
            vscode.window.showInformationMessage(`두뇌 추가됨: ${name}`);
        } catch (err: any) {
            logError('Failed to add brain profile.', { error: err?.message || String(err) });
            vscode.window.showErrorMessage(`두뇌 추가 중 오류: ${err?.message ?? err}`);
        }
    }

    async _editBrainProfile(profileId?: string) {
        const currentProfiles = getBrainProfiles();
        const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
        if (!target) return;

        const name = await vscode.window.showInputBox({
            prompt: 'Edit brain profile name',
            value: target.name,
            validateInput: (value) => value.trim() ? null : 'Brain name is required.'
        });
        if (!name) return;

        const folder = await vscode.window.showInputBox({
            prompt: 'Edit local brain folder path',
            value: target.localBrainPath,
            validateInput: (value) => value.trim() ? null : 'Brain folder path is required.'
        });
        if (!folder) return;

        const description = await vscode.window.showInputBox({
            prompt: 'Edit optional description shown in the Astra sidebar',
            value: target.description || ''
        });

        const repo = await vscode.window.showInputBox({
            prompt: 'Edit optional Second Brain Git repository URL',
            value: target.secondBrainRepo || ''
        });

        const nextProfiles = currentProfiles.map((profile) => profile.id === target.id
            ? {
                ...profile,
                name: name.trim(),
                localBrainPath: folder.trim(),
                secondBrainRepo: (repo || '').trim(),
                description: (description || '').trim()
            }
            : profile
        );
        await this._commitBrainProfileChange(nextProfiles, target.id, `**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``);
    }

    async _deleteBrainProfile(profileId?: string) {
        const currentProfiles = getBrainProfiles();
        const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
        if (!target) return;

        if (currentProfiles.length <= 1) {
            vscode.window.showWarningMessage('At least one brain profile is required.');
            return;
        }

        const confirm = await vscode.window.showWarningMessage(
            `Delete brain profile "${target.name}"? The folder itself will not be deleted.`,
            { modal: true },
            'Delete Profile'
        );
        if (confirm !== 'Delete Profile') return;

        const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id);
        const nextActive = nextProfiles[0];
        await this._commitBrainProfileChange(nextProfiles, nextActive.id, `**[Brain Deleted]** ${target.name}`);
    }

    async _saveWikiRaw() {
        const history = this._agent.getHistory();
        if (history.length === 0) {
            vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.');
            return;
        }

        const activeBrain = getActiveBrainProfile();
        const rawDir = path.join(activeBrain.localBrainPath, 'raw-data');
        if (!fs.existsSync(rawDir)) {
            fs.mkdirSync(rawDir, { recursive: true });
        }

        const category = await vscode.window.showInputBox({
            prompt: 'Wiki raw data category',
            value: 'Project Notes'
        });
        if (category === undefined) return;

        const expectedValue = await vscode.window.showInputBox({
            prompt: 'Expected value or future use',
            value: 'Reusable as source material for a future wiki document.'
        });
        if (expectedValue === undefined) return;

        const timestamp = new Date();
        const slug = slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
        const fileName = `${formatTimestampForFile(timestamp)}-${slug}.md`;
        const filePath = path.join(rawDir, fileName);
        const markdown = buildWikiRawMarkdown(history, {
            category: category.trim() || 'Project Notes',
            expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.',
            activeBrainName: activeBrain.name,
            activeBrainPath: activeBrain.localBrainPath,
            createdAt: timestamp.toISOString()
        });

        await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8'));
        vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`);
        this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``);
    }

    // _buildWikiRawMarkdown / _formatTimestampForFile → `src/sidebar/builders/wikiRaw.ts`

    // slugify / summarizeForTitle / summarizeTextForWiki / escapeYamlString
    //   → `src/sidebar/builders/textHelpers.ts`

    // --- BridgeInterface Methods ---

    /**
     * `streamStart → streamChunk → streamEnd` triple — webview 가 한 덩이 메시지로
     * 인식하게. injectSystemMessage / proactiveSuggestion 등 두 곳 이상이 같은
     * 패턴을 쓰므로 한 메서드로 통일.
     */
    private _streamSystemNotice(content: string): void {
        if (!this._view) return;
        this._view.webview.postMessage({ type: 'streamStart' });
        this._view.webview.postMessage({ type: 'streamChunk', value: content });
        this._view.webview.postMessage({ type: 'streamEnd' });
    }

    /** Webview 에 error toast 한 줄 — 6+ 곳이 같은 inline payload 를 만들고 있었음. */
    private _postError(value: string): void {
        this._view?.webview.postMessage({ type: 'error', value });
    }

    /** streamEnd 단독 송신 — 회사 turn finally / alignment cancel 등 input 잠금 해제용. */
    private _postStreamEnd(): void {
        this._view?.webview.postMessage({ type: 'streamEnd' });
    }

    public injectSystemMessage(msg: string): void {
        this._streamSystemNotice(msg);
    }

    public getHistoryText(): string {
        return "Conversation history placeholder for evaluation.";
    }

    public sendPromptFromExtension(prompt: string): void {
        if (this._view) {
            this._view.show?.(true);
            this._view.webview.postMessage({ type: 'injectPrompt', value: prompt });
        }
    }

    public findBrainFiles(dir: string): string[] {
        return findBrainFiles(dir);
    }

    // --- End BridgeInterface ---

    public focusInput() {
        this._view?.webview.postMessage({ type: 'focusInput' });
    }

    public clearChat() {
        this._view?.webview.postMessage({ type: 'clearChat' });
    }

    /**
     * 두뇌 동기화 — 두 모드:
     *   1. 원격 미설정 (profile.secondBrainRepo 비어 있음, 기본): 검색 인덱스/캐시
     *      새로고침만. 사용자의 멘탈 모델("동기화 = 내 로컬 두뇌 폴더 인식")과 일치.
     *      git 을 건드리지 않으므로 인증도 불필요.
     *   2. 원격 설정됨: pull(rebase) → commit/push → 인덱스 갱신 (백업/공유 모드).
     *
     * 배경: 옛 구현은 원격 설정 여부와 무관하게 git push 를 강행해서, 두뇌 폴더가
     * 우연히 다른 git 레포 안에 있으면 그 레포의 원격 인증을 요구하는 혼란이 있었다.
     */
    public async syncBrain() {
        const activeBrain = getActiveBrainProfile();
        const brainDir = activeBrain.localBrainPath;
        if (!fs.existsSync(brainDir)) {
            vscode.window.showErrorMessage("Second Brain directory not found.");
            return;
        }

        // ── 모드 1: 원격 미설정 → 로컬 새로고침만 (git 없음, 인증 불필요) ──
        if (!activeBrain.secondBrainRepo || !activeBrain.secondBrainRepo.trim()) {
            try {
                invalidateBrainFilesCache();
                clearBrainTokenIndex(brainDir);
                const count = findBrainFiles(brainDir).length;
                vscode.window.showInformationMessage(
                    `두뇌 새로고침 완료 — 문서 ${count}개 인식됨. ` +
                    `(이 두뇌는 원격 저장소가 설정되어 있지 않아 git 동기화는 건너뜁니다. ` +
                    `새 문서는 평소에도 자동 인식되므로 이 버튼은 강제 새로고침 용도입니다.)`,
                );
            } catch (e: any) {
                vscode.window.showErrorMessage(`두뇌 새로고침 실패: ${String(e?.message ?? e).slice(0, 150)}`);
            }
            return;
        }
        vscode.window.withProgress({
            location: vscode.ProgressLocation.Notification,
            title: "Astra: Syncing Second Brain...",
            cancellable: false
        }, async () => {
            const { execSync } = require('child_process');
            const run = (cmd: string): string =>
                execSync(cmd, { cwd: brainDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) as string;

            // ── ⓪ upstream 확인 — 없으면 pull/push 없이 로컬 커밋만 (정직 보고) ──
            // 사례: 두뇌 폴더에 원격 없는 중첩 .git 이 생겨 있던 환경에서
            // "There is no tracking information" 으로 동기화 전체가 죽었다.
            // 원격 추적이 없는 두뇌는 로컬 버전 관리만 수행하고 그 사실을 알린다.
            let hasUpstream = true;
            try {
                run('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
            } catch {
                hasUpstream = false;
            }

            // ── ① 원격 변경 수신 (pull --rebase --autostash) ──
            let pulledFiles = 0;
            if (hasUpstream) {
                try {
                    run('git fetch');
                    // upstream 대비 새로 들어올 파일 수 — 사용자에게 "몇 개 받았는지" 보여주기 위해.
                    try {
                        const incoming = run('git diff --name-only HEAD "@{u}"').trim();
                        pulledFiles = incoming ? incoming.split('\n').filter(Boolean).length : 0;
                    } catch { /* 카운트만 생략 */ }
                    run('git pull --rebase --autostash');
                } catch (err: any) {
                    const detail = String(err?.stderr || err?.message || err).slice(0, 200);
                    logError('Brain sync: pull failed.', { brainDir, detail });
                    vscode.window.showErrorMessage(`두뇌 동기화 실패 (원격 수신 중): ${detail}`);
                    return;
                }
            }

            // ── ② 로컬 변경 송신 (add → commit → push) ──
            // 변경 판정은 *staged* 기준 (`git diff --cached --quiet`) — 두뇌 폴더가
            // 더 큰 레포의 하위 폴더일 때 `git status --porcelain` 은 폴더 *밖* 의
            // 변경까지 보고해서, staged 가 비어 있는데 commit 을 시도하다
            // "no changes added" 로 실패하는 버그가 있었다.
            let pushed = false;
            let committedLocally = false;
            try {
                run('git add .');
                let hasStaged = false;
                try {
                    run('git diff --cached --quiet'); // exit 0 = staged 없음
                } catch {
                    hasStaged = true; // exit 1 = staged 변경 존재
                }
                if (hasStaged) {
                    run('git commit -m "[G1-Sync] Manual knowledge update"');
                    committedLocally = true;
                    if (hasUpstream) {
                        run('git push');
                        pushed = true;
                    }
                } else if (hasUpstream) {
                    // 커밋할 변경 없음 — ahead 상태(이전 커밋 미push)면 push 만.
                    const ahead = run('git rev-list --count "@{u}"..HEAD').trim();
                    if (ahead && ahead !== '0') {
                        run('git push');
                        pushed = true;
                    }
                }
            } catch (err: any) {
                const detail = String(err?.stderr || err?.message || err).slice(0, 200);
                logError('Brain sync: push failed.', { brainDir, detail });
                // 인증 부재는 사용자가 직접 해결해야 하는 환경 문제 — git 원문 대신
                // 행동 가능한 안내. (확장은 비대화형이라 아이디/토큰을 물어볼 수 없다.)
                if (/could not read Username|Authentication failed|terminal prompts disabled|403/i.test(detail)) {
                    vscode.window.showErrorMessage(
                        `두뇌 동기화 실패: git 인증 정보가 저장되어 있지 않습니다. ` +
                        `터미널에서 "cd ${brainDir} && git push" 를 1회 실행해 아이디/토큰을 입력하면 ` +
                        `키체인에 저장되어 이후 버튼이 정상 작동합니다. (로컬 커밋은 완료된 상태)`,
                    );
                } else {
                    vscode.window.showErrorMessage(`두뇌 동기화 실패 (송신 중): ${detail}`);
                }
                return;
            }

            // ── ③ 검색 인덱스/캐시 무효화 — 새 문서가 즉시 검색에 잡히게 ──
            try {
                invalidateBrainFilesCache();
                clearBrainTokenIndex(brainDir);
            } catch { /* 캐시 무효화 실패는 치명적이지 않음 — TTL/mtime 이 결국 따라잡음 */ }

            const parts: string[] = [];
            if (pulledFiles > 0) parts.push(`원격 문서 ${pulledFiles}개 수신`);
            if (pushed) parts.push('로컬 변경 push 완료');
            if (committedLocally && !pushed) parts.push('로컬 커밋 완료');
            if (!hasUpstream) parts.push('⚠️ 원격 추적 브랜치 없음 — 로컬 버전 관리만 수행됨');
            if (parts.length === 0) parts.push('변경 없음 — 이미 최신 상태');
            vscode.window.showInformationMessage(`두뇌 동기화 완료: ${parts.join(' · ')} (검색 인덱스 갱신됨)`);
        });
    }

    // _getChronicleProjects / _putChronicleProjects → `src/sidebar/managers/chronicleProjectStore.ts`
    //   (`this._chronicleStore.getAll()` / `.putAll()`)

    /**
     * One-time repair for chronicle projects that were stored before the
     * multi-subproject fix landed.
     *
     * Old activation code always treated `workspaceFolders[0]` (= the open
     * parent folder, e.g. `/.../Antigravity`) as the project root, so every
     * subproject the user activated (`ConnectAI`, `Datacollector_MAC`,
     * `Skybound`, …) ended up with `projectRoot === <parent>` in globalState.
     * That breaks reload: `_ensureActiveProjectForWorkspace` case 2 matches
     * the corrupted entry on every boot and force-switches the active
     * project to whichever bad row it found first.
     *
     * The repair: for each chronicle project whose `projectName` does NOT
     * match the basename of its stored `projectRoot` (i.e. it can't actually
     * be the project root), and where a same-name subfolder exists under
     * the current workspace, retarget the entry to that subfolder. Idempotent
     * — re-running it after the first pass changes nothing.
     */
    async _repairCorruptedChronicleProjectRoots(): Promise<void> {
        const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
        if (!wsRoot) return;
        const projects = this._chronicleStore.getAll();
        if (projects.length === 0) return;
        const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
        const wsRootNorm = norm(wsRoot);
        let changed = false;
        const repaired = projects.map((p) => {
            if (!p.projectName || !p.projectRoot) return p;
            const rootBase = path.basename(p.projectRoot).toLowerCase();
            const nameMatches = rootBase === p.projectName.toLowerCase();
            if (nameMatches) return p; // root basename agrees with name → trust it
            // Only repair entries that look like they were silently captured at
            // the workspace parent. Anything else (different machine, custom
            // path) we leave alone.
            if (norm(p.projectRoot) !== wsRootNorm) return p;
            const candidate = path.join(wsRoot, p.projectName);
            try {
                if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) return p;
            } catch {
                return p;
            }
            const newDocPath = path.join(candidate, '.astra', 'project-context', 'architecture.md');
            changed = true;
            return {
                ...p,
                projectRoot: candidate,
                recordRoot: path.join(candidate, 'docs', 'records', p.projectName),
                architectureDocPath: fs.existsSync(newDocPath) ? newDocPath : p.architectureDocPath,
            };
        });
        if (!changed) return;
        await this._chronicleStore.putAll(repaired);
        const fixedCount = repaired.filter((p, i) => p !== projects[i]).length;
        logInfo('architecture: repaired chronicle projects with wrong projectRoot.', {
            wsRoot, count: fixedCount,
        });
    }

    // ─── Project Architecture Context (Feature 2) ──────────────────────────────
    //
    // Activation flow:
    //   1. Chat preprocessor (or an explicit "Activate" button) calls
    //      _tryActivateArchitectureFromText(latestUserMessage).
    //   2. If the text yields a known/inferable project, we set it active,
    //      ensure the architecture doc exists, register the file watcher,
    //      and broadcast the state to the webview as a chip.
    //   3. On every subsequent prompt, _handlePrompt reads
    //      _buildProjectArchitectureContext() and injects it into the model
    //      call. Detach → empty context + watcher disposed.

    /**
     * Try to resolve a project handle from arbitrary user text. Combines:
     *   • Korean / English natural-language activation phrasing.
     *   • Absolute filesystem paths.
     *   • The existing Chronicle project list as ground truth for name matches.
     */
    _detectProjectFromText(text: string): KnownProject | null {
        const known = this._chronicleStore.getAll().map<KnownProject>((p) => ({
            projectId: p.projectId,
            projectName: p.projectName,
            projectRoot: p.projectRoot,
        }));
        const hit = detectProjectIntent(text || '', known);
        return hit?.project ?? null;
    }

    /**
     * Activate (or refresh) architecture context for the project resolved from
     * `text`. No-op when no project is detected. Returns the activated profile
     * id, or `null` if nothing changed. Side-effects: writes the architecture
     * doc, marks the project active, broadcasts the chip state.
     */
    async _tryActivateArchitectureFromText(text: string): Promise<string | null> {
        const detected = this._detectProjectFromText(text);
        if (!detected) return null;
        return this._activateArchitectureForProject(detected.projectId, {
            fallbackName: detected.projectName,
            fallbackRoot: detected.projectRoot,
        });
    }

    /**
     * Make `projectId` the active project, ensure its architecture doc exists,
     * and register the file watcher. If the project isn't in the chronicle
     * store yet (path-only match), materialise a minimal profile so subsequent
     * turns can find it.
     */
    async _activateArchitectureForProject(
        projectId: string,
        opts: { fallbackName?: string; fallbackRoot?: string } = {}
    ): Promise<string | null> {
        const projects = this._chronicleStore.getAll();
        let profile = projects.find((p) => p.projectId === projectId);

        // Materialise a stub when the user references a project by path that
        // isn't yet registered. The factory uses the path's basename as the
        // name and the standard records location so existing Chronicle code
        // keeps working.
        if (!profile) {
            const root = opts.fallbackRoot || '';
            if (!root) {
                logError('architecture: cannot activate without project root.', { projectId });
                return null;
            }
            profile = architectureActivationStub(projectId, root, opts.fallbackName);
            projects.push(profile);
            await this._chronicleStore.putAll(projects);
        }

        if (!profile.projectRoot) {
            logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
            return null;
        }

        // Generate or refresh the doc. Always idempotent — the generator
        // preserves user-owned sections.
        const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
        const now = new Date().toISOString();
        const updated: ProjectProfile = {
            ...profile,
            architectureDocPath: result.docPath,
            // [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined.
            // After a user Detach, `architectureAutoAttach === false`, so `false ?? true`
            // stays `false` and the chip stays "detached — click Attach to re-enable"
            // forever no matter how many times the user clicks Attach. The activation
            // path is an explicit user intent to re-enable, so force `true` here.
            architectureAutoAttach: true,
            architectureAutoUpdate: profile.architectureAutoUpdate ?? true,
            architectureLastUpdated: now,
            architectureLastScanSignature: result.scan.signature,
            updatedAt: now,
        };
        const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p));
        await this._chronicleStore.putAll(next);
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);

        // (Re)register the watcher for this project.
        this._archWatch.register(updated);

        // Tell the webview to show / refresh the chip.
        await this._sendArchitectureStatus();
        logInfo('architecture: activated.', {
            projectId, docPath: result.docPath, created: result.created,
        });
        return projectId;
    }

    /** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
    async _detachArchitecture(): Promise<void> {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            this._archWatch.dispose();
            await this._sendArchitectureStatus();
            return;
        }
        const projects = this._chronicleStore.getAll();
        const next = projects.map((p) => p.projectId === profile.projectId
            ? { ...p, architectureAutoAttach: false }
            : p);
        await this._chronicleStore.putAll(next);
        this._archWatch.dispose();
        await this._sendArchitectureStatus();
        logInfo('architecture: detached.', { projectId: profile.projectId });
    }

    /**
     * Force a refresh of the architecture doc for the active project.
     *
     * Always rewrites the auto-managed block (so the "Last Refresh" stamp +
     * stats reflect the click). Emits an `architectureRefreshResult` event
     * with the per-file work breakdown — that's what makes the operation
     * visibly trustworthy in the UI (no more "0.1s, nothing visible").
     */
    async _refreshArchitecture(): Promise<void> {
        const startedAt = Date.now();
        const profile = this._getActiveChronicleProject();
        if (!profile || !profile.projectRoot) {
            this._view?.webview.postMessage(buildArchitectureRefreshFailedPayload('no-active-project'));
            return;
        }
        const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
        const now = new Date().toISOString();
        const projects = this._chronicleStore.getAll();
        const next = projects.map((p) => p.projectId === profile.projectId
            ? {
                ...p,
                architectureDocPath: result.docPath,
                architectureLastUpdated: now,
                architectureLastScanSignature: result.scan.signature,
                updatedAt: now,
            }
            : p);
        await this._chronicleStore.putAll(next);
        await this._sendArchitectureStatus();
        // Tell the webview exactly what the scan did so the user can
        // trust the "Refresh" button actually ran. The three numbers
        // (newly / cached / deleted) together explain whether the doc
        // changed or just had its timestamp bumped.
        this._view?.webview.postMessage(buildArchitectureRefreshResultPayload({
            projectName: profile.projectName,
            docPath: result.docPath,
            newlyAnalyzed: result.refreshStats.newlyAnalyzed,
            cached: result.refreshStats.cached,
            deleted: result.refreshStats.deleted.length,
            durationMs: Date.now() - startedAt,
        }));
    }

    /**
     * Re-attach the architecture context for the active project after a
     * prior Detach. Rebuilds the doc (so the user gets a fresh scan),
     * flips `architectureAutoAttach=true`, re-registers the watcher, and
     * broadcasts the chip back to its active state. The complement of
     * `_detachArchitecture`.
     */
    async _attachArchitecture(): Promise<void> {
        // `_ensureActiveProjectForWorkspace` guarantees the active project
        // matches the current VS Code workspace — without that, hitting
        // Attach right after opening a different folder would silently
        // attach to whatever was last active in the *previous* workspace.
        const profile = await this._ensureActiveProjectForWorkspace();
        if (!profile || !profile.projectRoot) {
            this._view?.webview.postMessage(buildArchitectureRefreshFailedPayload('no-active-project'));
            return;
        }
        await this._activateArchitectureForProject(profile.projectId, {
            fallbackName: profile.projectName,
            fallbackRoot: profile.projectRoot,
        });
    }

    /**
     * Make sure the active chronicle project actually corresponds to the
     * folder the user has open in VS Code. Three cases:
     *
     *   1. Active project already matches workspace → return it as-is.
     *   2. A *different* chronicle project matches the workspace → flip
     *      the active id to that one (the user switched folders since
     *      last session).
     *   3. No chronicle project matches → synthesise a new one from the
     *      workspace folder name + register it.
     *
     * Returns the (possibly newly created) active project, or `null` when
     * no workspace is open. Idempotent — calling repeatedly with no change
     * is free.
     */
    async _ensureActiveProjectForWorkspace(): Promise<ProjectProfile | null> {
        const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
        if (!wsRoot) return null;
        // When the parent folder contains several subprojects, use the active
        // editor's location to pick the *effective* subproject root instead of
        // always reporting the parent.
        const hint = vscode.window.activeTextEditor?.document.uri.fsPath
            ?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath;
        const resolved = resolveActiveSubprojectRoot(wsRoot, hint);
        const active = this._getActiveChronicleProject();
        const projects = this._chronicleStore.getAll();
        // Did the editor hint actually point at a nested subproject? If the
        // resolver fell back to the workspace root (no nested marker, or the
        // Astra sidebar itself was focused so no editor existed), we must NOT
        // clobber the user's current active project — they may have set it by
        // hand via the project picker, or by typing a project intent. Auto-
        // switching is only safe when there is a clear editor-driven signal.
        const isNestedHit = !!resolved && normalizePathForCompare(resolved) !== normalizePathForCompare(wsRoot);
        if (!isNestedHit && active) {
            return active;
        }
        const workspaceRoot = isNestedHit ? resolved : wsRoot;
        if (active && active.projectRoot && normalizePathForCompare(active.projectRoot) === normalizePathForCompare(workspaceRoot)) {
            return active;
        }
        // Case 2: another chronicle project matches → switch active to it
        const matching = projects.find((p) => normalizePathForCompare(p.projectRoot) === normalizePathForCompare(workspaceRoot));
        if (matching) {
            await this._context.globalState.update(
                SidebarChatProvider.activeChronicleProjectStateKey,
                matching.projectId,
            );
            logInfo('architecture: switched active project to match workspace.', {
                from: active?.projectId,
                to: matching.projectId,
            });
            return matching;
        }
        // Case 3: synthesise a fresh entry for this workspace
        const profile = workspaceSynthesizedProfile(workspaceRoot);
        const nextProjects = projects.filter((p) => p.projectId !== profile.projectId).concat(profile);
        await this._chronicleStore.putAll(nextProjects);
        await this._context.globalState.update(
            SidebarChatProvider.activeChronicleProjectStateKey,
            profile.projectId,
        );
        logInfo('architecture: registered new project from workspace.', {
            projectId: profile.projectId, projectRoot: workspaceRoot,
        });
        return profile;
    }

    /** Active project 의 architecture context (agent.ts prompt prepend). 빌더에 위임. */
    _buildProjectArchitectureContext(): string {
        return buildProjectArchitectureContext(this._getActiveChronicleProject());
    }

    /**
     * Webview chip data. Three states:
     *
     *   1. **active**     — project mode is on; doc is being auto-attached.
     *   2. **inactive**   — there's a project + workspace, but architecture
     *                       is either never-activated or user-detached.
     *                       The chip shows an `[Attach]` button instead of
     *                       hiding entirely, so users always have a one-
     *                       click path back into project mode.
     *   3. **hidden**     — no workspace open and no project at all.
     *
     * Also does an auto-activation pass for the *fresh-workspace* case:
     * when the active project has no `architectureDocPath` yet AND the
     * user hasn't explicitly detached, we generate the doc + flip
     * `autoAttach=true` so the user opens a new folder and immediately
     * sees the architecture context working. Existing detach choices are
     * always respected.
     */
    async _sendArchitectureStatus(): Promise<void> {
        if (!this._view) return;
        // Always sync the active project to the current VS Code workspace
        // before reporting — otherwise switching workspaces leaves the
        // chip pointing at the *previous* project's doc.
        const p = await this._ensureActiveProjectForWorkspace();
        if (!p) {
            this._view.webview.postMessage(buildArchitectureHiddenPayload());
            return;
        }
        const wasDetached = p.architectureAutoAttach === false;
        const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath));

        // Auto-activation for fresh workspaces: never been activated AND
        // never been detached → kick off a build and re-broadcast. Single
        // recursion is safe because the post-activate state will hit the
        // `active` branch below.
        if (!hasDoc && !wasDetached && p.projectRoot) {
            try {
                await this._activateArchitectureForProject(p.projectId, {
                    fallbackName: p.projectName,
                    fallbackRoot: p.projectRoot,
                });
                return; // _activateArchitectureForProject sends its own status
            } catch (e: any) {
                logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) });
                // Fall through to the inactive state so the user still sees an Attach button.
            }
        }

        this._view.webview.postMessage(buildArchitectureStatusPayload(p, hasDoc, wasDetached));
        if (hasDoc && !wasDetached) {
            // Re-register the watcher in case it was disposed (e.g. workspace switch).
            this._archWatch.register(p);
        }
    }

    // ─── 1인 기업 (Company) Mode ────────────────────────────────────────────
    //
    // When `companyState.enabled` is true, prompts coming through the chat
    // handler are routed to `_runCompanyTurn` instead of the normal
    // AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as
    // each phase progresses; the webview shows a step-by-step header for
    // CEO planning, each specialist's dispatch, and the final synthesis.

    /** True iff company mode is active. Cheap — read from globalState. */
    isCompanyModeEnabled(): boolean {
        return readCompanyState(this._context).enabled;
    }

    /**
     * Abort the currently-running 1인 기업 turn if any. Returns true when an
     * abort was actually fired (so the chat handler can skip `agent.stop()`
     * — the company path never touches AgentExecutor). The dispatcher will
     * see `signal.aborted` at its next phase boundary and emit
     * `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
     * `streamEnd` so the UI unlocks.
     */
    abortCompanyTurn(): boolean {
        if (!this._companyTurn.abort()) return false;
        // 승인 게이트 대기 중인 모든 stage 를 'abort' 로 해제. 안 하면 dispatcher 가
        // 영원히 await 상태로 남아 turn 이 절대 종료 안 됨. ApprovalGateManager 가
        // 일괄 처리 + Map clear.
        this._approvalGates.abortAll();
        return true;
    }

    /**
     * Clear the cached "last completed company turn" — called when the user
     * starts a new chat / loads a different session. Without this, an old
     * report would bleed into the next session's intent classifications and
     * make stale "follow-up" verdicts.
     */
    clearLastCompanyTurnSummary(): void {
        this._companyTurn.clearLastSummary();
    }

    /** Read accessor for the intent classifier. May return undefined on cold start. */
    getLastCompanyTurnSummary() {
        return this._companyTurn.getLastSummary();
    }

    /**
     * Intent Alignment 1라운드 실행. new_task 분류 직후 (또는 사용자 답변 후)
     * 호출되며 LLM 분석기를 한 번 돌려 contract를 채운다. confidence가 'high'
     * (또는 strict 모드 아니고 medium)이면 곧장 pipeline dispatch로 넘어가고,
     * 그 외엔 webview에 카드를 띄워 사용자 응답을 기다린다(_pendingAlignment).
     *
     * mode:
     *   - 'smart': high → 자동 dispatch, medium → 사용자 확인 카드, low → 질문 카드
     *   - 'strict': confidence 무관 항상 사용자 확인 카드
     *
     * roundsLimit를 넘기면 더 묻지 않고 현재 contract로 카드(확인)만 띄움.
     */
    async _runIntentAlignment(opts: {
        userPrompt: string;
        previousContract?: import('./features/company').RequirementContract;
        previousAnswers?: Array<{ q: string; a: string }>;
        pipelineIdOverride?: string;
        mode: 'smart' | 'strict';
        roundsLimit: number;
        roundsAsked: number;
    }): Promise<void> {
        const cfg = getConfig();
        const state = readCompanyState(this._context);
        const activePipeline = resolveActivePipeline(state);

        // Pixel Office: 분석 시작 표시 (LLM 콜 직전).
        try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }

        // 모드 전환 직전 일반 채팅 요약 — Intent Alignment 가 *이미 논의된 맥락* 을
        // 재질문하지 않도록. 후속 라운드(previousContract 있음) 면 chatHistory 가
        // 이미 contract 에 흡수됐으므로 중복 첨부 안 함.
        let priorChatSummary: string | undefined;
        if (!opts.previousContract) {
            try {
                const history = this._agent.getHistory();
                const visible = history.filter((m) => !m.internal && (m.role === 'user' || m.role === 'assistant'));
                // 마지막 N=10 턴, content 200자 cap — 토큰 폭주 방지.
                const recent = visible.slice(-10);
                if (recent.length > 0) {
                    priorChatSummary = recent
                        .map((m) => `${m.role}: ${String(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`)
                        .join('\n');
                }
            } catch { /* history 못 가져와도 alignment 자체는 동작 */ }
        }

        // 현재 워크스페이스 아키텍처 컨텍스트 — "이 프로젝트가 뭐냐"류의 재질문 차단.
        // 첫 라운드만 (후속 라운드는 contract 에 이미 흡수). 분석기는 작은 모델이므로
        // 3,000자로 재절단 (architecture 원본 cap 은 16,000자).
        let projectContext: string | undefined;
        if (!opts.previousContract) {
            try {
                const arch = this._buildProjectArchitectureContext();
                if (arch) {
                    projectContext = arch.length > 3000
                        ? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
                        : arch;
                }
            } catch { /* architecture 없어도 alignment 는 계속 */ }
            // 사용자 프롬프트에 URL 이 있으면 본문도 컨텍스트에 — "그 사이트가
            // 뭐냐"는 재질문 차단. 결과는 5분 캐시되어 dispatch 와 중복 비용 없음.
            try {
                if (cfg.webAutoFetchEnabled !== false) {
                    const urls = extractUrls(opts.userPrompt, 2);
                    if (urls.length > 0) {
                        const webBlocks: string[] = [];
                        for (const url of urls) {
                            webBlocks.push(await buildUrlContext(url));
                        }
                        const web = webBlocks.join('\n\n');
                        const webCapped = web.length > 2000 ? web.slice(0, 2000) + '\n…(이하 생략)' : web;
                        projectContext = projectContext
                            ? `${projectContext}\n\n${webCapped}`
                            : webCapped;
                    }
                }
            } catch { /* URL 수집 실패해도 alignment 는 계속 */ }
        }

        const analysis = await analyzeIntent(
            new AIService(),
            {
                userOriginalPrompt: opts.userPrompt,
                previousAnswers: opts.previousAnswers,
                previousContract: opts.previousContract,
                activePipelineName: activePipeline?.name,
                availableRoleCategories: extractActiveRoleCategories(state),
                priorChatSummary,
                projectContext,
            },
            // 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
            { model: cfg.companyIntentClassifierModel || cfg.defaultModel },
        );

        let contract = analysis.contract;
        const reachedLimit = opts.roundsAsked >= opts.roundsLimit;

        // ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
        // 답을 찾은 질문은 answeredQuestions 로 이동(자가 조사 marker), 못 찾은
        // 것만 카드에 노출. 라운드 카운트는 소비하지 않는다 (사용자 응답이 아니므로).
        if (cfg.companyAlignmentSelfResearch !== false
            && contract.openQuestions.length > 0 && !reachedLimit) {
            try {
                const brain = getActiveBrainProfile();
                const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
                if (evidence.some((e) => e.excerpts.length > 0)) {
                    const answers = await selfAnswerQuestions(new AIService(), {
                        userPrompt: opts.userPrompt,
                        evidence,
                        model: cfg.companyIntentClassifierModel || cfg.defaultModel,
                    });
                    const solved = answers.filter((a) => a.answered && a.answer.trim());
                    if (solved.length > 0) {
                        const solvedSet = new Set(solved.map((s) => s.question));
                        // 비파괴적 갱신 — analysis.contract 원본은 건드리지 않는다.
                        contract = {
                            ...contract,
                            answeredQuestions: [
                                ...contract.answeredQuestions,
                                ...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })),
                            ],
                            openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
                        };
                        this._pixelOfficeBroadcast({
                            recentLogs: this._pixelOffice.appendLog(`🔎 자가 조사로 질문 ${solved.length}건 해결`),
                        });
                    }
                }
            } catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
        }

        if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
            // contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
            this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
            try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
            this._alignment.clear();
            this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
            await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
            return;
        }

        // 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나
        // openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만).
        const askMode: 'confirm' | 'questions' = reachedLimit || contract.openQuestions.length === 0
            ? 'confirm' : 'questions';
        this._view?.webview.postMessage(buildAlignmentAskPayload(askMode, contract, opts.roundsAsked, opts.roundsLimit));
        try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ }
        this._alignment.set({
            userOriginalPrompt: opts.userPrompt,
            contract,
            roundsAsked: opts.roundsAsked,
            pipelineIdOverride: opts.pipelineIdOverride,
        });
        // streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음.
        // 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는
        // dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push.
        this._postStreamEnd();
        void this._sendReadyStatus();
    }

    /**
     * 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출.
     * 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림.
     */
    async _handleAlignmentAnswer(userMessage: string): Promise<void> {
        const pending = this._alignment.get();
        if (!pending) return;
        const cfg = getConfig();
        const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart';
        const { ALIGNMENT_DEFAULT_MAX_ROUNDS } = await import('./features/company/intentAlignment');
        const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? ALIGNMENT_DEFAULT_MAX_ROUNDS));
        // 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로
        // 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가
        // 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서
        // 갱신한다.
        const compositeAnswer = userMessage.trim();
        const updatedAnswers = [
            ...pending.contract.answeredQuestions,
            { q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer },
        ];
        // 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 alignment 슬롯을
        // 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다.
        this._alignment.clear();
        await this._runIntentAlignment({
            userPrompt: pending.userOriginalPrompt,
            previousContract: pending.contract,
            previousAnswers: updatedAnswers,
            pipelineIdOverride: pending.pipelineIdOverride,
            mode,
            roundsLimit,
            roundsAsked: pending.roundsAsked + 1,
        });
    }

    /**
     * 사용자가 카드의 "✅ 진행" 버튼을 눌러 현 contract 그대로 dispatch
     * 시키고 싶을 때. 슬롯 비우고 pipeline.
     */
    async _proceedWithCurrentAlignment(): Promise<void> {
        const pending = this._alignment.consume();
        if (!pending) return;
        this._saveAlignmentKnowledgeIfAny(pending.userOriginalPrompt, pending.contract.answeredQuestions);
        await this._runCompanyTurn(
            pending.userOriginalPrompt,
            undefined,
            pending.pipelineIdOverride,
            pending.contract,
        );
    }

    /**
     * Alignment 학습 루프 (Phase 3) — 사용자가 직접 답해준 Q/A 를 두뇌에 저장.
     * 자가 조사 항목과 짧은 답의 필터링은 saveAlignmentKnowledge 내부에서 처리.
     * fire-and-forget: 저장 실패가 dispatch 를 막으면 안 된다.
     */
    private _saveAlignmentKnowledgeIfAny(userPrompt: string, qaList: Array<{ q: string; a: string }>): void {
        try {
            const cfg = getConfig();
            if (cfg.companyAlignmentKnowledgeSave === false) return;
            if (!qaList || qaList.length === 0) return;
            const brain = getActiveBrainProfile();
            const saved = saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
            if (saved) {
                this._pixelOfficeBroadcast({
                    recentLogs: this._pixelOffice.appendLog('💾 확인된 정보 두뇌 저장'),
                });
            }
        } catch { /* non-fatal */ }
    }

    /**
     * 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
     * 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
     */
    cancelPendingAlignment(): void {
        if (!this._alignment.isPending()) return;
        this._alignment.clear();
        this._view?.webview.postMessage(buildAlignmentCancelledPayload());
        this._postStreamEnd();
        try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ }
        void this._sendReadyStatus();
    }

    /**
     * Casual chat path inside 1인 기업 모드 — used when the intent classifier
     * routes a message to `chat` or `followup` instead of `new_task`. We
     * deliberately *don't* spin up the dispatcher here: that surface is for
     * multi-step work. Instead we route through the normal chat path (same
     * AgentExecutor.handlePrompt used outside company mode) so streaming UI,
     * brain retrieval, and the action-tag executor all work as users expect.
     *
     * The `reason` from the classifier is surfaced as a small label so the
     * user can tell *why* their message wasn't treated as a new task — if
     * they meant it as one, they can rephrase or override with a keyword
     * like "파이프라인 돌려" / "기획해줘".
     */
    async _handleCompanyCasual(prompt: string, intent: 'chat' | 'followup', reason: string, originalData: any): Promise<void> {
        // 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨.
        // 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다.
        const label = intent === 'followup' ? '💬 후속 대화' : '💬 대화';
        this._view?.webview.postMessage({
            type: 'companyIntentDecision',
            value: { intent, reason, label },
        });
        try {
            await this._handlePrompt(originalData);
            await this._autoWriteChronicleAfterPrompt();
        } finally {
            // Same guarantee as the normal chat path — a throw after the
            // answer streamed must not skip the 기록 save.
            await this._saveCurrentSession();
        }
    }

    /**
     * Called by chatHandlers when the user clicks an approval card button.
     * Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent
     * — extra clicks after the first one are silently dropped.
     */
    resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean {
        return this._approvalGates.resolve(stageId, decision);
    }

    /**
     * Push the full pipeline catalogue + active id to the webview so the
     * editor overlay can render the cards. Pipelines are user-defined
     * (no built-ins) so an empty list is the default for new users.
     */
    async _sendCompanyPipelines(): Promise<void> {
        if (!this._view) return;
        this._view.webview.postMessage(buildCompanyPipelinesPayload(readCompanyState(this._context)));
    }

    /** Send the chip state (active flag + agent count + name) to the webview. */
    async _sendCompanyStatus(): Promise<void> {
        if (!this._view) return;
        this._view.webview.postMessage(buildCompanyStatusPayload(readCompanyState(this._context)));
    }

    /**
     * Push the full agent catalogue when the manage panel opens. Each entry
     * carries both the *default* (from `agents.ts`) and *override* (from
     * globalState) fields so the UI can show the user what they've edited,
     * gray out unchanged fields, and offer a Reset button per agent.
     */
    async _sendCompanyAgents(): Promise<void> {
        if (!this._view) return;
        const state = readCompanyState(this._context);
        const globalWeight = getConfig().knowledgeMixSecondBrainWeight ?? 50;
        this._view.webview.postMessage(buildCompanyAgentsPayload(state, globalWeight));
    }

    /**
     * Drive one full company turn. Caller is the chat handler; it's already
     * persisted the user message and started a streaming bubble. We feed
     * progress events back as `companyTurnUpdate` messages so the same bubble
     * fills in as each agent finishes.
     */
    async _runCompanyTurn(
        userPrompt: string,
        resumeTimestamp?: string,
        pipelineIdOverride?: string,
        requirementContract?: import('./features/company').RequirementContract,
    ): Promise<void> {
        const ai = new AIService();
        // dispatcher 의 매 phase 이벤트를 받아 (1) webview 송신 (2) plan-ready/report-done
        // summary 캐시 기록 (다음 turn 의 intent classifier 가 follow-up 판정 시 사용)
        // (3) Pixel Office monitoring hook 으로 분기. mutable finalReport 는 turn 종료 후
        // emitter.getFinalReport() 로 회수해 기록 목록에 저장.
        const emitter = createCompanyTurnEmit({
            postWebview: (msg) => { this._view?.webview.postMessage(msg); },
            recordSummary: (brief, tail) => this._companyTurn.recordSummary(brief, tail),
            onPixelOffice: (event) => this.pixelOfficeOnTurnEvent(event),
        });
        // Fresh AbortController per turn — the Stop button routes through
        // `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
        // `signal.aborted` between phases and short-circuits cleanly.
        const abort = this._companyTurn.startTurn();
        try {
            // ── 자동 수집 컨텍스트 (architecture + URL pre-fetch) ──
            // 일반 챗은 둘 다 자동 주입받지만 기업 모드 dispatcher 는 빠져 있던 공백.
            // 실패해도 turn 은 진행 (try/catch).
            let architectureContextBlock: string | undefined;
            try {
                const arch = this._buildProjectArchitectureContext();
                if (arch) {
                    architectureContextBlock = arch.length > 6000
                        ? arch.slice(0, 6000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
                        : arch;
                }
            } catch { /* architecture 없어도 진행 */ }
            let webContextBlock: string | undefined;
            try {
                const cfgTurn = getConfig();
                if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
                    const urls = extractUrls(userPrompt, 2);
                    if (urls.length > 0) {
                        this._pixelOfficeBroadcast({
                            recentLogs: this._pixelOffice.appendLog(`🌐 URL ${urls.length}건 수집 중`),
                        });
                        const blocks: string[] = [];
                        for (const url of urls) {
                            blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
                        }
                        const joined = blocks.join('\n\n');
                        webContextBlock = joined.length > 8000 ? joined.slice(0, 8000) + '\n…(이하 생략)' : joined;
                    }
                }
            } catch { /* URL 수집 실패해도 진행 */ }

            const deps = buildDispatcherDeps({
                context: this._context,
                ai,
                signal: abort.signal,
                onEvent: emitter.emit,
                executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
                approvalGates: this._approvalGates,
                pipelineIdOverride,
                requirementContract,
                architectureContextBlock,
                webContextBlock,
            });
            // 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
            // 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
            // 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료.
            if (resumeTimestamp) {
                const result = await resumeCompanyTurn(resumeTimestamp, deps);
                if (!result) {
                    this._postError('재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).');
                }
            } else {
                await runCompanyTurn(userPrompt, deps);
            }
        } catch (e: any) {
            logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
            this._postError(`1인 기업 모드 실행 실패: ${e?.message ?? e}`);
        } finally {
            this._companyTurn.endTurn(abort);
            // The webview's send button is locked into the "generating" state
            // when the user submits; it only unlocks on `streamEnd`. The
            // normal chat path posts that from inside AgentExecutor, but
            // the company turn never touches AgentExecutor, so we have to
            // post it ourselves here — otherwise the input stays disabled
            // with the red Stop button after the round completes.
            this._postStreamEnd();
            void this._sendReadyStatus();
            // turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
            // 방금 abort된 세션이 곧장 목록에 떠야 하므로.
            void this._sendCompanyResumable();
            // 완료된 회사 turn을 채팅 기록(기록 목록)에도 한 줄로 남긴다 — 보고서가
            // 나온 경우(report-done)에만. abort돼서 보고서가 없으면 기록 목록은
            // 건드리지 않고 resume 목록에만 남는다.
            const finalReport = emitter.getFinalReport();
            if (finalReport) {
                void this._saveCompanyTurnSession(userPrompt, finalReport);
            }
        }
    }

    /**
     * Records a completed 1인 기업 turn as a standalone entry in the 기록(Chat
     * History) list. Company turns run through the dispatcher, not AgentExecutor,
     * so they never populate `_agent` history and `_saveCurrentSession()` can't
     * see them — without this they only ever appeared in the resume list.
     *
     * Each turn gets its own entry (it doesn't touch `_currentSessionId`): a
     * dispatch is a discrete unit of work, not a back-and-forth chat thread.
     */
    async _saveCompanyTurnSession(userPrompt: string, report: string) {
        try {
            const prompt = (userPrompt || '').trim();
            const body = (report || '').trim();
            if (!prompt && !body) return;

            const history: ChatMessage[] = [
                { role: 'user', content: prompt || '(요청 내용 없음)' },
                { role: 'assistant', content: body || '(보고서가 생성되지 않았습니다.)' },
            ];
            const sessions = this._sessionStore.getAll();
            sessions.unshift({
                id: Date.now().toString(),
                title: buildSessionTitleFromText(prompt, '1인 기업 업무'),
                timestamp: Date.now(),
                history,
                brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
                negativePrompt: this._currentNegativePrompt,
            });
            // ChatSessionStore.putAll() 가 50 cap 적용 — 여기서는 단순 위임.
            await this._sessionStore.putAll(sessions);
            await this._sendSessionList();
        } catch (err: any) {
            logError('Failed to record company turn into chat history.', { error: err?.message ?? String(err) });
        }
    }

    /**
     * Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이
     * 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함.
     */
    async _sendCompanyResumable(): Promise<void> {
        if (!this._view) return;
        try {
            const items = listResumableSessions(this._context);
            const state = readCompanyState(this._context);
            this._view.webview.postMessage(buildCompanyResumablePayload(items, state));
        } catch (e: any) {
            logError('company._sendCompanyResumable failed.', { error: e?.message ?? String(e) });
        }
    }

    /** Open the architecture doc in editor group 2. */
    async _openArchitectureDoc(): Promise<void> {
        const p = this._getActiveChronicleProject();
        if (!p || !p.architectureDocPath) return;
        try {
            const doc = await vscode.workspace.openTextDocument(p.architectureDocPath);
            await vscode.window.showTextDocument(doc, {
                viewColumn: vscode.ViewColumn.Two,
                preview: false,
            });
        } catch (e: any) {
            vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`);
        }
    }

    // Architecture watcher 메서드는 ArchitectureWatchManager (`this._archWatch`) 로 이관.

    _getActiveChronicleProject(): ProjectProfile | null {
        const projects = this._chronicleStore.getAll();
        if (projects.length === 0) return null;
        const activeId = this._context.globalState.get<string>(SidebarChatProvider.activeChronicleProjectStateKey, '');
        return projects.find(project => project.projectId === activeId) || projects[0];
    }

    async _sendChronicleProjects() {
        if (!this._view) return;
        const projects = this._chronicleStore.getAll();
        const active = this._getActiveChronicleProject();
        this._view.webview.postMessage(buildChronicleProjectsPayload(projects, active?.projectId || ''));
    }

    async _createChronicleProject() {
        const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
        const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project';

        const projectName = await vscode.window.showInputBox({
            prompt: 'Project name for Chronicle records',
            value: defaultName,
            validateInput: (value) => value.trim() ? null : 'Project name is required.'
        });
        if (!projectName) return;

        const description = await vscode.window.showInputBox({
            prompt: 'One-line project description',
            value: 'Project planning, decisions, development logs, and bug records.'
        });
        if (description === undefined) return;

        const projectRoot = await vscode.window.showInputBox({
            prompt: 'Project root path',
            value: workspaceRoot,
            validateInput: (value) => value.trim() ? null : 'Project root is required.'
        });
        if (!projectRoot) return;

        const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim());
        const recordRoot = await vscode.window.showInputBox({
            prompt: 'Markdown record folder path',
            value: defaultRecordRoot,
            validateInput: (value) => value.trim() ? null : 'Record folder path is required.'
        });
        if (!recordRoot) return;

        const corePurpose = await vscode.window.showInputBox({
            prompt: 'Core project purpose or guardrail',
            value: 'Keep project knowledge traceable through Markdown records.'
        });
        if (corePurpose === undefined) return;

        const detailChoice = await vscode.window.showQuickPick([
            { label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' },
            { label: 'simple', description: 'Request summary, decisions, and implementation result' },
            { label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' }
        ], {
            placeHolder: 'Chronicle record detail level'
        });
        if (!detailChoice) return;

        const now = new Date().toISOString();
        const projects = this._chronicleStore.getAll();
        const idBase = slugify(projectName.trim());
        let projectId = idBase;
        let suffix = 2;
        while (projects.some(project => project.projectId === projectId)) {
            projectId = `${idBase}-${suffix++}`;
        }

        const profile: ProjectProfile = {
            projectId,
            projectName: projectName.trim(),
            projectRoot: projectRoot.trim(),
            recordRoot: recordRoot.trim(),
            description: description.trim(),
            corePurpose: corePurpose.trim(),
            targetUsers: ['Project developer'],
            avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'],
            detailLevel: detailChoice.label as ProjectProfile['detailLevel'],
            createdAt: now,
            updatedAt: now
        };

        this._chronicle.ensureProject(profile);
        const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile];
        await this._chronicleStore.putAll(nextProjects);
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId);
        await this._sendChronicleProjects();
        this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``);
    }

    async _setActiveChronicleProject(projectId: string) {
        if (!projectId || projectId === 'new') {
            await this._createChronicleProject();
            return;
        }

        const target = this._chronicleStore.getAll().find(project => project.projectId === projectId);
        if (!target) return;
        await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId);
        await this._sendChronicleProjects();
        await this._sendChronicleRecords();
        this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``);
    }

    async _openChronicleFolder() {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('No Chronicle project is selected.');
            return;
        }

        try {
            this._chronicle.ensureProject(profile);
            await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot));
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`);
        }
    }

    async _sendChronicleRecords() {
        if (!this._view) return;
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            this._view.webview.postMessage(buildChronicleRecordsPayload([]));
            return;
        }

        try {
            const records = this._chronicle.listRecords(profile);
            this._view.webview.postMessage(buildChronicleRecordsPayload(records));
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`);
        }
    }

    async _openChronicleRecord(recordPath: string) {
        const profile = this._getActiveChronicleProject();
        if (!profile || !recordPath) {
            vscode.window.showWarningMessage('Select a Chronicle record first.');
            return;
        }

        const root = path.resolve(profile.recordRoot);
        const target = path.resolve(recordPath);
        if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') {
            vscode.window.showErrorMessage('Selected Chronicle record path is not valid.');
            return;
        }

        if (!fs.existsSync(target)) {
            vscode.window.showErrorMessage('Selected Chronicle record no longer exists.');
            await this._sendChronicleRecords();
            return;
        }

        await openInEditorGroup(target);
    }

    /**
     * Chronicle write 메서드 6개의 공통 entry guard. 활성 project 없으면 warning
     * + null 반환 → 호출자가 early return. 6 곳 × 4 줄 boilerplate dedup.
     */
    private _ensureActiveChronicleProfile(): ProjectProfile | null {
        const profile = this._getActiveChronicleProject();
        if (!profile) {
            vscode.window.showWarningMessage('Select or create a Designer project first.');
            return null;
        }
        return profile;
    }

    /**
     * Chronicle write 메서드 6개의 공통 post-write 시퀀스:
     *   1) 타임라인에 record 생성 이벤트 한 줄 append
     *   2) toast 알림 + webview 의 records list 갱신
     *   3) chat 에 system message 로 저장 경로 노출
     * `label` 은 한 단어 ('planning', 'discussion', 'decision', 'development',
     * 'bug', 'retrospective') — 인간 친화 toast/badge 문구는 `display` 가 결정.
     */
    private async _commitChronicleWrite(
        profile: ProjectProfile,
        label: string,
        display: string,
        result: { relativePath: string; filePath: string },
        createdAt: string,
    ): Promise<void> {
        this._chronicle.appendTimeline(profile, [`${display} record created: ${result.relativePath}`], createdAt);
        vscode.window.showInformationMessage(`Chronicle ${label} saved: ${result.relativePath}`);
        await this._sendChronicleRecords();
        this.injectSystemMessage(`**[Chronicle ${display} Saved]** \`${result.filePath}\``);
    }

    /**
     * 현재 chat history 의 *가장 최근* user / assistant 메시지 한 쌍. 4 개 write
     * 메서드가 같은 패턴으로 호출하던 걸 dedup.
     */
    private _latestUserAssistantPair(): { latestUser: string; latestAssistant: string } {
        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        return { latestUser, latestAssistant };
    }

    async _writeChroniclePlanningFromCurrentChat() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const { latestUser, latestAssistant } = this._latestUserAssistantPair();
        const featureName = await vscode.window.showInputBox({
            prompt: 'Feature name for the planning document',
            value: summarizeForTitle(latestUser || 'Project Chronicle Feature')
        });
        if (!featureName) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writePlanning(profile, {
                featureName: featureName.trim(),
                purpose: 'Record the reason, scope, direction, and success criteria before implementation.',
                background: summarizeTextForWiki(latestAssistant || latestUser),
                userIntent: summarizeTextForWiki(latestUser),
                sourceRequest: latestUser || 'No user request captured in the current chat.',
                scope: [
                    'Create a project-specific planning record.',
                    'Capture user intent and implementation direction.',
                    'Keep the record independent from chat execution internals.'
                ],
                outOfScope: [
                    'Full automatic transcript capture.',
                    'External database integration.',
                    'Git automation.'
                ],
                developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.',
                dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.',
                expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.',
                successCriteria: [
                    'The planning document is created under the selected project record folder.',
                    'The document includes user intent, scope, out-of-scope items, and success criteria.'
                ],
                developerInstruction: 'Use this document as the implementation guardrail for the next development step.',
                createdAt
            });
            await this._commitChronicleWrite(profile, 'planning', 'Planning', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
        }
    }

    async _writeChronicleDiscussionFromCurrentChat() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const { latestUser, latestAssistant } = this._latestUserAssistantPair();
        const title = await vscode.window.showInputBox({
            prompt: 'Discussion title',
            value: summarizeForTitle(latestUser || 'Project Discussion')
        });
        if (!title) return;

        const question = await vscode.window.showInputBox({
            prompt: 'AI question to record (optional)',
            value: ''
        });
        if (question === undefined) return;

        let questions: any[] = [];
        if (question.trim()) {
            const reason = await vscode.window.showInputBox({
                prompt: 'Why was this question asked?',
                value: 'To avoid writing records to the wrong project or making an unclear design decision.'
            });
            if (reason === undefined) return;

            const impact = await vscode.window.showInputBox({
                prompt: 'How does this question affect the decision?',
                value: 'It determines the correct project context, scope, or implementation path.'
            });
            if (impact === undefined) return;

            questions = [{
                question: question.trim(),
                reason: reason.trim(),
                expectedInformation: 'Information needed to clarify project context, scope, or decision direction.',
                impactOnDecision: impact.trim()
            }];
        }

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeDiscussion(profile, {
                title: title.trim(),
                userRequest: summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
                interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.',
                questions,
                discussions: [
                    summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
                ],
                decisions: [],
                createdAt
            });
            await this._commitChronicleWrite(profile, 'discussion', 'Discussion', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
        }
    }

    async _writeChronicleDecisionFromInput() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const title = await vscode.window.showInputBox({
            prompt: 'Decision title',
            value: 'Use independent Markdown record module'
        });
        if (!title) return;

        const decision = await vscode.window.showInputBox({
            prompt: 'Decision',
            value: 'Implement this behavior as an independent Project Chronicle module.'
        });
        if (decision === undefined) return;

        const reason = await vscode.window.showInputBox({
            prompt: 'Decision reason',
            value: 'To reduce coupling and keep project records portable.'
        });
        if (reason === undefined) return;

        const alternatives = await vscode.window.showInputBox({
            prompt: 'Rejected alternatives (comma-separated)',
            value: 'Integrate with Second Brain, integrate directly into Agent execution'
        });
        if (alternatives === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const adrNumber = this._chronicle.nextAdrNumber(profile);
            const result = this._chronicle.writeDecision(profile, {
                title: title.trim(),
                status: 'accepted',
                context: 'A project record needs to capture not only what changed, but why the direction was chosen.',
                decision: decision.trim(),
                reason: reason.trim(),
                alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean),
                consequences: [
                    'Records can evolve independently from chat and agent internals.',
                    'Future automation can emit chronicle events without owning the core execution path.'
                ],
                createdAt
            }, adrNumber);
            await this._commitChronicleWrite(profile, 'decision', 'Decision', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
        }
    }

    async _writeChronicleDevelopmentFromCurrentChat() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const { latestUser, latestAssistant } = this._latestUserAssistantPair();
        const featureName = await vscode.window.showInputBox({
            prompt: 'Feature name for the development log',
            value: summarizeForTitle(latestUser || 'Implementation Log')
        });
        if (!featureName) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeDevelopmentLog(profile, {
                featureName: featureName.trim(),
                purpose: 'Record the actual implementation outcome for later maintenance.',
                implementationSummary: summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'),
                architecture: 'Project Chronicle records are written through an independent Markdown module.',
                changedFiles: ['Capture exact changed files after verification.'],
                dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.',
                bugs: [],
                lessons: [
                    'Write implementation notes as soon as a stable development step finishes.',
                    'Keep generated records project-specific.'
                ],
                createdAt
            });
            await this._commitChronicleWrite(profile, 'development log', 'Development', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
        }
    }

    async _writeChronicleBugFromInput() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const title = await vscode.window.showInputBox({
            prompt: 'Bug title',
            value: 'record-generation-issue'
        });
        if (!title) return;

        const symptom = await vscode.window.showInputBox({
            prompt: 'Bug symptom',
            value: 'Describe what failed or looked wrong.'
        });
        if (symptom === undefined) return;

        const cause = await vscode.window.showInputBox({
            prompt: 'Bug cause',
            value: 'Cause is not confirmed yet.'
        });
        if (cause === undefined) return;

        const fix = await vscode.window.showInputBox({
            prompt: 'Fix',
            value: 'Describe the fix or mitigation.'
        });
        if (fix === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const bugNumber = this._chronicle.nextBugNumber(profile);
            const result = this._chronicle.writeBug(profile, {
                title: title.trim(),
                symptom: symptom.trim(),
                cause: cause.trim(),
                fix: fix.trim(),
                prevention: 'Validate project selection, record path, and write permissions before generating files.',
                createdAt
            }, bugNumber);
            await this._commitChronicleWrite(profile, 'bug record', 'Bug', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
        }
    }

    async _writeChronicleRetrospectiveFromInput() {
        const profile = this._ensureActiveChronicleProfile();
        if (!profile) return;

        const title = await vscode.window.showInputBox({
            prompt: 'Retrospective title',
            value: 'Project Chronicle Guard iteration'
        });
        if (!title) return;

        const summary = await vscode.window.showInputBox({
            prompt: 'Work summary',
            value: 'Completed an incremental development step and recorded the outcome.'
        });
        if (summary === undefined) return;

        const wentWell = await vscode.window.showInputBox({
            prompt: 'What went well? (comma-separated)',
            value: 'Kept the feature independent, Generated Markdown records, Preserved project context'
        });
        if (wentWell === undefined) return;

        const toImprove = await vscode.window.showInputBox({
            prompt: 'What should improve? (comma-separated)',
            value: 'More automatic question intent capture, Richer record editing UI'
        });
        if (toImprove === undefined) return;

        const nextActions = await vscode.window.showInputBox({
            prompt: 'Next actions (comma-separated)',
            value: 'Add tests, Improve Designer UI, Add event-based record capture'
        });
        if (nextActions === undefined) return;

        try {
            const createdAt = new Date().toISOString();
            const result = this._chronicle.writeRetrospective(profile, {
                title: title.trim(),
                summary: summary.trim(),
                wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean),
                toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean),
                nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean),
                createdAt
            });
            await this._commitChronicleWrite(profile, 'retrospective', 'Retrospective', result, createdAt);
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
        }
    }

    /**
     * v2.2.70 — 도구 드롭다운의 "자동 기록" 토글에서 호출. config 를 즉시 갱신하고 webview 에
     * 새 상태를 푸시. globalState 갱신이 아닌 vscode 설정 갱신이므로 다음 세션까지 영구 유지.
     */
    async _setChronicleAutoRecord(enabled: boolean): Promise<void> {
        try {
            await vscode.workspace.getConfiguration('g1nation').update(
                'chronicleAutoRecord', !!enabled, vscode.ConfigurationTarget.Global
            );
            logInfo(`[chronicleAutoRecord] toggled → ${enabled ? 'ON' : 'OFF'}`);
        } catch (e: any) {
            logError('[chronicleAutoRecord] update failed', { error: e?.message || String(e) });
        }
        await this._sendChronicleAutoRecordStatus();
    }

    /** Send current 자동 기록 enabled flag to the webview so the Tools menu can render the toggle state. */
    async _sendChronicleAutoRecordStatus(): Promise<void> {
        if (!this._view) return;
        this._view.webview.postMessage({
            type: 'chronicleAutoRecordStatus',
            value: { enabled: getConfig().chronicleAutoRecord !== false }
        });
    }

    async _autoWriteChronicleAfterPrompt() {
        // v2.2.70 — 자동 기록 OFF (g1nation.chronicleAutoRecord=false) 면 즉시 종료.
        // 수동 기록 (도구 메뉴, /wiki 명령 등) 은 영향받지 않는다.
        if (getConfig().chronicleAutoRecord === false) {
            return;
        }
        const history = this._agent.getHistory();
        const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
        const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
        const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject();
        if (!profile) return;

        const recordType = inferAutoChronicleRecordType(latestUser, latestAssistant);
        if (!recordType) return;

        const signature = [
            profile.projectId,
            recordType,
            summarizeTextForWiki(latestUser).slice(0, 240),
            summarizeTextForWiki(latestAssistant).slice(0, 240)
        ].join('|');
        const lastSignature = this._context.globalState.get<string>(SidebarChatProvider.lastAutoChronicleSignatureStateKey, '');
        if (signature === lastSignature) return;

        try {
            this._chronicle.ensureProject(profile);
            const createdAt = new Date().toISOString();
            const title = summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record');
            const summary = summarizeTextForWiki(latestAssistant || latestUser);
            let result;

            if (recordType === 'bug') {
                const bugNumber = this._chronicle.nextBugNumber(profile);
                result = this._chronicle.writeBug(profile, buildAutoBugRecord(title, summary, latestUser, createdAt), bugNumber);
            } else if (recordType === 'planning') {
                result = this._chronicle.writePlanning(profile, buildAutoPlanningRecord(title, summary, latestUser, createdAt));
            } else if (recordType === 'decision') {
                const adrNumber = this._chronicle.nextAdrNumber(profile);
                result = this._chronicle.writeDecision(profile, buildAutoDecisionRecord(title, summary, latestUser, createdAt), adrNumber);
            } else if (recordType === 'development') {
                result = this._chronicle.writeDevelopmentLog(profile, buildAutoDevelopmentRecord(title, summary, latestAssistant, createdAt));
            } else {
                result = this._chronicle.writeDiscussion(profile, buildAutoDiscussionRecord(title, summary, latestUser, createdAt));
            }

            this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt);
            await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature);
            await this._sendChronicleRecords();
            vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500);
        } catch (err: any) {
            logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType });
        }
    }

    _getChronicleProjectForConversation(text: string): ProjectProfile | null {
        const projectPath = extractLocalProjectPath(text);
        if (!projectPath) return null;

        const projects = this._chronicleStore.getAll();
        const resolvedPath = path.resolve(projectPath);
        const existing = projects.find(project => {
            const root = project.projectRoot ? path.resolve(project.projectRoot) : '';
            const recordRoot = path.resolve(project.recordRoot);
            return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`);
        });
        if (existing) return existing;

        const projectName = path.basename(resolvedPath) || 'Current Project';
        const now = new Date().toISOString();
        return {
            projectId: slugify(projectName),
            projectName,
            projectRoot: resolvedPath,
            recordRoot: path.join(resolvedPath, 'docs', 'records', projectName),
            description: 'Auto-detected from the local project path in the conversation.',
            corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.',
            targetUsers: ['Project developer'],
            avoidDirections: ['Do not mix records across projects.'],
            detailLevel: 'standard',
            createdAt: now,
            updatedAt: now
        };
    }

    // _extractLocalProjectPath / _inferAutoChronicleRecordType / _extractChangedFilesFromText
    //   → `src/sidebar/builders/autoChronicleClassifier.ts`

    async _writeChronicleRecord(recordType: string) {
        switch (recordType) {
            case 'planning':
                await this._writeChroniclePlanningFromCurrentChat();
                break;
            case 'discussion':
                await this._writeChronicleDiscussionFromCurrentChat();
                break;
            case 'decision':
                await this._writeChronicleDecisionFromInput();
                break;
            case 'development':
                await this._writeChronicleDevelopmentFromCurrentChat();
                break;
            case 'bug':
                await this._writeChronicleBugFromInput();
                break;
            case 'retrospective':
                await this._writeChronicleRetrospectiveFromInput();
                break;
            default:
                vscode.window.showWarningMessage('Select a Chronicle record type first.');
        }
    }

    // _getAgentsDir → `this._agentSkillStore.getDir()`
    async _sendAgentsList() {
        if (!this._view) return;
        const agents = this._agentSkillStore.list();
        const lastPath = this._agentSkillStore.getLastSelectedPath();
        this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
        void this._sendReadyStatus();
    }

    async _handleProactiveSuggestion(context: string) {
        if (!this._view) return;
        this._streamSystemNotice(`\n\n> [!TIP]\n> ${proactiveSuggestionFor(context)}\n`);
    }

    async _createAgent() {
        const name = await vscode.window.showInputBox({
            prompt: 'Name of the new Agent (e.g., frontend_expert)',
            placeHolder: 'Agent name...'
        });
        if (!name) return;

        const filePath = this._agentSkillStore.createNew(name);
        if (!filePath) {
            vscode.window.showErrorMessage('Agent directory could not be determined.');
            return;
        }

        await openInEditorGroup(filePath);
        await this._sendAgentsList();
    }

    async _sendAgentContent(agentPath: string) {
        if (!this._view || !agentPath || agentPath === 'none') return;
        const content = this._agentSkillStore.readContent(agentPath);
        if (content === null) return;
        const negativePrompt = this._agentSkillStore.getNegativePrompt(agentPath);
        this._view.webview.postMessage({ type: 'agentContent', value: content, negativePrompt });
        await this._agentSkillStore.setLastSelectedPath(agentPath);
    }

    async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
        if (!agentPath || agentPath === 'none') return;
        try {
            this._agentSkillStore.writeContent(agentPath, content);
            if (negativePrompt !== undefined) {
                await this._agentSkillStore.setNegativePrompt(agentPath, negativePrompt);
            }
            vscode.window.showInformationMessage('Agent skill updated successfully.');
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`);
        }
    }

    async _deleteAgent(agentPath: string) {
        if (!agentPath || agentPath === 'none') return;

        try {
            // delete() 가 invalid/missing/deleted 를 구분하지만, missing 케이스는
            // confirm 없이 lastSelected 정리만 해야 하므로 분기를 provider 가 유지.
            const agentsDir = path.resolve(this._agentSkillStore.getDir());
            const targetPath = path.resolve(agentPath);
            if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
                vscode.window.showErrorMessage('Selected agent skill path is not valid.');
                return;
            }

            if (!fs.existsSync(targetPath)) {
                await this._agentSkillStore.setLastSelectedPath('none');
                await this._sendAgentsList();
                return;
            }

            const confirm = await vscode.window.showWarningMessage(
                `Delete agent skill "${path.basename(targetPath, '.md')}"?`,
                { modal: true },
                'Delete Skill'
            );
            if (confirm !== 'Delete Skill') return;

            this._agentSkillStore.delete(targetPath);
            await this._agentSkillStore.setLastSelectedPath('none');
            await this._sendAgentsList();
            this._view?.webview.postMessage({ type: 'agentDeleted' });
            vscode.window.showInformationMessage('Agent skill deleted successfully.');
        } catch (err: any) {
            vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`);
        }
    }

    async _handlePrompt(data: any) {
        if (!this._view) return;

        const { value, model, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
        this._currentNegativePrompt = negativePrompt || '';
        const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
            ? brainProfileId
            : getActiveBrainProfile().id;
        this._currentSessionBrainId = selectedBrainId;
        
        // Agent skill 본문 + per-agent model override 해석. notifications 는 placeholder
        // 경고/모델 override 안내를 webview 가 적절한 메시지로 띄울 수 있게 따로 반환.
        const skill = resolveAgentSkill(agentFile, typeof model === 'string' ? model : '');
        const agentSkillContext = skill.agentSkillContext;
        const effectiveModel = skill.effectiveModel;
        for (const note of skill.notifications) {
            if (note.type === 'placeholder') {
                this._view?.webview.postMessage({
                    type: 'lmStudioError',
                    value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)',
                });
            } else if (note.type === 'modelOverride') {
                this._view?.webview.postMessage({
                    type: 'agentModelOverride',
                    value: { agent: note.agent, model: note.model },
                });
            }
        }

        const designerContext = designerGuard !== false
            ? this._buildDesignerGuardContext(typeof value === 'string' ? value : null)
            : undefined;

        // Project Architecture activation (Feature 2): if the user just said
        // "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
        // to that project's mode before assembling the prompt. Best-effort:
        // failures here never block the actual answer.
        if (typeof value === 'string' && value.trim().length > 0) {
            try {
                await this._tryActivateArchitectureFromText(value);
            } catch (e: any) {
                logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
            }
        }
        // Re-resolve the active subproject from the currently-focused editor.
        // Without this, switching between subprojects (e.g. ConnectAI →
        // Datacollector) inside one VS Code window keeps loading the previous
        // subproject's architecture into the prompt.
        try {
            await this._ensureActiveProjectForWorkspace();
        } catch (e: any) {
            logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) });
        }
        const projectArchitectureContext = this._buildProjectArchitectureContext();

        // 첨부 파일 분류/추출 — PDF text+vision fallback, text decode, image passthrough.
        let processedPrompt = value || '';
        let imageFiles: any[] | undefined = undefined;
        const attachments = await processAttachments(files);
        if (attachments.textContents.length > 0) {
            processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${attachments.textContents.join('\n')}`;
        }
        if (attachments.imageFiles.length > 0) {
            imageFiles = attachments.imageFiles;
        }

        try {
            await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
                visionContent: imageFiles,
                agentSkillContext,
                agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
                negativePrompt,
                designerContext,
                projectArchitectureContext: projectArchitectureContext || undefined,
                secondBrainTraceEnabled: secondBrainTrace !== false,
                secondBrainTraceDebug: !!secondBrainTraceDebug,
                brainProfileId: selectedBrainId
            });
        } catch (error: any) {
            logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
            this._postError(error.message);
        } finally {
            void this._sendReadyStatus();
        }
    }

    _buildDesignerGuardContext(prompt: string | null = null): string {
        const suppressTemplate = isThinFollowUp(prompt, this._agent?.getHistory() ?? []);
        return buildProjectChronicleGuardContext(this._getActiveChronicleProject(), { suppressTemplate });
    }

    async _sendModels(force: boolean = false) {
        if (!this._view) return;
        if (!this._modelDiscovery.tryEnter()) {
            logInfo('Model discovery already in progress, skipping.');
            return;
        }
        try {
            const config = getConfig();
            const url = config.ollamaUrl;
            let defaultModel = config.defaultModel;
            let models: string[] = [];
            let online = false;

            const cached = this._modelDiscovery.getCached(url, force);
            if (cached) {
                models = cached.models;
                online = cached.online;
                this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
            } else {
                const engine = resolveEngine(url);
                const modelsUrl = buildApiUrl(url, engine, 'models');
                const result = await this._modelDiscovery.discoverFromEngine({
                    url, engine, modelsUrl, lmStudio: this._lmStudio, force,
                });
                models = result.models;
                online = result.online;
                this._modelDiscovery.setCached(url, models, online);
                this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
            }

            if (models.length === 0) {
                models = defaultModel ? [defaultModel] : [];
            }

            const baseModel = defaultModel?.replace(/:\d+$/, '');
            if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) {
                defaultModel = baseModel;
                await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
            }

            if (models.length > 0 && !defaultModel) {
                // [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정
                defaultModel = models[0];
                await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
            } else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) {
                // [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우:
                // 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존
                logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) });
                models.unshift(defaultModel);
            }

            const defaultIdx = models.indexOf(defaultModel);
            if (defaultIdx > 0) {
                models.splice(defaultIdx, 1);
                models.unshift(defaultModel);
            }

            let loadedModels: string[] = [];
            if (resolveEngine(url) === 'lmstudio' && this._lmStudio) {
                try {
                    loadedModels = await this._lmStudio.loadedModels();
                } catch (e) {
                    logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
                }
            }

            // Cloud provider 모델 합치기 — 활성화된 provider 의 모델들이 dropdown 끝에 추가됨.
            // 각 모델 id 는 'openrouter:...' 등 prefix 포함이라 사용자가 선택 시 agent 가 cloud 로 라우팅.
            try {
                const { listAllCloudModels } = await import('./features/providers');
                const cloudModels = await listAllCloudModels(this._context);
                for (const m of cloudModels) {
                    if (!models.includes(m.id)) models.push(m.id);
                }
            } catch (e) {
                logInfo('Cloud model list failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
            }

            this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
        } catch (err) {
            logError('Model list update failed.', err);
        } finally {
            this._modelDiscovery.release();
        }
        void this._sendReadyStatus();
    }

    static _htmlTemplateCache: string | undefined;

    _getHtml(webview: vscode.Webview): string {
        if (!SidebarChatProvider._htmlTemplateCache) {
            const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html');
            SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8');
        }
        const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media');
        const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString();
        const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString();
        // VS Code의 outer webview iframe이 codicon.ttf를 data:font/ttf 로 inject한다.
        // 기본 CSP는 font-src 'self' https://*.vscode-cdn.net 라 data: 가 빠져 있어
        // DevTools에 violation 경고가 매번 찍힘. 우리가 명시적 CSP를 박아 data: 를
        // 허용해 주면 호스트 iframe도 같은 CSP를 상속하면서 경고가 사라진다.
        const csp = [
            `default-src 'none'`,
            `img-src ${webview.cspSource} https: data:`,
            `style-src ${webview.cspSource} 'unsafe-inline'`,
            `script-src ${webview.cspSource} https://cdn.jsdelivr.net 'unsafe-inline'`,
            `font-src ${webview.cspSource} https: data:`,
            `connect-src ${webview.cspSource} https:`,
        ].join('; ');
        return SidebarChatProvider._htmlTemplateCache
            .replace('__CSP__', csp)
            .replace('__STYLES_URI__', stylesUri)
            .replace('__SCRIPT_URI__', scriptUri);
    }
}

/**
 * Adapter that makes a {@link vscode.WebviewPanel} quack like a
 * {@link vscode.WebviewView}, so providers written against the view API can
 * mount inside an editor column without their internals knowing the difference.
 *
 * `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels
 * fire that event for both visibility *and* column moves, but the listener
 * here only re-fires when the visible flag actually toggles.
 */
export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView {
    const visibilityEmitter = new vscode.EventEmitter<void>();
    let _lastVisible = panel.visible;
    panel.onDidChangeViewState(() => {
        if (panel.visible !== _lastVisible) {
            _lastVisible = panel.visible;
            visibilityEmitter.fire();
        }
    });
    panel.onDidDispose(() => visibilityEmitter.dispose());
    const adapter: any = {
        viewType: panel.viewType,
        webview: panel.webview,
        get visible() { return panel.visible; },
        get title() { return panel.title; },
        set title(v: string | undefined) { panel.title = v ?? ''; },
        description: undefined as string | undefined,
        badge: undefined as vscode.ViewBadge | undefined,
        onDidChangeVisibility: visibilityEmitter.event,
        onDidDispose: panel.onDidDispose,
        show(preserveFocus?: boolean) {
            panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus);
        },
    };
    return adapter as vscode.WebviewView;
}

// Astra Office full-panel HTML 은 src/features/astraOffice/ 로 이전됨 (refactor #1).
// _buildPixelOfficeHtml() 가 renderAstraOfficePanelHtml() 을 직접 호출한다.
