From 443488a3b6d7e49b36d948de655775cea3c9a437 Mon Sep 17 00:00:00 2001 From: g1nation Date: Sun, 5 Jul 2026 22:48:59 +0900 Subject: [PATCH] =?UTF-8?q?v2.2.297:=20stocks=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9A=B0=EB=93=9C=20=EB=B0=B1=EC=97=85=20?= =?UTF-8?q?=E2=80=94=20Sheets=20=5Fbackup=20=ED=83=AD=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=B0=B1=EC=97=85=20+=20/stocks=20backup=C2=B7restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본 JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가, writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착. Co-Authored-By: Claude Fable 5 --- PATCHNOTES.md | 8 ++ package.json | 7 +- src/extension.ts | 4 +- src/features/sheets/index.ts | 2 + src/features/sheets/sheetsApi.ts | 69 +++++++++++++++ src/features/stocks/backupSync.ts | 133 +++++++++++++++++++++++++++++ src/features/stocks/index.ts | 1 + src/features/stocks/slashStocks.ts | 30 +++++++ src/features/stocks/stocksStore.ts | 31 ++++++- 9 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/features/stocks/backupSync.ts diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 0796d7d..ea59c4c 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,13 @@ # Astra Patch Notes +## v2.2.297 (2026-07-05) +### ☁️ stocks 목록 클라우드 백업 — 컴퓨터 바꿔도 워치리스트 유지 +stocks.json 이 로컬 워크스페이스에만 있어 기기를 바꾸면 목록이 사라지던 문제 해결. 기존 Sheet1~3 sync 는 분류 표시용이라 복원이 불가능했다. +- **자동 백업**: 목록이 바뀔 때마다(add/remove/check/discover 자동편입/워처 가격갱신 — writeStocksStore 성공 시 리스너 발화) 30초 디바운스 후 기존 spreadsheet(`g1nation.stocks.spreadsheetId`)의 `_backup` 탭에 **원본 JSON 그대로** 백업. 탭은 없으면 자동 생성(batchUpdate addSheet), 목록이 줄어도 잔여 행이 안 남게 클리어 후 덮어쓰기. `g1nation.stocks.autoBackup`(기본 ON)으로 끄기 가능. +- **`/stocks backup`**: 수동 즉시 백업. **`/stocks restore`**: 새 컴퓨터에서 한 번으로 복원(로컬 목록 교체, silent write 로 되백업 루프 차단, 손상 행은 skip). +- sheetsApi 에 `ensureSheetTab`(탭 존재 확인+생성) / `clearSheetRange`(values:clear) 추가 — OAuth·인프라는 기존 calendar/sheets 공유 그대로. +검증: tsc 무오류 + jest 818 통과 + esbuild 정상. + ## v2.2.296 (2026-07-05) ### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색 로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다. diff --git a/package.json b/package.json index 72a98be..aa7a3ab 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.296", + "version": "2.2.297", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -1238,6 +1238,11 @@ "default": "", "markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)." }, + "g1nation.stocks.autoBackup": { + "type": "boolean", + "default": true, + "markdownDescription": "**stocks.json 자동 클라우드 백업** (v2.2.297) — 목록이 바뀔 때마다(추가/제거/가격갱신/발굴 편입) 30초 디바운스 후 `g1nation.stocks.spreadsheetId` spreadsheet 의 `_backup` 탭에 원본 JSON 그대로 백업. 컴퓨터를 바꿔도 `/stocks restore` 한 번으로 복원. spreadsheetId 미설정이면 조용히 skip. 수동 백업은 `/stocks backup`." + }, "g1nation.stocks.sheetSwing": { "type": "string", "default": "Sheet1", diff --git a/src/extension.ts b/src/extension.ts index ccf368d..9e680cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,7 +39,7 @@ import { getBrainTokenIndex, clearBrainTokenIndex } from './retrieval'; import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers'; import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup'; import { runInitialSetup } from './extension/initialSetup'; -import { startStocksWatcher } from './features/stocks'; +import { startStocksWatcher, registerStocksAutoBackup } from './features/stocks'; import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; import { startEmailWatcher } from './features/email/emailWatcher'; import { ensureDefaultBrainConfigured } from './extension/brainBootstrap'; @@ -327,6 +327,8 @@ export async function activate(context: vscode.ExtensionContext) { // Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거. // disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup. context.subscriptions.push(startStocksWatcher(context)); + // Stocks 자동 백업 — stocks.json 변경 시 Sheets `_backup` 탭으로 (v2.2.297). + context.subscriptions.push(registerStocksAutoBackup(context)); // Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송. // 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만). diff --git a/src/features/sheets/index.ts b/src/features/sheets/index.ts index 1da3fca..c6790a2 100644 --- a/src/features/sheets/index.ts +++ b/src/features/sheets/index.ts @@ -2,6 +2,8 @@ export { readSheetRange, writeSheetRange, appendSheetRows, + clearSheetRange, + ensureSheetTab, parseTsvBody, valuesToMarkdownTable, SheetCell, diff --git a/src/features/sheets/sheetsApi.ts b/src/features/sheets/sheetsApi.ts index f52dccc..4a34966 100644 --- a/src/features/sheets/sheetsApi.ts +++ b/src/features/sheets/sheetsApi.ts @@ -127,6 +127,75 @@ export async function appendSheetRows( } } +/** + * range 의 값 지우기 — 셀 서식은 유지, 값만 제거. + * 백업 탭 덮어쓰기 전 잔여 행 제거용 (목록이 줄었을 때 아래 행이 남지 않게). + */ +export async function clearSheetRange( + context: vscode.ExtensionContext, + spreadsheetId: string, + range: string, +): Promise<{ ok: true } | ApiFailure> { + if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' }; + if (!range.trim()) return { ok: false, error: 'range 비어있음' }; + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:clear`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(15000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` }; + return { ok: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? String(e) }; + } +} + +/** + * title 이름의 탭이 spreadsheet 에 없으면 batchUpdate(addSheet) 로 생성. + * 이미 있으면 no-op. 백업 탭처럼 사용자가 미리 만들어 두지 않는 탭에 쓰기 전 호출. + */ +export async function ensureSheetTab( + context: vscode.ExtensionContext, + spreadsheetId: string, + title: string, +): Promise<{ ok: true; created: boolean } | ApiFailure> { + if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' }; + if (!title.trim()) return { ok: false, error: 'sheet title 비어있음' }; + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + try { + const metaUrl = `${API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`; + const metaRes = await fetch(metaUrl, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(15000), + }); + const meta: any = await metaRes.json().catch(() => ({})); + if (!metaRes.ok) return { ok: false, error: meta?.error?.message || `HTTP ${metaRes.status}` }; + const titles: string[] = (meta.sheets ?? []).map((s: any) => s?.properties?.title).filter(Boolean); + if (titles.includes(title)) return { ok: true, created: false }; + + const updRes = await fetch(`${API_BASE}/${encodeURIComponent(spreadsheetId)}:batchUpdate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${tok.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ requests: [{ addSheet: { properties: { title } } }] }), + signal: AbortSignal.timeout(15000), + }); + const upd: any = await updRes.json().catch(() => ({})); + if (!updRes.ok) return { ok: false, error: upd?.error?.message || `HTTP ${updRes.status}` }; + return { ok: true, created: true }; + } catch (e: any) { + return { ok: false, error: e?.message ?? String(e) }; + } +} + // ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ────────────── /** diff --git a/src/features/stocks/backupSync.ts b/src/features/stocks/backupSync.ts new file mode 100644 index 0000000..428c706 --- /dev/null +++ b/src/features/stocks/backupSync.ts @@ -0,0 +1,133 @@ +import * as vscode from 'vscode'; +import { clearSheetRange, ensureSheetTab, readSheetRange, writeSheetRange, type SheetValues } from '../sheets'; +import { logError, logInfo } from '../../utils'; +import { onStocksStoreChanged, readStocksStore, writeStocksStore } from './stocksStore'; +import type { Stock } from './types'; + +/** + * stocks.json 원본 클라우드 백업 — Google Sheets `_backup` 탭 (v2.2.297). + * + * 문제: 목록의 source of truth 가 로컬 `.astra/stocks.json` 뿐이라 컴퓨터를 바꾸면 + * 데이터가 사라진다. Sheet1~3 sync 는 분류 *표시용* 이라 복원 불가. + * + * 해결: 같은 spreadsheet(`g1nation.stocks.spreadsheetId`)에 `_backup` 탭을 자동 + * 생성하고, 종목당 한 행으로 **원본 JSON 그대로** 백업. 새 컴퓨터에서는 + * `/stocks restore` 한 번으로 복원. + * + * 백업 시점: writeStocksStore 성공 → onStocksStoreChanged → 디바운스 30초 후 1회. + * (add/remove/check/discover 자동편입/워처 가격갱신 전부 이 경로를 지남.) + * + * 레이아웃: + * A1: BACKUP_MARKER B1: ISO timestamp C1: 종목 수 + * A2~: 심볼 B2~: 이름 C2~: JSON.stringify(stock) + * 심볼/이름 컬럼은 사람이 시트에서 훑어보기용 — 복원은 C열 JSON 만 사용. + */ + +const BACKUP_TAB = '_backup'; +const BACKUP_MARKER = 'ASTRA_STOCKS_BACKUP_V1'; +const DEBOUNCE_MS = 30_000; + +function getSpreadsheetId(): string { + return (vscode.workspace.getConfiguration('g1nation').get('stocks.spreadsheetId') || '').trim(); +} + +function autoBackupEnabled(): boolean { + return vscode.workspace.getConfiguration('g1nation').get('stocks.autoBackup', true) !== false; +} + +/** 지금 즉시 백업 1회. spreadsheetId 미설정이면 skip (ok:false + reason). */ +export async function backupToSheet( + context: vscode.ExtensionContext, +): Promise<{ ok: boolean; reason?: string; count?: number }> { + const spreadsheetId = getSpreadsheetId(); + if (!spreadsheetId) { + return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' }; + } + const store = readStocksStore(); + + const ensured = await ensureSheetTab(context, spreadsheetId, BACKUP_TAB); + if (!ensured.ok) return { ok: false, reason: `백업 탭 확인 실패: ${ensured.error}` }; + if (ensured.created) logInfo(`Stocks backup: '${BACKUP_TAB}' 탭 생성.`); + + // 목록이 줄었을 때 잔여 행이 남지 않게 먼저 클리어. 클리어 실패는 치명적이지 + // 않으므로 (덮어쓰기는 어차피 진행) 로그만 남기고 계속. + const cleared = await clearSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A:C`); + if (!cleared.ok) logError('Stocks backup: 기존 행 클리어 실패 — 덮어쓰기로 계속.', { error: cleared.error }); + + const rows: SheetValues = [[BACKUP_MARKER, new Date().toISOString(), store.length]]; + for (const s of store) { + rows.push([s.심볼, s.이름, JSON.stringify(s)]); + } + const wrote = await writeSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1`, rows); + if (!wrote.ok) return { ok: false, reason: `백업 쓰기 실패: ${wrote.error}` }; + + logInfo('Stocks backup: 완료.', { count: store.length }); + return { ok: true, count: store.length }; +} + +/** + * `_backup` 탭에서 목록 복원 — 로컬 stocks.json 을 백업 내용으로 **교체**. + * silent write — 복원 직후 같은 내용을 되백업하는 루프 방지. + */ +export async function restoreFromSheet( + context: vscode.ExtensionContext, +): Promise<{ ok: boolean; reason?: string; count?: number; backedUpAt?: string; previousCount?: number }> { + const spreadsheetId = getSpreadsheetId(); + if (!spreadsheetId) { + return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' }; + } + const read = await readSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1:C`); + if (!read.ok) return { ok: false, reason: `백업 탭 읽기 실패: ${read.error} — 아직 백업한 적이 없다면 /stocks backup 먼저.` }; + + const values = read.values; + const header = values[0] ?? []; + if (String(header[0] ?? '') !== BACKUP_MARKER) { + return { ok: false, reason: `'${BACKUP_TAB}' 탭이 백업 형식이 아닙니다 (헤더 마커 불일치).` }; + } + const backedUpAt = String(header[1] ?? ''); + + const restored: Stock[] = []; + for (const row of values.slice(1)) { + const raw = String(row[2] ?? '').trim(); + if (!raw) continue; + try { + const s = JSON.parse(raw); + if (s && typeof s === 'object' && typeof s.심볼 === 'string' && typeof s.이름 === 'string') { + restored.push(s as Stock); + } + } catch { /* 손상 행은 skip — 나머지는 살린다 */ } + } + if (restored.length === 0) { + return { ok: false, reason: '백업에 유효한 종목이 없습니다 — 로컬 목록을 건드리지 않았습니다.' }; + } + + const previousCount = readStocksStore().length; + if (!writeStocksStore(restored, { silent: true })) { + return { ok: false, reason: 'stocks.json 쓰기 실패 (워크스페이스 확인).' }; + } + logInfo('Stocks restore: 완료.', { count: restored.length, backedUpAt, previousCount }); + return { ok: true, count: restored.length, backedUpAt, previousCount }; +} + +/** + * 자동 백업 배선 — extension activate 에서 1회 호출. + * store 변경 → 30초 디바운스 → backupToSheet. spreadsheetId 미설정/설정 OFF 면 조용히 skip. + */ +export function registerStocksAutoBackup(context: vscode.ExtensionContext): vscode.Disposable { + let timer: NodeJS.Timeout | undefined; + const sub = onStocksStoreChanged(() => { + if (!autoBackupEnabled() || !getSpreadsheetId()) return; + if (timer) clearTimeout(timer); + timer = setTimeout(async () => { + timer = undefined; + const r = await backupToSheet(context); + if (!r.ok) logError('Stocks 자동 백업 실패.', { reason: r.reason }); + }, DEBOUNCE_MS); + }); + return { + dispose: () => { + sub.dispose(); + if (timer) { clearTimeout(timer); timer = undefined; } + }, + }; +} diff --git a/src/features/stocks/index.ts b/src/features/stocks/index.ts index 12d8591..c000651 100644 --- a/src/features/stocks/index.ts +++ b/src/features/stocks/index.ts @@ -1,2 +1,3 @@ export { startStocksWatcher, runOnceNow } from './stocksWatcher'; export { handleStocksCommand } from './slashStocks'; +export { registerStocksAutoBackup } from './backupSync'; diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index 7d0cd44..84723c8 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -9,6 +9,7 @@ import { AIService } from '../../core/services'; import { classifyAll } from './signalClassifier'; import { writeStocksStore } from './stocksStore'; import { syncToSheets } from './sheetsSync'; +import { backupToSheet, restoreFromSheet } from './backupSync'; import { judgeStock } from './llmJudge'; import { sendStocksReport, buildReportText } from './telegramReport'; import { runOnceNow } from './stocksWatcher'; @@ -27,6 +28,8 @@ import type { ClassifiedStock, Stock } from './types'; * /stocks check — 현재가 갱신 (Yahoo) * /stocks signal — 매수사정권 종목만 * /stocks sync — Google Sheets 동기화 + * /stocks backup — 원본 목록을 Sheets `_backup` 탭에 백업 + * /stocks restore — `_backup` 탭에서 목록 복원 (기기 이전용) * /stocks add <심볼> <이름> [투자성향] * /stocks remove <심볼> * /stocks judge <심볼> — LLM 4-criteria 평가 @@ -116,6 +119,25 @@ async function cmdSync(view: Webview | undefined, context: vscode.ExtensionConte } } +async function cmdBackup(view: Webview | undefined, context: vscode.ExtensionContext): Promise { + chunk(view, '\n☁️ Google Sheets `_backup` 탭으로 백업 중...\n'); + const r = await backupToSheet(context); + chunk(view, r.ok + ? `\n✅ 백업 완료: ${r.count}개 종목. 새 컴퓨터에서는 \`/stocks restore\` 로 복원하세요.\n` + : `\n❌ 백업 실패: ${r.reason}\n`); +} + +async function cmdRestore(view: Webview | undefined, context: vscode.ExtensionContext): Promise { + chunk(view, '\n☁️ Google Sheets `_backup` 탭에서 복원 중...\n'); + const r = await restoreFromSheet(context); + if (!r.ok) { + chunk(view, `\n❌ 복원 실패: ${r.reason}\n`); + return; + } + const when = r.backedUpAt ? ` (백업 시각: ${r.backedUpAt})` : ''; + chunk(view, `\n✅ 복원 완료: ${r.count}개 종목${when} — 기존 로컬 ${r.previousCount}개를 교체했습니다.\n`); +} + async function cmdAdd(arg: string, view: Webview | undefined): Promise { const parts = arg.split(/\s+/); if (parts.length < 2) { @@ -645,6 +667,8 @@ function cmdHelp(view: Webview | undefined): void { ' `/stocks check` — 현재가 갱신', ' `/stocks signal` — 매수사정권 종목만', ' `/stocks sync` — Google Sheets 동기화', + ' `/stocks backup` — 목록 원본을 Sheets `_backup` 탭에 백업 (변경 시 자동 백업도 됨)', + ' `/stocks restore` — `_backup` 탭에서 목록 복원 (새 컴퓨터 이전용)', ' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)', ' `/stocks remove <심볼>` — 종목 제거', ' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)', @@ -683,6 +707,12 @@ export async function handleStocksCommand( case 'sync': if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; } await cmdSync(view, context); return true; + case 'backup': + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (backup 불가).\n'); return true; } + await cmdBackup(view, context); return true; + case 'restore': + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (restore 불가).\n'); return true; } + await cmdRestore(view, context); return true; case 'add': await cmdAdd(rest, view); return true; case 'remove': case 'rm': await cmdRemove(rest, view); return true; case 'judge': await cmdJudge(rest, view); return true; diff --git a/src/features/stocks/stocksStore.ts b/src/features/stocks/stocksStore.ts index b3f42d4..0a297a1 100644 --- a/src/features/stocks/stocksStore.ts +++ b/src/features/stocks/stocksStore.ts @@ -17,6 +17,28 @@ import type { Stock, StocksStore } from './types'; const STORE_REL_PATH = '.astra/stocks.json'; +/** + * 저장 성공 시 알림 리스너 (v2.2.297) — 모든 변경 경로(add/remove/update/check/ + * discover 자동편입/워처 가격갱신)가 writeStocksStore 한 곳을 지나므로, 여기서 + * fire 하면 Sheets 자동 백업이 변경을 빠짐없이 따라간다. + * 리스너 예외는 삼킨다 — 백업 실패가 저장 자체를 깨면 안 됨. + */ +type StoreChangeListener = () => void; +const changeListeners = new Set(); + +export function onStocksStoreChanged(listener: StoreChangeListener): { dispose(): void } { + changeListeners.add(listener); + return { dispose: () => { changeListeners.delete(listener); } }; +} + +function fireStoreChanged(): void { + for (const l of changeListeners) { + try { l(); } catch (e: any) { + logError('stocks store 변경 리스너 예외.', { error: e?.message ?? String(e) }); + } + } +} + export function getStocksFilePath(): string | null { const folders = vscode.workspace.workspaceFolders; if (!folders || folders.length === 0) return null; @@ -41,8 +63,12 @@ export function readStocksStore(): StocksStore { } } -/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */ -export function writeStocksStore(store: StocksStore): boolean { +/** + * Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). + * opts.silent: 변경 리스너 미발화 — restore 처럼 백업에서 방금 읽어온 내용을 + * 다시 백업하는 루프를 끊을 때만 사용. + */ +export function writeStocksStore(store: StocksStore, opts?: { silent?: boolean }): boolean { const filePath = getStocksFilePath(); if (!filePath) { logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.'); @@ -54,6 +80,7 @@ export function writeStocksStore(store: StocksStore): boolean { const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8'); fs.renameSync(tmp, filePath); + if (!opts?.silent) fireStoreChanged(); return true; } catch (e: any) { logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });