release: v2.0.6 - Intelligence & UX Optimization (2026-05-14)

This commit is contained in:
g1nation
2026-05-14 00:13:54 +09:00
parent 39386f90b5
commit f1d5dbf031
18 changed files with 592 additions and 59 deletions
+27 -3
View File
@@ -306,9 +306,24 @@ export async function activate(context: vscode.ExtensionContext) {
}
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock);
const userMessage = contextBlock
? `[SECOND BRAIN CONTEXT]\n${contextBlock}\n\n[USER MESSAGE]\n${text}`
: text;
// Per-chat conversation history — without this every inbound
// is a fresh turn, so the user "tells the bot something" and
// it gets immediately forgotten. We inline the recent N
// exchanges into the user message because the AI service's
// {system, user} surface doesn't carry a messages array.
const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } =
await import('./integrations/telegram/conversationHistory');
const history = getRecentMessages(chatId, 10);
const historyBlock = formatHistoryForPrompt(history);
const pieces: string[] = [];
if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`);
if (historyBlock) pieces.push(historyBlock);
pieces.push(`[USER MESSAGE]\n${text}`);
const userMessage = pieces.join('\n\n');
// Persist the user's message *before* the AI call so failures
// still leave a trail (next inbound will see what they said).
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
try {
const result = await telegramAi.chat({ system: systemPrompt, user: userMessage });
@@ -330,6 +345,10 @@ export async function activate(context: vscode.ExtensionContext) {
].join('\n');
}
// Persist the assistant's reply so the *next* inbound sees
// what we just said. Without this, the bot would forget its
// own answer the moment the user follows up.
appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' });
// Telegram has a hard 4096 char/message limit. Long replies are
// chunked and joined with a "(이어서)" hint so the user knows
// multiple messages belong together.
@@ -446,6 +465,11 @@ export async function activate(context: vscode.ExtensionContext) {
await provider._detachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned off.');
}),
vscode.commands.registerCommand('g1nation.architecture.attach', async () => {
if (!provider) return;
await provider._attachArchitecture();
vscode.window.showInformationMessage('Astra: Project architecture auto-attach turned on.');
}),
vscode.commands.registerCommand('g1nation.architecture.open', async () => {
if (!provider) return;
await provider._openArchitectureDoc();
+45 -2
View File
@@ -284,7 +284,8 @@ async function _dispatchOne(
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
// appended to the response so the user sees what really happened.
let finalResponse = rawResponse || '_(empty response)_';
if (rawResponse && deps.executeActionTags && _hasActionTag(rawResponse)) {
const hasTag = !!rawResponse && _hasActionTag(rawResponse);
if (rawResponse && deps.executeActionTags && hasTag) {
try {
const report = await deps.executeActionTags(rawResponse);
if (report.length > 0) {
@@ -297,12 +298,30 @@ async function _dispatchOne(
logError('company.dispatcher: action-tag execution failed.', { agentId, err });
finalResponse = `${rawResponse}\n\n---\n⚠️ Action 실행 실패: ${err}`;
}
} else if (rawResponse && !hasTag && _claimsFileCreation(rawResponse)) {
// Hallucination guard: small models love to *narrate* file
// creation ("foo.py를 생성했습니다 …") without emitting the
// <create_file> tag — so the user sees ✅ in chat but nothing
// on disk. Catch the mismatch here and flag it loudly so the
// CEO synthesis (which reads this response) and the user both
// know nothing was actually written.
const warning = '⚠️ **실제 파일이 생성되지 않았습니다.** Agent가 파일 생성을 텍스트로 설명했지만 ConnectAI 액션 태그(`<create_file>` 등)를 사용하지 않아 디스크에 아무것도 만들어지지 않았어요. 같은 요청을 다시 시도하거나, 사용자가 직접 만드세요.';
finalResponse = `${rawResponse}\n\n---\n${warning}`;
logInfo('company.dispatcher: agent claimed creation without action tag.', { agentId });
}
// `error: 'no-action-tag-but-claimed'` is *advisory* — we still let
// the turn complete because some agents (Writer, Researcher) are
// legitimately answer-only. But by flagging the agent output we
// mark it as not-fully-successful so the CEO synthesis can read
// the warning verbatim.
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
return {
agentId, task,
response: finalResponse,
durationMs: Date.now() - startedAt,
error: rawResponse ? undefined : 'empty-response',
error: rawResponse
? (claimedButDidnt ? 'claimed-creation-no-tag' : undefined)
: 'empty-response',
};
} catch (e: any) {
const err = e?.message ?? String(e);
@@ -325,3 +344,27 @@ async function _dispatchOne(
function _hasActionTag(text: string): boolean {
return /<\s*(?:create_file|edit_file|delete_file|read_file|list_files|list_brain|run_command|read_brain|reveal_in_explorer|open_file|glob|grep)\b/i.test(text);
}
/**
* Heuristic: does the response *narrate* having created files/folders?
*
* We look for the combination of (a) a Korean / English creation verb and
* (b) a filename-like or "folder" mention. The intent is to catch the
* hallucination pattern where an agent writes "foo.py 파일을 생성했습니다"
* or "Created `bar/` directory" without emitting the corresponding
* `<create_file>` tag, so the dispatcher can flag it back to the CEO and
* the user instead of silently reporting success.
*
* Kept narrow on purpose — a *plan* like "다음에는 X를 만들어야 합니다"
* shouldn't trigger this. We require past-tense / completion phrasing.
*/
function _claimsFileCreation(text: string): boolean {
// Past-tense creation verbs (Korean + English).
const claimRe = /(?:생성했|만들었|작성했|저장했|구현했|created|wrote|saved|built|generated)/i;
if (!claimRe.test(text)) return false;
// Combined with either an explicit filename (something.ext) or the word
// "폴더" / "directory" / "folder" near the verb.
const fileLike = /\b[\w\-./]+\.(?:py|js|ts|tsx|jsx|md|json|html|css|sh|yaml|yml|sql|java|go|rs|c|cpp|rb|php)\b/i.test(text);
const folderLike = /(?:폴더|디렉토리|directory|folder)/i.test(text);
return fileLike || folderLike;
}
+32 -8
View File
@@ -77,15 +77,39 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push('- 추측·일반론·placeholder 금지. 가진 정보만 인용.');
// ── Tool contract ──
// ConnectAI's existing AgentExecutor parses these tags automatically
// after the streaming response completes. Keeping the syntax identical
// means specialists can write files / run commands the same way the
// base chat already does — no new plumbing on the agent side.
// Hard rule about action tags. Earlier wording ("태그 없이 평문으로만
// 답해도 됩니다") let small models (gemma 4 e2b etc.) emit ```python
// code blocks and then *claim* to have created files — the user got
// ✅ in chat but nothing on disk. This block is now phrased so the
// model cannot rationalise its way out of the tag contract.
parts.push('');
parts.push('## 도구 사용 규칙 (필요할 때만)');
parts.push('실제 파일 생성·명령 실행이 필요하면 ConnectAI의 액션 태그를 사용하세요.');
parts.push('예) `<create_file path="...">내용</create_file>`, `<run_command>npm test</run_command>` 등.');
parts.push('태그 없이 평문으로만 답해도 됩니다 — 기획·분석·아이디어 작업은 보통 태그가 필요 없습니다.');
parts.push('## ⚠️ 실제 파일·명령 실행 (이 섹션 매우 중요)');
parts.push('당신은 사용자의 **실제** 파일 시스템과 터미널에 직접 연결되어 있습니다.');
parts.push('**텍스트로 "만들었다 / 작성했다 / 생성했다 / 저장했다" 라고 말해도 사용자 디스크엔 아무 일도 안 일어납니다.**');
parts.push('파일을 만들거나 명령을 실행하려면 **반드시** 아래 액션 태그로 감싸세요. 시스템이 자동으로 디스크에 적용합니다:');
parts.push('');
parts.push(' • `<create_file path="...">내용</create_file>` — 새 파일 생성·덮어쓰기');
parts.push(' • `<edit_file path="..."><find>옛 내용</find><replace>새 내용</replace></edit_file>` — 부분 편집');
parts.push(' • `<read_file path="..."/>` — 32KB까지 읽기 (편집 전엔 반드시 먼저 read)');
parts.push(' • `<delete_file path="..."/>` — 파일·디렉토리 삭제');
parts.push(' • `<list_files path="..."/>` — 디렉토리 목록 보기');
parts.push(' • `<run_command>명령</run_command>` — 셸 실행 (디렉토리 생성 등)');
parts.push('');
parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:');
parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`');
parts.push('- 절대 경로 가능하지만 **반드시 워크스페이스 내부**여야 함. `/antigravity/...` `/tmp/...` 같은 시스템 루트 경로는 거부됨.');
parts.push('- 디렉토리는 `<create_file>`이 자동으로 만들어줍니다 (mkdir -p). 별도 명령 불필요.');
parts.push('');
parts.push('❌ **하지 말아야 할 패턴**:');
parts.push(' - ```python\\nprint("...")\\n``` 코드 블록만 답하고 "생성 완료"라고 말하기 → 디스크엔 만들어지지 않음');
parts.push(' - `<create_file path="/antigravity/foo.py">` 같은 시스템 루트 경로 → 거부됨');
parts.push(' - 사용자가 "X 만들어줘"라고 했는데 코드만 보여주고 끝내기 → 사용자는 결과물을 받지 못함');
parts.push('');
parts.push('✅ **올바른 패턴**:');
parts.push(' 사용자: "타이머 파이썬 스크립트 만들어줘"');
parts.push(' 당신: 짧은 설명 한두 줄 + `<create_file path="timer.py">import time\\n...</create_file>` + 자가평가');
parts.push('');
parts.push('기획·분석·아이디어처럼 *결과물이 파일 아닌 경우*에는 액션 태그 없이 마크다운으로만 답해도 됩니다.');
// ── Peer context (this turn) ──
const peers = inputs.peerOutputs ?? [];
+8
View File
@@ -25,6 +25,7 @@
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { appendTelegramMessage } from '../../integrations/telegram/conversationHistory';
import { COMPANY_AGENTS } from './agents';
import { AgentTurnOutput, CompanyState, CompanyTaskPlan } from './types';
@@ -72,6 +73,13 @@ export async function buildTelegramReporter(
return async (text: string): Promise<boolean> => {
if (!text || !text.trim()) return false;
// Append to the per-chat history *before* the send. The bot's
// next inbound turn reads this history, so even if delivery
// fails the user's follow-up question still has context for
// "what did you just say to me?". Persisting on attempt also
// means timing matches the user's perception ("the bot reported
// X, then I replied").
appendTelegramMessage({ chatId, role: 'assistant', text, kind: 'company-mirror' });
try {
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
return true;
+10 -2
View File
@@ -115,6 +115,14 @@ export interface BuildResult {
created: boolean;
/** Result of the scan that fed this build. */
scan: ArchitectureScanResult;
/**
* What the underlying deep-scan actually did this run — how many files
* were freshly analysed vs. served from the on-disk cache, and whether
* any tracked files have disappeared. The sidebar surfaces these counts
* after every Refresh so users can trust the operation actually ran
* (instead of the previous mysterious "updated just now in 0.1s").
*/
refreshStats: RefreshStats;
}
/** Resolve the architecture doc path for a given project root. */
@@ -181,7 +189,7 @@ export function buildOrRefreshArchitectureDoc(
newlyAnalyzed: deep.refreshStats.newlyAnalyzed,
cached: deep.refreshStats.cached,
});
return { docPath, created: true, scan };
return { docPath, created: true, scan, refreshStats: deep.refreshStats };
}
// In-place refresh: rewrite the auto-managed block, keep user-owned sections.
@@ -196,7 +204,7 @@ export function buildOrRefreshArchitectureDoc(
deleted: deep.refreshStats.deleted.length,
});
}
return { docPath, created: false, scan };
return { docPath, created: false, scan, refreshStats: deep.refreshStats };
}
/**
@@ -0,0 +1,154 @@
/**
* Per-chat conversation history for the Telegram bot.
*
* Why this exists: the previous bot was *stateless* — every inbound
* message hit `AIService.chat({system, user})` in isolation, with no
* memory of what the user said two minutes ago or what the secretary
* just reported. Users hit this immediately: "I just told you about
* project X, why don't you remember?"
*
* This module solves it with a thin append-only log:
*
* - Each `(chatId, role, text)` triple is appended to a JSONL file at
* `<workspace>/.astra/company/_agents/secretary/telegram_history.jsonl`.
* One file per workspace, scoped by `chatId` at read time, so
* multi-chat setups don't bleed into each other.
* - A small in-memory cache (last 200 entries) sits in front so the
* hot path is `O(1)` lookup, not "scan the whole file".
* - `getRecentMessages` returns the most recent N entries for a chat,
* including the secretary's company-turn mirror entries that the
* dispatcher appends — so when the user asks a follow-up question
* ("저 폴더가 없다고"), the AI sees its own report from 30 seconds ago.
*
* The file is human-readable on purpose — users can grep it / delete it
* to clear history without any CLI.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError } from '../../utils';
export interface TelegramHistoryEntry {
chatId: number;
role: 'user' | 'assistant';
text: string;
/** ISO timestamp at append time. */
ts: string;
/** Optional tag: `'company-mirror'` for secretary's auto-reports, `'reply'` for normal replies. */
kind?: 'company-mirror' | 'reply' | 'user';
}
/** Max entries kept across all chats in the in-memory cache. */
const MEMORY_CAP = 200;
/** Max entries returned by `getRecentMessages`. */
const DEFAULT_RECENT = 12;
let _cache: TelegramHistoryEntry[] = [];
let _hydratedForWorkspace: string | null = null;
function _historyPath(): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!ws) return '';
return path.join(ws, '.astra', 'company', '_agents', 'secretary', 'telegram_history.jsonl');
}
/**
* Lazy-load the on-disk JSONL into the cache the first time we need it
* for the current workspace. Switching workspaces re-hydrates from the
* new file — that way each project keeps its own chat memory.
*/
function _hydrateIfNeeded(): void {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
if (_hydratedForWorkspace === ws) return;
_hydratedForWorkspace = ws;
_cache = [];
const p = _historyPath();
if (!p || !fs.existsSync(p)) return;
try {
const lines = fs.readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
// Trim to the cap from the *end* so we keep the freshest entries.
const tail = lines.length > MEMORY_CAP ? lines.slice(-MEMORY_CAP) : lines;
for (const line of tail) {
try {
const entry = JSON.parse(line) as Partial<TelegramHistoryEntry>;
if (typeof entry.chatId === 'number'
&& (entry.role === 'user' || entry.role === 'assistant')
&& typeof entry.text === 'string'
&& typeof entry.ts === 'string') {
_cache.push(entry as TelegramHistoryEntry);
}
} catch {
// Skip malformed line — keep going so a single bad write doesn't poison everything.
}
}
} catch (e: any) {
logError('telegram.history: read failed.', { error: e?.message ?? String(e) });
}
}
/**
* Append one entry. Best-effort: cache always updates so the *current*
* session sees its own writes immediately even if the disk write fails.
*/
export function appendTelegramMessage(entry: Omit<TelegramHistoryEntry, 'ts'> & { ts?: string }): void {
_hydrateIfNeeded();
const fullEntry: TelegramHistoryEntry = {
chatId: entry.chatId,
role: entry.role,
text: entry.text,
kind: entry.kind,
ts: entry.ts ?? new Date().toISOString(),
};
_cache.push(fullEntry);
if (_cache.length > MEMORY_CAP) _cache.splice(0, _cache.length - MEMORY_CAP);
const p = _historyPath();
if (!p) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.appendFileSync(p, JSON.stringify(fullEntry) + '\n', 'utf8');
} catch (e: any) {
logError('telegram.history: append failed.', { chatId: entry.chatId, error: e?.message ?? String(e) });
}
}
/**
* Return the most recent N messages for one chat, oldest-first so the
* caller can feed them to an LLM in turn order. `maxEntries` defaults to
* 12 — enough for a couple of follow-up turns without exploding the
* context window of a small local model.
*/
export function getRecentMessages(
chatId: number,
maxEntries: number = DEFAULT_RECENT,
): TelegramHistoryEntry[] {
_hydrateIfNeeded();
const forChat = _cache.filter((e) => e.chatId === chatId);
return forChat.slice(-Math.max(1, maxEntries));
}
/**
* Format recent history as a readable block we can inline into the user
* message. We don't use the AI service's multi-message API because
* `IAIService.chat({system, user})` only takes one user turn — embedding
* the back-and-forth in the user message keeps the API surface unchanged
* while still giving the model the context it needs.
*/
export function formatHistoryForPrompt(history: TelegramHistoryEntry[]): string {
if (!history.length) return '';
const lines: string[] = ['[Previous conversation in this Telegram chat]'];
for (const e of history) {
const speaker = e.role === 'user' ? 'User' : 'Bot';
// Keep each message bounded so a single huge company-mirror report
// doesn't dominate the prompt budget.
const trimmed = e.text.length > 800 ? e.text.slice(0, 800) + '…' : e.text;
lines.push(`${speaker}: ${trimmed}`);
}
lines.push('[End of previous conversation]');
return lines.join('\n');
}
/** For tests + the "clear history" command. */
export function _resetCacheForTests(): void {
_cache = [];
_hydratedForWorkspace = null;
}
+5
View File
@@ -151,6 +151,11 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
case 'detachArchitecture':
await provider._detachArchitecture();
return true;
case 'attachArchitecture':
// Re-enable architecture context for the current workspace —
// user clicked the inactive chip's [Attach] button.
await provider._attachArchitecture();
return true;
case 'activateArchitectureFromText': {
// Optional explicit-toggle path: webview can pass arbitrary text
// (e.g. the current input draft) for one-shot intent detection.
+184 -11
View File
@@ -1121,8 +1121,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
logInfo('architecture: detached.', { projectId: profile.projectId });
}
/** Force a refresh of the architecture doc for the active project. */
/**
* 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({
@@ -1145,6 +1153,111 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
: p);
await this._putChronicleProjects(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({
type: 'architectureRefreshResult',
value: {
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({
type: 'architectureRefreshFailed',
value: { reason: '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 workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return null;
const projects = this._getChronicleProjects();
const active = this._getActiveChronicleProject();
const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) {
return active;
}
// Case 2: another chronicle project matches → switch active to it
const matching = projects.find((p) => norm(p.projectRoot) === norm(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 projectName = path.basename(workspaceRoot) || 'Current Project';
const projectId = this._slugify(projectName);
const now = new Date().toISOString();
const profile: ProjectProfile = {
projectId,
projectName,
projectRoot: workspaceRoot,
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
description: 'Auto-detected from workspace folder.',
corePurpose: '',
detailLevel: 'standard',
createdAt: now,
updatedAt: now,
};
const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile);
await this._putChronicleProjects(nextProjects);
await this._context.globalState.update(
SidebarChatProvider.activeChronicleProjectStateKey,
projectId,
);
logInfo('architecture: registered new project from workspace.', {
projectId, projectRoot: workspaceRoot,
});
return profile;
}
/**
@@ -1166,24 +1279,84 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
});
}
/** Webview chip data — shown above the input box when active. */
/**
* 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;
const p = this._getActiveChronicleProject();
const active = !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
this._view.webview.postMessage({
type: 'architectureStatus',
value: active && p
? {
// 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({ type: 'architectureStatus', value: { active: false } });
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.
}
}
const fullyActive = hasDoc && !wasDetached;
if (fullyActive) {
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: true,
projectId: p.projectId,
projectName: p.projectName,
docPath: p.architectureDocPath,
lastUpdated: p.architectureLastUpdated || '',
autoUpdate: p.architectureAutoUpdate !== false,
}
: { active: false },
});
},
});
// Re-register the watcher in case it was disposed (e.g. workspace switch).
this._registerArchitectureWatcher(p);
} else {
// Inactive but attachable: surface the project name + an Attach hook.
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: false,
canAttach: !!p.projectRoot,
projectId: p.projectId,
projectName: p.projectName,
// Distinguishes "never activated" from "detached" so the
// chip can choose the right label ("Activate" vs "Re-attach").
detached: wasDetached,
},
});
}
}
// ─── 1인 기업 (Company) Mode ────────────────────────────────────────────