diff --git a/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md b/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md
new file mode 100644
index 000000000..8111a0b6e
--- /dev/null
+++ b/docs/2026/2026-04/2026-04-21/explainVisionMarketplaceEcosystem.md
@@ -0,0 +1,103 @@
+---
+type: explain
+tags: [explain, vision, marketplace, defineFeature, defineApp, ecosystem, roadmap]
+date: 2026-04-21
+---
+
+# Vision — 인지 인터페이스의 창작자 경제
+
+> 작성일: 2026-04-21
+> 맥락: 기존 vision.md(엔진 관점)에 **마켓 레이어**를 한 층 더 얹어, 어제 도입된 `defineFeature`/`defineApp`을 생태계의 원시(原始) 단위로 재해석한다.
+
+> - 변하지 않는 것(키보드·ARIA)은 **엔진이 보장**, 장르(slack·CMS·kanban)는 **baseline이 제공**, 모듈은 **누구나 만들어 팔고 조립**한다
+> - 잘 팔린 모듈은 장르 baseline으로 승격되어 지식이 역류한다 — "쌓이지 대체되지 않는다"의 구체적 메커니즘
+> - 어제 PR #7의 `defineFeature`가 마켓 층의 첫 원시 단위. 지금 4-17~21의 모든 작업은 "팔 만한 물건이 실제로 팔 만하다"를 경화하는 공급자 사전공사
+> - **즉답: 지향점은 vibe-coding SaaS가 아니라, 공급·수요·표준화가 한 루프로 도는 창작자 경제다**
+
+## 1. 3층 변화 속도 모델
+
+마켓이 성립하려면 "아래 층은 안 바뀐다"는 보장이 필요하다. 키보드 QWERTY가 100년 유지된 덕에 키캡 시장이 도는 것과 같다.
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ ③ MODULE (빠르게 변함, 개인·회사가 만듦) │ ← 마켓 거래 단위
+│ "이 Finder의 Sort 모듈" · "이 CMS의 번역 패널" │
+│ = defineFeature 한 단위 │
+│ ↕ 조립 │
+├─────────────────────────────────────────────────────────────┤
+│ ② GENRE (천천히 변함, 합의되면 유지) │ ← 장르 템플릿
+│ chat · CMS · kanban · file-explorer · spreadsheet │
+│ = defineApp baseline │
+│ ↕ 구현 │
+├─────────────────────────────────────────────────────────────┤
+│ ① PRIMITIVE (안 변함, 인류 합의) │ ← 엔진 보장
+│ 키보드 · ARIA 7축 · undo · focus · selection │
+│ = Axes + Patterns + Plugins │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## 2. 선순환 루프
+
+```mermaid
+flowchart TD
+ A[① 엔진 성숙
키보드·ARIA·undo 공짜] --> B[② 장르 baseline 공개
Finder · Chat · CMS]
+ B --> C[③ LLM + 개인이
내 니즈의 모듈 vibe-coding]
+ C --> D[④ 마켓 등록·판매
defineFeature 단위]
+ D --> E[⑤ 다른 사용자가
baseline + 모듈 조립]
+ E --> F[⑥ 조립된 앱에서
또 이런 거 수요 발생]
+ F --> C
+ D -.잘 팔리는 모듈.-> G[⑦ 장르 baseline에 흡수]
+ G --> B
+```
+
+⑦이 핵심이다. Slack의 threads가 모든 채팅앱 기본이 된 것처럼, 잘 팔린 모듈은 장르의 기본값으로 승격되어 ③→②→①로 지식이 역류한다.
+
+## 3. 마켓 성립 조건 — 현재 진척도
+
+| 조건 | 왜 필요 | 상태 |
+|---|---|---|
+| A. 모듈 단위 정의 | 팔 물건이 정의돼야 거래 | 🟢 `defineFeature`/`defineApp` 어제 도입 |
+| B. 조립/해제 런타임 | 사서 바로 끼울 수 있어야 | 🟡 Settings 체크박스 UX 증명. keymap 실런타임 통합 미완 |
+| C. 모듈 간 호환 표준 | 다른 사람 모듈끼리 안 싸워야 | 🟡 7 기여 타입 선언. 런타임 소비 일부만 |
+| D. 모듈 생성 비용 0 | 공급이 생기려면 | 🔴 A2UI 선언 파이프라인 PoC(4-19)·LLM DX 풀세트 아직 |
+| E. 디자인 일관성 자동 | 제각각이면 상품성 X | 🟡 ax() 24축 경화 집중(4-17~19) |
+| F. 장르 baseline 카탈로그 | 어느 장르부터 열 것인가 | 🟡 Finder 완성. chat/CMS/kanban 쇼케이스 단계 |
+| G. 배포·버저닝·샌드박싱 | 실제 상업 거래 | 🔴 |
+| H. 수익 분배·권리 | 선순환의 금전적 동기 | 🔴 |
+
+지금 4-17~21 PRD의 대부분이 **A~E**에 모여 있다. 마켓 인프라(G·H)를 열기 전에 "공급을 0원에 찍어내는 엔진"을 먼저 경화하는 순서. 공급 없는 마켓은 안 돈다.
+
+## 4. 최종 지향점 (수정판)
+
+기존 vision의 ⑦(vibe-coding SaaS)은 **혼자 쓰는 도구**였다. 마켓 층을 받으면 한 칸 더 올라간다:
+
+> **⑧' 인지 인터페이스의 창작자 경제** —
+> 엔진이 "변하지 않는 것"을 보장하고,
+> 장르가 "이미 합의된 것"을 제공하고,
+> 누구나 "자기 필요"를 모듈로 만들어 팔고,
+> 누구나 사서 조립하며,
+> 잘 팔린 모듈은 장르 기본값으로 승격되어 다음 세대가 그 위에서 다시 시작하는,
+> **공급·수요·표준화가 한 루프로 돌아가는 생태계**
+
+이 지향점에서 기존 축들을 재해석하면:
+
+- **FE의 가치 재정의**: 조작 장인 → 블록 설계자 → 블록 판매자/구매자
+- **vibe-coding SaaS**: 혼자 쓰는 도구 ❌ → 공급자·수요자가 만나는 시장 ⭕
+- **A2UI protocol**: LLM용 입력 형식 → **유통 가능한 디지털 상품의 표준 규격** (JSON이라 샌드박싱·버저닝·diff·라이선스 부착 가능)
+- **ARIA 준수**: 접근성 규제 대응 → **상품 품질 보증 라벨** (Unity "URP 호환"처럼)
+- **렌더러 독립**: 기술적 확장 → **한 번 만든 모듈이 웹·앱·TUI에서 다 팔림** (market size × renderer count)
+
+## 5. 루프를 닫으려면 비어 있는 것
+
+A~E(팔 만한 물건) 다음 단계로 빠져 있는 것:
+
+1. **모듈 manifest**: author, dependencies, compatible versions — `defineFeature`에 메타 필드
+2. **Registry/배포**: npm(공개) vs Shopify(큐레이션) vs Figma(둘 다) 중 결정
+3. **샌드박싱**: 남의 모듈이 내 데이터를 못 건드리게 — Plugin 권한 축 신설 가능성
+4. **버저닝·호환성**: ①은 안 변해야 성립하지만 ②③은 변함 — semver + A2UI schema version
+5. **발견·큐레이션 UX**: 마켓 자체가 interactive-os로 만든 앱이어야 함 (메타 dogfooding)
+6. **수익 구조**: 무료/유료/구독/수수료 — Primitive 단의 게이팅 훅 필요 여부
+
+## Insight — 마켓은 공급이 없으면 안 돌아간다
+
+네 비전은 기존 수직 로드맵의 **지붕이 아니라 그 위에 한 층 더 있는 마켓 층**이다. 그리고 어제 `defineFeature`가 들어가면서 이 마켓 층의 **첫 원시 단위가 이미 정의**됐다. 지금 당장 마켓을 열지는 않더라도, "잘 팔릴 만한 물건을 먼저 만들 수 있는 도구"부터 경화하는 게 지금 4-17~21 전 작업이 수렴하는 이유로 해석된다. 공급을 0원에 찍어내는 엔진부터 닫는 것이 순서다.
diff --git a/src/interactive-os/engine/createCommandEngine.ts b/src/interactive-os/engine/createCommandEngine.ts
index 402921457..6aa0e429d 100644
--- a/src/interactive-os/engine/createCommandEngine.ts
+++ b/src/interactive-os/engine/createCommandEngine.ts
@@ -21,8 +21,16 @@ export function createCommandEngine(
// --- subscribers ---
const subscribers = new Set<(event: EngineEvent) => void>()
+ const storeSubscribers = new Set<() => void>()
let seq = 0
+ const notifyStoreSubscribers = () => {
+ const snapshot = Array.from(storeSubscribers)
+ for (const listener of snapshot) {
+ try { listener() } catch (e) { console.error('[engine] store subscriber threw:', e) }
+ }
+ }
+
/** Tracks the original command dispatched before middleware chain (stack for reentrant dispatch) */
const _pendingOriginalStack: Command[] = []
@@ -204,6 +212,7 @@ export function createCommandEngine(
}
if (store !== prev) {
onStoreChange(store)
+ notifyStoreSubscribers()
}
_lastResult = { ok: true, store }
}
@@ -257,7 +266,9 @@ export function createCommandEngine(
},
getStore,
syncStore: (newStore: NormalizedData) => {
+ if (newStore === store) return
store = newStore
+ notifyStoreSubscribers()
},
inspect,
setInspectKeyMap: (desc: Record) => { inspectKeyMap = desc },
@@ -267,6 +278,10 @@ export function createCommandEngine(
subscribers.add(listener)
return () => { subscribers.delete(listener) }
},
+ subscribeStore: (listener: () => void) => {
+ storeSubscribers.add(listener)
+ return () => { storeSubscribers.delete(listener) }
+ },
emitUnhandledKey: (event: KeyboardEvent) => {
if (subscribers.size === 0) return
seq++
diff --git a/src/interactive-os/engine/shallow.ts b/src/interactive-os/engine/shallow.ts
new file mode 100644
index 000000000..208eaacaf
--- /dev/null
+++ b/src/interactive-os/engine/shallow.ts
@@ -0,0 +1,34 @@
+/**
+ * Shallow equality for selector results. Use as the `equalityFn` arg of
+ * `useEngineSelector` when the selector composes a new object/array each call.
+ *
+ * Supports plain objects, arrays, `Map`, and `Set`. Matches Zustand semantics.
+ */
+export function shallow(a: T, b: T): boolean {
+ if (Object.is(a, b)) return true
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false
+
+ if (a instanceof Map && b instanceof Map) {
+ if (a.size !== b.size) return false
+ for (const [k, v] of a) {
+ if (!b.has(k) || !Object.is(v, b.get(k))) return false
+ }
+ return true
+ }
+ if (a instanceof Set && b instanceof Set) {
+ if (a.size !== b.size) return false
+ for (const v of a) {
+ if (!b.has(v)) return false
+ }
+ return true
+ }
+
+ const keysA = Object.keys(a as object)
+ const keysB = Object.keys(b as object)
+ if (keysA.length !== keysB.length) return false
+ for (const key of keysA) {
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false
+ if (!Object.is((a as Record)[key], (b as Record)[key])) return false
+ }
+ return true
+}
diff --git a/src/interactive-os/engine/subscribeStore.test.ts b/src/interactive-os/engine/subscribeStore.test.ts
new file mode 100644
index 000000000..30775a444
--- /dev/null
+++ b/src/interactive-os/engine/subscribeStore.test.ts
@@ -0,0 +1,90 @@
+// @vitest-environment node
+import { describe, it, expect } from 'vitest'
+import { createCommandEngine } from './createCommandEngine'
+import { buildRegistry } from './types'
+import type { NormalizedData } from '../store/types'
+
+const makeStore = (): NormalizedData => ({
+ entities: {
+ __focus__: { id: '__focus__', focusedId: 'a' },
+ a: { id: 'a', data: { label: 'A' } },
+ },
+ relationships: { __root__: ['a'] },
+})
+
+const setFocus = {
+ type: 'test:setFocus',
+ handler: (store: NormalizedData, payload: unknown) => {
+ const { id } = payload as { id: string }
+ return {
+ ...store,
+ entities: {
+ ...store.entities,
+ __focus__: { ...store.entities.__focus__, focusedId: id },
+ },
+ }
+ },
+}
+
+const noop = {
+ type: 'test:noop',
+ handler: (store: NormalizedData) => store,
+}
+
+const makeEngine = () => {
+ const registry = buildRegistry({ setFocus, noop })
+ return createCommandEngine(makeStore(), [], registry, () => {}, { logger: false })
+}
+
+describe('engine.subscribeStore', () => {
+ it('fires on store-changing dispatch', () => {
+ const engine = makeEngine()
+ let count = 0
+ engine.subscribeStore(() => { count++ })
+ engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } })
+ expect(count).toBe(1)
+ })
+
+ it('does not fire when dispatch returns identical store', () => {
+ const engine = makeEngine()
+ let count = 0
+ engine.subscribeStore(() => { count++ })
+ engine.dispatch({ type: 'test:noop', payload: {} })
+ expect(count).toBe(0)
+ })
+
+ it('fires on syncStore with a new reference', () => {
+ const engine = makeEngine()
+ let count = 0
+ engine.subscribeStore(() => { count++ })
+ engine.syncStore(makeStore())
+ expect(count).toBe(1)
+ })
+
+ it('does not fire on syncStore with the same reference', () => {
+ const engine = makeEngine()
+ let count = 0
+ engine.subscribeStore(() => { count++ })
+ engine.syncStore(engine.getStore())
+ expect(count).toBe(0)
+ })
+
+ it('unsubscribe stops notifications', () => {
+ const engine = makeEngine()
+ let count = 0
+ const unsub = engine.subscribeStore(() => { count++ })
+ unsub()
+ engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } })
+ expect(count).toBe(0)
+ })
+
+ it('snapshot returned by getStore reflects latest after notify', () => {
+ const engine = makeEngine()
+ let observedFocus = ''
+ engine.subscribeStore(() => {
+ observedFocus = (engine.getStore().entities.__focus__!.focusedId as string)
+ })
+ engine.dispatch({ type: 'test:setFocus', payload: { id: 'b' } })
+ expect(observedFocus).toBe('b')
+ })
+})
diff --git a/src/interactive-os/engine/types.ts b/src/interactive-os/engine/types.ts
index c2519e589..b03f58c73 100644
--- a/src/interactive-os/engine/types.ts
+++ b/src/interactive-os/engine/types.ts
@@ -81,6 +81,11 @@ export interface CommandEngine {
setInspectPattern(info: InspectPatternInfo): void
/** Subscribe to engine events (dispatch, error, etc.) */
subscribe(listener: (event: EngineEvent) => void): Unsubscribe
+ /**
+ * Subscribe to store-reference changes only (stable-ref signature for useSyncExternalStore).
+ * Fires when store is replaced via dispatch or syncStore. Does not fire on rejections.
+ */
+ subscribeStore(listener: () => void): Unsubscribe
/** Emit an unhandled key event — called by view layer when keyMap has no match */
emitUnhandledKey(event: KeyboardEvent): void
}
diff --git a/src/interactive-os/engine/useEngineStore.ts b/src/interactive-os/engine/useEngineStore.ts
new file mode 100644
index 000000000..650f09af1
--- /dev/null
+++ b/src/interactive-os/engine/useEngineStore.ts
@@ -0,0 +1,36 @@
+import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'
+import type { NormalizedData } from '../store/types'
+import type { CommandEngine } from './types'
+
+const identity = (x: T): T => x
+
+/**
+ * Subscribe a component to an engine's store. Re-renders only when the selected
+ * slice changes under `equalityFn` (default `Object.is`).
+ *
+ * This is the canonical way for widgets to read engine state without forcing a
+ * parent rerender. Pair with `shallow` when the selector returns a composed
+ * object/array.
+ */
+export function useEngineSelector(
+ engine: CommandEngine,
+ selector: (store: NormalizedData) => T,
+ equalityFn: (a: T, b: T) => boolean = Object.is,
+): T {
+ return useSyncExternalStoreWithSelector(
+ engine.subscribeStore,
+ engine.getStore,
+ engine.getStore,
+ selector,
+ equalityFn,
+ )
+}
+
+/**
+ * Subscribe a component to the engine's full store. Equivalent to
+ * `useEngineSelector(engine, s => s)`. Use this for internal OS hooks that
+ * genuinely need the whole snapshot; prefer `useEngineSelector` in widgets.
+ */
+export function useEngineStore(engine: CommandEngine): NormalizedData {
+ return useEngineSelector(engine, identity)
+}
diff --git a/src/interactive-os/primitives/useAriaZone.ts b/src/interactive-os/primitives/useAriaZone.ts
index 2ce258e82..05433f502 100644
--- a/src/interactive-os/primitives/useAriaZone.ts
+++ b/src/interactive-os/primitives/useAriaZone.ts
@@ -221,6 +221,7 @@ export function useAriaZone(options: UseAriaZoneOptions): UseAriaReturn {
setInspectRole: (role, childRole) => { engine.setInspectRole(role, childRole) },
setInspectPattern: (info) => { engine.setInspectPattern(info) },
subscribe: (listener) => engine.subscribe(listener),
+ subscribeStore: (listener) => engine.subscribeStore(listener),
emitUnhandledKey: (event) => { engine.emitUnhandledKey(event) },
}
diff --git a/src/interactive-os/primitives/useControlledAria.ts b/src/interactive-os/primitives/useControlledAria.ts
index a9dcea59d..f73237685 100644
--- a/src/interactive-os/primitives/useControlledAria.ts
+++ b/src/interactive-os/primitives/useControlledAria.ts
@@ -32,6 +32,7 @@ export function useControlledAria(options: UseControlledAriaOptions): UseAriaRet
setInspectRole: () => { /* no-op for controlled mode */ },
setInspectPattern: () => { /* no-op for controlled mode */ },
subscribe: () => () => { /* no-op for controlled mode */ },
+ subscribeStore: () => () => { /* no-op for controlled mode */ },
emitUnhandledKey: () => { /* no-op for controlled mode */ },
}),
// Re-create whenever store or onDispatch changes so the engine always