diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..f33f6ee
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## 변경 목적
+
+
+
+## 검증 결과
+
+
+
+## 호환성 영향
+
+
diff --git a/.github/workflows/desktop-core-validation.yml b/.github/workflows/desktop-core-validation.yml
new file mode 100644
index 0000000..4f499b7
--- /dev/null
+++ b/.github/workflows/desktop-core-validation.yml
@@ -0,0 +1,52 @@
+name: Desktop core validation
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: desktop-core-${{ github.event.number }}
+ cancel-in-progress: true
+
+jobs:
+ desktop:
+ name: Desktop core / ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, macos-14, ubuntu-22.04]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '24.21.0'
+ cache: npm
+ - run: npm ci
+ - name: Linux desktop dependencies
+ if: runner.os == 'Linux'
+ run: npx playwright install-deps chromium
+ - run: npm run check
+ - name: Core document flow
+ if: runner.os != 'Linux'
+ run: npm run test:desktop:core
+ - name: Core document flow under Xvfb
+ if: runner.os == 'Linux'
+ run: xvfb-run -a npm run test:desktop:core
+ - name: Core evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: desktop-core-${{ matrix.os }}
+ retention-days: 14
+ include-hidden-files: true
+ path: |
+ .pre04-runs/core-*/results.json
+ .pre04-runs/core-*/*.log
+ .pre04-runs/core-*/*.png
diff --git a/.github/workflows/desktop-validation.yml b/.github/workflows/desktop-validation.yml
index 851850d..8a65d27 100644
--- a/.github/workflows/desktop-validation.yml
+++ b/.github/workflows/desktop-validation.yml
@@ -1,8 +1,6 @@
-name: Desktop validation
+name: Desktop full validation
on:
- push:
- branches: [main]
workflow_dispatch:
permissions:
@@ -10,7 +8,7 @@ permissions:
jobs:
desktop:
- name: Desktop / ${{ matrix.os }}
+ name: Desktop full / ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
@@ -53,6 +51,9 @@ jobs:
retention-days: 14
include-hidden-files: true
path: |
+ .pre04-runs/core-*/results.json
+ .pre04-runs/core-*/*.log
+ .pre04-runs/core-*/*.png
.pre04-runs/integration-*/results.json
.pre04-runs/integration-*/*.log
.pre04-runs/smoke-*/results.json
@@ -64,3 +65,7 @@ jobs:
.pre04-runs/m3-*/results.json
.pre04-runs/m3-*/*.png
+ .pre04-runs/m4-*/results.json
+ .pre04-runs/m4-*/*.png
+ .pre04-runs/ux-*/results.json
+ .pre04-runs/ux-*/*.png
diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml
new file mode 100644
index 0000000..d6de2e9
--- /dev/null
+++ b/.github/workflows/pr-policy.yml
@@ -0,0 +1,36 @@
+name: PR policy
+
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: pr-policy-${{ github.event.number }}
+ cancel-in-progress: true
+
+jobs:
+ policy:
+ name: PR policy
+ runs-on: ubuntu-22.04
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '24.21.0'
+ - run: node --test scripts/pr-policy.test.mjs
+ - name: Read current PR metadata and validate
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.number }}
+ run: |
+ gh api "repos/$GH_REPO/pulls/$PR_NUMBER" --jq '{pull_request: .}' > "$RUNNER_TEMP/pr-policy.json"
+ GITHUB_EVENT_PATH="$RUNNER_TEMP/pr-policy.json" node scripts/pr-policy.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a28a991..cd01659 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,7 +10,28 @@ permissions:
contents: read
jobs:
+ validation:
+ name: Verify full validation
+ runs-on: ubuntu-22.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ actions: read
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@v7
+ with:
+ node-version: '24.21.0'
+ - name: Require manual full validation of the release commit
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ export RELEASE_COMMIT="$(git rev-parse HEAD)"
+ node scripts/release-validation.mjs
package:
+ needs: validation
name: Package / ${{ matrix.asset }}
strategy:
fail-fast: false
diff --git a/README.adoc b/README.adoc
new file mode 100644
index 0000000..b8f9616
--- /dev/null
+++ b/README.adoc
@@ -0,0 +1,128 @@
+= Metis
+:experimental:
+
+English | link:doc/README.ko.adoc[한국어]
+
+Metis aims to be a local-first knowledge tool that uses standard AsciiDoc documents as the source of truth. Built around file ownership and tool independence, it helps users structure, compose, reuse, and explore connected knowledge.
+
+== Development philosophy
+
+**Knowledge should remain in the user's files, and tools should help users work with it.** Metis aims to provide an AsciiDoc-based knowledge environment where documents remain readable, editable, and reusable even after users stop using the app.
+
+image::doc/diagrams/philosophy-en.svg[Workflow diagram]
+
+link:doc/diagrams/philosophy-en.mmd[Mermaid source]
+
+The following principles guide implementation and feature decisions. Document meaning and portability take priority when adding convenience features.
+
+* **AsciiDoc source first:** Respect its syntax and semantics, and avoid custom syntax where the standard can express the content. Derived data, such as search indexes and graphs, must be rebuildable from the source.
+* **Files and ownership first:** Use local plain text files as the foundation of knowledge. The same documents should work with other editors, Git, and AsciiDoc tools.
+* **Meaning and reuse:** Prioritize document meaning over presentation. Use ``include``, attributes, and ``xref`` to compose and reuse documents.
+* **Extensions that preserve the source:** Design plugins and AI to assist document work. Knowledge must remain readable and durable when those features are removed.
+
+The principles above guide development priorities and decisions.
+
+== Development environment
+
+Metis is a desktop app built with Electron, React, and TypeScript. npm workspaces manage the app and shared packages.
+
+[cols="1,3",options="header"]
+|===
+| Requirement | Details
+| Node.js | ``>=24.21.0 <25``; **24.21.0** is the reproducibility and CI baseline
+| npm | **11.19.0** was used for existing local validation; install using the root ``package-lock.json``
+| Operating system | Locally validated on Windows x64. CI is configured for macOS and Linux, but execution has not yet been validated
+| Runtime environment | A desktop environment capable of displaying Electron windows. Network access is required for the initial dependency and Electron installation
+|===
+
+Run all commands from the **repository root**. Basic usage requires no separate ``.env`` file, external server, or database configuration.
+
+== Install, build, and run
+
+[source,sh]
+----
+npm ci
+npm start
+----
+
+``npm start`` builds the app and launches Electron. Select **Open folder (폴더 열기)** to open a folder containing AsciiDoc documents, or start with **New workspace (새 작업 공간)**. Use **New document (새 문서)** to create a document in the workspace.
+
+To build without launching the app:
+
+[source,sh]
+----
+npm run build
+----
+
+Build output is written to ``apps/desktop/dist/``. There is currently no development server, watch mode, or HMR command. After editing the source, close the running app and run ``npm start`` again.
+
+image::doc/diagrams/development-en.svg[Workflow diagram]
+
+link:doc/diagrams/development-en.mmd[Mermaid source]
+
+== Validate changes
+
+[source,sh]
+----
+npm run check
+npm run test:desktop:core
+----
+
+PRs run these commands on Windows, macOS, and Linux. The core GUI scenario checks startup, opening a workspace, document creation, editing, saving and reopening, AsciiDoc preview, search, and source-byte preservation. PRs do not package the application or run the full GUI suite.
+
+[cols="1,3",options="header"]
+|===
+| Command | Purpose
+| ``npm run check`` | Type checking, unit tests, tooling tests, and build
+| ``npm run test:desktop:core`` | Core document flow against the built development app
+| ``npm run test:integration`` | Full development GUI suite: 31 existing scenarios plus the core flow
+| ``npm run test:integration:packaged`` | The same full suite against the latest packaged app
+|===
+
+GUI tests do not build the app. Run the check or build command first. Evidence is saved under ``.pre04-runs/core-*/`` and ``.pre04-runs/integration-*/``. Linux uses Xvfb for GUI tests.
+
+Before releasing, manually run the full 3-OS validation from Actions or the CLI:
+
+[source,sh]
+----
+gh workflow run desktop-validation.yml --ref main
+gh run list --workflow desktop-validation.yml --event workflow_dispatch --branch main
+gh run watch RUN_ID --exit-status
+gh run view RUN_ID --json headSha,url,conclusion
+----
+
+Tag the successful run's exact headSha, even if main has advanced. Release packaging requires the latest manual full validation of that same main commit and all three OS jobs to succeed. Full validation includes packaging and both development and packaged GUI suites; it never publishes a release by itself. See link:doc/git-strategy.adoc[Git strategy] for retry and tag rules.
+
+== Package the app
+
+[source,sh]
+----
+npm run package
+npm run test:integration:packaged
+----
+
+``package`` includes a build and creates an **application folder for the current host OS and architecture** under ``.pre04-runs/package-*/out/``. It does not include an installer, signing, or automatic updates. The latest package staging path is recorded in ``.pre04-runs/latest-package.txt``, which packaged integration tests read automatically.
+
+To launch the packaged app directly from Windows PowerShell:
+
+[source,powershell]
+----
+$metisPackage = Get-Content .pre04-runs/latest-package.txt
+& "$metisPackage/out/Metis-win32-x64/Metis.exe"
+----
+
+This example targets Windows x64. On Linux, run packaged GUI tests with ``xvfb-run -a npm run test:integration:packaged``.
+
+== Respect — Obsidian and AsciiDoc
+
+Metis respects and draws inspiration from Obsidian and AsciiDoc.
+
+**Obsidian** is an important reference for Metis's approach to local-first work, user-owned knowledge, connections and exploration between documents, and extensible workflows. We aim to learn from the experience it offers people building and connecting their own knowledge.
+
+**AsciiDoc** provides the foundation for Metis's document philosophy and source format. We value meaningful plain text, the separation of content and presentation, and document structure, composition, and reuse. We aim to keep documents usable with existing AsciiDoc tools.
+
+We thank the developers and contributors who have built and sustained both projects and their ecosystems. Inspired by their work, Metis aims to create a knowledge workspace that stays true to AsciiDoc.
+
+== Contributing and Git policy
+
+See link:doc/git-strategy.adoc[Git strategy] for PR labels, required checks, squash merges, and release tag protection.
diff --git a/README.md b/README.md
deleted file mode 100644
index cc508a1..0000000
--- a/README.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Metis
-
-English | [한국어](doc/README.ko.md)
-
-Metis aims to be a local-first knowledge tool that uses standard AsciiDoc documents as the source of truth. Built around file ownership and tool independence, it helps users structure, compose, reuse, and explore connected knowledge.
-
-## Development philosophy
-
-**Knowledge should remain in the user's files, and tools should help users work with it.** Metis aims to provide an AsciiDoc-based knowledge environment where documents remain readable, editable, and reusable even after users stop using the app.
-
-```mermaid
-flowchart TD
- Source["AsciiDoc standards · semantics · source files"] --> Preserve["Tool independence · long-term preservation · user ownership"]
- Preserve --> Knowledge["Local knowledge connections · exploration"]
- Knowledge --> Features["Convenience · extensions · AI assistance"]
-```
-
-The following principles guide implementation and feature decisions. Document meaning and portability take priority when adding convenience features.
-
-- **AsciiDoc source first:** Respect its syntax and semantics, and avoid custom syntax where the standard can express the content. Derived data, such as search indexes and graphs, must be rebuildable from the source.
-- **Files and ownership first:** Use local plain text files as the foundation of knowledge. The same documents should work with other editors, Git, and AsciiDoc tools.
-- **Meaning and reuse:** Prioritize document meaning over presentation. Use `include`, attributes, and `xref` to compose and reuse documents.
-- **Extensions that preserve the source:** Design plugins and AI to assist document work. Knowledge must remain readable and durable when those features are removed.
-
-See the [development philosophy](docs/development-philosophy.md) (in Korean) for detailed priorities and decision criteria.
-
-## Development environment
-
-Metis is a desktop app built with Electron, React, and TypeScript. npm workspaces manage the app and shared packages.
-
-| Requirement | Details |
-| --- | --- |
-| Node.js | `>=24.21.0 <25`; **24.21.0** is the reproducibility and CI baseline |
-| npm | **11.19.0** was used for existing local validation; install using the root `package-lock.json` |
-| Operating system | Locally validated on Windows x64. CI is configured for macOS and Linux, but execution has not yet been validated |
-| Runtime environment | A desktop environment capable of displaying Electron windows. Network access is required for the initial dependency and Electron installation |
-
-Run all commands from the **repository root**. Basic usage requires no separate `.env` file, external server, or database configuration.
-
-## Install, build, and run
-
-```sh
-npm ci
-npm start
-```
-
-`npm start` builds the app and launches Electron. Select **Open folder (폴더 열기)** to open a folder containing AsciiDoc documents, or start with **New workspace (새 작업 공간)**. Use **New document (새 문서)** to create a document in the workspace.
-
-To build without launching the app:
-
-```sh
-npm run build
-```
-
-Build output is written to `apps/desktop/dist/`. There is currently no development server, watch mode, or HMR command. After editing the source, close the running app and run `npm start` again.
-
-```mermaid
-flowchart LR
- Install["npm ci"] --> Start["npm start"]
- Start --> Build["Build → apps/desktop/dist"]
- Build --> Run["Launch Electron"]
- Run --> Edit["Close app · edit source"]
- Edit --> Start
-```
-
-## Validate changes
-
-```sh
-npm run check
-npm run test:integration
-```
-
-| Command | Purpose |
-| --- | --- |
-| `npm run check` | Type checking → unit tests → tooling tests → build |
-| `npm run typecheck` | Run type checking only |
-| `npm test` | Run Vitest unit tests only |
-| `npm run test:desktop` | Run basic smoke checks against the built development app |
-| `npm run test:integration` | Run PRE-04, M1-01–08, M2-01–07, and M3-01–03 integration checks against the built development app |
-
-GUI tests do not build the app themselves. Run `npm run check` or `npm run build` first. Test workspaces, profiles, and results are created under `.pre04-runs/`. Integration results and per-stage logs are written to `.pre04-runs/integration-*/`.
-
-Linux CI installs system dependencies with `npx playwright install-deps chromium` and runs GUI tests with `xvfb-run -a npm run test:integration`.
-
-## Package the app
-
-```sh
-npm run package
-npm run test:integration:packaged
-```
-
-`package` includes a build and creates an **application folder for the current host OS and architecture** under `.pre04-runs/package-*/out/`. It does not include an installer, signing, or automatic updates. The latest package staging path is recorded in `.pre04-runs/latest-package.txt`, which packaged integration tests read automatically.
-
-To launch the packaged app directly from Windows PowerShell:
-
-```powershell
-$metisPackage = Get-Content .pre04-runs/latest-package.txt
-& "$metisPackage/out/Metis-win32-x64/Metis.exe"
-```
-
-This example targets Windows x64. On Linux, run packaged GUI tests with `xvfb-run -a npm run test:integration:packaged`.
-
-## Respect — Obsidian and AsciiDoc
-
-Metis respects and draws inspiration from Obsidian and AsciiDoc.
-
-**Obsidian** is an important reference for Metis's approach to local-first work, user-owned knowledge, connections and exploration between documents, and extensible workflows. We aim to learn from the experience it offers people building and connecting their own knowledge.
-
-**AsciiDoc** provides the foundation for Metis's document philosophy and source format. We value meaningful plain text, the separation of content and presentation, and document structure, composition, and reuse. We aim to keep documents usable with existing AsciiDoc tools.
-
-We thank the developers and contributors who have built and sustained both projects and their ecosystems. Inspired by their work, Metis aims to create a knowledge workspace that stays true to AsciiDoc.
diff --git a/doc/README.ko.adoc b/doc/README.ko.adoc
new file mode 100644
index 0000000..4609bd2
--- /dev/null
+++ b/doc/README.ko.adoc
@@ -0,0 +1,128 @@
+= Metis
+:experimental:
+
+link:../README.adoc[English] | 한국어
+
+Metis는 표준 AsciiDoc 문서를 지식의 원본으로 삼는 로컬 우선 지식 도구를 지향합니다. 파일 기반의 소유권과 도구 독립성을 바탕으로, 문서의 구조화·조합·재사용과 연결된 지식 탐색을 지원하는 것이 목표입니다.
+
+== 개발 철학
+
+**지식은 사용자의 파일에 남고, 도구는 그 지식을 다루는 일을 도와야 합니다.** Metis는 앱을 사용하지 않게 되더라도 문서를 읽고 편집하고 재사용할 수 있는 AsciiDoc 기반 지식 환경을 지향합니다.
+
+image::diagrams/philosophy-ko.svg[흐름도]
+
+link:diagrams/philosophy-ko.mmd[Mermaid 원본]
+
+아래 원칙은 구현과 기능 선택의 기준입니다. 편의 기능을 추가할 때도 문서의 의미와 이식성을 먼저 지킵니다.
+
+* **AsciiDoc 원본 우선:** 문법과 의미를 존중하며, 표준으로 표현할 수 있는 내용에는 자체 문법을 만들지 않습니다. 검색 색인·그래프 같은 파생 데이터는 원본에서 다시 만들 수 있어야 합니다.
+* **파일과 소유권 우선:** 로컬의 일반 텍스트 파일을 지식의 기반으로 삼습니다. 다른 편집기와 Git, AsciiDoc 도구에서도 같은 문서를 사용할 수 있어야 합니다.
+* **의미와 재사용 중심:** 표현 방식보다 문서의 의미를 먼저 다룹니다. ``include``, 속성, ``xref``를 활용해 문서를 조합하고 재사용합니다.
+* **원본을 지키는 확장:** 플러그인이나 AI는 문서 작업을 돕는 기능으로 설계합니다. 해당 기능이 없어져도 지식 자산의 가독성과 보존성을 유지해야 합니다.
+
+위 원칙을 개발의 우선순위와 판단 기준으로 사용합니다.
+
+== 개발 환경
+
+Electron·React·TypeScript 기반 데스크톱 앱이며, npm workspaces로 앱과 공통 패키지를 관리합니다.
+
+[cols="1,3",options="header"]
+|===
+| 항목 | 필수 정보
+| Node.js | ``>=24.21.0 <25``; 재현 및 CI 기준은 **24.21.0**
+| npm | 기존 로컬 검증 기준 **11.19.0**; 루트 ``package-lock.json``으로 설치
+| 운영체제 | Windows x64 로컬 검증 완료. macOS·Linux는 CI 구성만 있으며 실행 검증 전
+| 실행 환경 | Electron 창을 표시할 수 있는 데스크톱 환경. 최초 의존성·Electron 설치 시 네트워크 필요
+|===
+
+모든 명령은 **저장소 루트**에서 실행합니다. 기본 실행에 별도 ``.env`` 파일이나 외부 서버·DB 설정은 필요하지 않습니다.
+
+== 설치·개발 빌드·실행
+
+[source,sh]
+----
+npm ci
+npm start
+----
+
+``npm start``는 앱을 빌드한 뒤 Electron을 실행합니다. 실행 후 **폴더 열기**로 AsciiDoc 문서가 있는 폴더를 선택하거나, **새 작업 공간**으로 시작하세요. 작업 공간에서 **새 문서**를 만들 수 있습니다.
+
+빌드만 수행하려면 다음 명령을 사용합니다.
+
+[source,sh]
+----
+npm run build
+----
+
+결과는 ``apps/desktop/dist/``에 생성됩니다. 현재 개발 서버·watch·HMR 명령은 없으므로 소스를 수정한 뒤 실행 중인 앱을 종료하고 ``npm start``를 다시 실행합니다.
+
+image::diagrams/development-ko.svg[흐름도]
+
+link:diagrams/development-ko.mmd[Mermaid 원본]
+
+== 변경 사항 검증
+
+[source,sh]
+----
+npm run check
+npm run test:desktop:core
+----
+
+PR마다 Windows·macOS·Linux에서 실행합니다. 핵심 GUI 검사는 앱 기동, 작업 공간 열기, 문서 생성·편집·저장·재열기, AsciiDoc 미리보기, 검색과 원문 바이트 보존을 확인합니다. PR에서는 패키징과 전체 GUI 검사를 실행하지 않습니다.
+
+[cols="1,3",options="header"]
+|===
+| 명령 | 용도
+| ``npm run check`` | 타입 검사·단위 테스트·도구 테스트·빌드
+| ``npm run test:desktop:core`` | 빌드된 개발 앱의 핵심 문서 흐름
+| ``npm run test:integration`` | 기존 31개 시나리오와 핵심 흐름의 전체 개발 앱 검사
+| ``npm run test:integration:packaged`` | 최신 패키지 앱에서 동일한 전체 검사
+|===
+
+GUI 검사는 자체 빌드를 하지 않으므로 먼저 check 또는 build 명령을 실행합니다. 결과는 ``.pre04-runs/core-*/`` 및 ``.pre04-runs/integration-*/``에 저장됩니다. Linux GUI 검사는 Xvfb에서 실행합니다.
+
+릴리스 전 Actions 화면 또는 CLI에서 3개 OS 전체 검증을 수동 실행합니다.
+
+[source,sh]
+----
+gh workflow run desktop-validation.yml --ref main
+gh run list --workflow desktop-validation.yml --event workflow_dispatch --branch main
+gh run watch RUN_ID --exit-status
+gh run view RUN_ID --json headSha,url,conclusion
+----
+
+성공한 실행의 정확한 headSha에 태그를 생성합니다. main이 전진했더라도 새 HEAD를 사용하지 않습니다. 릴리스 패키징은 동일 main 커밋의 가장 최근 수동 전체 검증과 3개 OS 검사가 모두 성공해야 시작합니다. 전체 검증에는 패키징과 개발·패키지 GUI 전체 검사가 포함되며 자동으로 릴리스를 발행하지 않습니다. 재실행과 태그 정책은 link:git-strategy.adoc[Git 전략]을 참고하세요.
+
+== 실행 파일 패키징
+
+[source,sh]
+----
+npm run package
+npm run test:integration:packaged
+----
+
+``package``는 빌드를 포함하며 **현재 호스트 OS·아키텍처용 실행 폴더**를 ``.pre04-runs/package-*/out/``에 생성합니다. 설치 프로그램·서명·자동 업데이트는 포함하지 않습니다. 최신 패키지의 staging 경로는 ``.pre04-runs/latest-package.txt``에 기록되며 패키지 통합 검사가 이를 자동으로 읽습니다.
+
+Windows PowerShell에서 패키지 앱을 직접 실행하는 예시입니다.
+
+[source,powershell]
+----
+$metisPackage = Get-Content .pre04-runs/latest-package.txt
+& "$metisPackage/out/Metis-win32-x64/Metis.exe"
+----
+
+위 예시는 Windows x64 기준입니다. Linux의 패키지 GUI 검사는 ``xvfb-run -a npm run test:integration:packaged``로 실행합니다.
+
+== Respect — Obsidian과 AsciiDoc
+
+Metis는 Obsidian과 AsciiDoc에서 받은 영감을 존중합니다.
+
+**Obsidian**은 Metis가 로컬 우선 접근, 사용자 소유의 지식, 문서 간 연결과 탐색, 확장 가능한 작업 환경을 고민하는 데 중요한 참고 모델입니다. 개인이 자신의 지식을 쌓고 연결하는 경험에서 배우고자 합니다.
+
+**AsciiDoc**은 Metis의 문서 철학과 원본 형식의 기반입니다. 의미를 담은 일반 텍스트, 내용과 표현의 분리, 문서의 구조화·조합·재사용이라는 가치를 존중하며, 기존 AsciiDoc 도구와 함께 사용할 수 있는 문서를 지향합니다.
+
+두 프로젝트와 생태계를 발전시켜 온 개발자와 기여자들에게 감사드립니다. Metis는 이 영감을 바탕으로 AsciiDoc에 충실한 지식 작업 환경을 만들어 가고자 합니다.
+
+== 기여 및 Git 정책
+
+PR 분류, 필수 검사, squash 병합 및 릴리스 태그 보호는 link:git-strategy.adoc[Git 전략]을 참고하세요.
diff --git a/doc/README.ko.md b/doc/README.ko.md
deleted file mode 100644
index dea9d40..0000000
--- a/doc/README.ko.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Metis
-
-[English](../README.md) | 한국어
-
-Metis는 표준 AsciiDoc 문서를 지식의 원본으로 삼는 로컬 우선 지식 도구를 지향합니다. 파일 기반의 소유권과 도구 독립성을 바탕으로, 문서의 구조화·조합·재사용과 연결된 지식 탐색을 지원하는 것이 목표입니다.
-
-## 개발 철학
-
-**지식은 사용자의 파일에 남고, 도구는 그 지식을 다루는 일을 도와야 합니다.** Metis는 앱을 사용하지 않게 되더라도 문서를 읽고 편집하고 재사용할 수 있는 AsciiDoc 기반 지식 환경을 지향합니다.
-
-```mermaid
-flowchart TD
- Source["AsciiDoc 표준 · 의미 · 원본 파일"] --> Preserve["도구 독립성 · 장기 보존 · 사용자 소유권"]
- Preserve --> Knowledge["로컬 기반 지식 연결 · 탐색"]
- Knowledge --> Features["편의 기능 · 확장 · AI 지원"]
-```
-
-아래 원칙은 구현과 기능 선택의 기준입니다. 편의 기능을 추가할 때도 문서의 의미와 이식성을 먼저 지킵니다.
-
-- **AsciiDoc 원본 우선:** 문법과 의미를 존중하며, 표준으로 표현할 수 있는 내용에는 자체 문법을 만들지 않습니다. 검색 색인·그래프 같은 파생 데이터는 원본에서 다시 만들 수 있어야 합니다.
-- **파일과 소유권 우선:** 로컬의 일반 텍스트 파일을 지식의 기반으로 삼습니다. 다른 편집기와 Git, AsciiDoc 도구에서도 같은 문서를 사용할 수 있어야 합니다.
-- **의미와 재사용 중심:** 표현 방식보다 문서의 의미를 먼저 다룹니다. `include`, 속성, `xref`를 활용해 문서를 조합하고 재사용합니다.
-- **원본을 지키는 확장:** 플러그인이나 AI는 문서 작업을 돕는 기능으로 설계합니다. 해당 기능이 없어져도 지식 자산의 가독성과 보존성을 유지해야 합니다.
-
-구체적인 우선순위와 판단 기준은 [개발 철학](../docs/development-philosophy.md)에 정리되어 있습니다.
-
-## 개발 환경
-
-Electron·React·TypeScript 기반 데스크톱 앱이며, npm workspaces로 앱과 공통 패키지를 관리합니다.
-
-| 항목 | 필수 정보 |
-| --- | --- |
-| Node.js | `>=24.21.0 <25`; 재현 및 CI 기준은 **24.21.0** |
-| npm | 기존 로컬 검증 기준 **11.19.0**; 루트 `package-lock.json`으로 설치 |
-| 운영체제 | Windows x64 로컬 검증 완료. macOS·Linux는 CI 구성만 있으며 실행 검증 전 |
-| 실행 환경 | Electron 창을 표시할 수 있는 데스크톱 환경. 최초 의존성·Electron 설치 시 네트워크 필요 |
-
-모든 명령은 **저장소 루트**에서 실행합니다. 기본 실행에 별도 `.env` 파일이나 외부 서버·DB 설정은 필요하지 않습니다.
-
-## 설치·개발 빌드·실행
-
-```sh
-npm ci
-npm start
-```
-
-`npm start`는 앱을 빌드한 뒤 Electron을 실행합니다. 실행 후 **폴더 열기**로 AsciiDoc 문서가 있는 폴더를 선택하거나, **새 작업 공간**으로 시작하세요. 작업 공간에서 **새 문서**를 만들 수 있습니다.
-
-빌드만 수행하려면 다음 명령을 사용합니다.
-
-```sh
-npm run build
-```
-
-결과는 `apps/desktop/dist/`에 생성됩니다. 현재 개발 서버·watch·HMR 명령은 없으므로 소스를 수정한 뒤 실행 중인 앱을 종료하고 `npm start`를 다시 실행합니다.
-
-```mermaid
-flowchart LR
- Install["npm ci"] --> Start["npm start"]
- Start --> Build["빌드 → apps/desktop/dist"]
- Build --> Run["Electron 실행"]
- Run --> Edit["앱 종료 · 소스 수정"]
- Edit --> Start
-```
-
-## 변경 사항 검증
-
-```sh
-npm run check
-npm run test:integration
-```
-
-| 명령 | 용도 |
-| --- | --- |
-| `npm run check` | 타입 검사 → 단위 테스트 → 실행 도구 테스트 → 빌드 |
-| `npm run typecheck` | 타입 검사만 실행 |
-| `npm test` | Vitest 단위 테스트만 실행 |
-| `npm run test:desktop` | 빌드된 개발 앱의 기본 기동 검사 |
-| `npm run test:integration` | 빌드된 개발 앱의 PRE-04·M1-01~08·M2-01~07·M3-01~03 통합 검사 |
-
-GUI 검사는 자체 빌드를 수행하지 않으므로 먼저 `npm run check` 또는 `npm run build`를 실행해야 합니다. 테스트용 작업 공간·프로필·결과는 `.pre04-runs/`에 생성되고, 통합 결과와 단계별 로그는 `.pre04-runs/integration-*/`에 기록됩니다.
-
-Linux CI는 시스템 의존성 설치에 `npx playwright install-deps chromium`을 사용하고, GUI 검사는 `xvfb-run -a npm run test:integration`으로 실행합니다.
-
-## 실행 파일 패키징
-
-```sh
-npm run package
-npm run test:integration:packaged
-```
-
-`package`는 빌드를 포함하며 **현재 호스트 OS·아키텍처용 실행 폴더**를 `.pre04-runs/package-*/out/`에 생성합니다. 설치 프로그램·서명·자동 업데이트는 포함하지 않습니다. 최신 패키지의 staging 경로는 `.pre04-runs/latest-package.txt`에 기록되며 패키지 통합 검사가 이를 자동으로 읽습니다.
-
-Windows PowerShell에서 패키지 앱을 직접 실행하는 예시입니다.
-
-```powershell
-$metisPackage = Get-Content .pre04-runs/latest-package.txt
-& "$metisPackage/out/Metis-win32-x64/Metis.exe"
-```
-
-위 예시는 Windows x64 기준입니다. Linux의 패키지 GUI 검사는 `xvfb-run -a npm run test:integration:packaged`로 실행합니다.
-
-## Respect — Obsidian과 AsciiDoc
-
-Metis는 Obsidian과 AsciiDoc에서 받은 영감을 존중합니다.
-
-**Obsidian**은 Metis가 로컬 우선 접근, 사용자 소유의 지식, 문서 간 연결과 탐색, 확장 가능한 작업 환경을 고민하는 데 중요한 참고 모델입니다. 개인이 자신의 지식을 쌓고 연결하는 경험에서 배우고자 합니다.
-
-**AsciiDoc**은 Metis의 문서 철학과 원본 형식의 기반입니다. 의미를 담은 일반 텍스트, 내용과 표현의 분리, 문서의 구조화·조합·재사용이라는 가치를 존중하며, 기존 AsciiDoc 도구와 함께 사용할 수 있는 문서를 지향합니다.
-
-두 프로젝트와 생태계를 발전시켜 온 개발자와 기여자들에게 감사드립니다. Metis는 이 영감을 바탕으로 AsciiDoc에 충실한 지식 작업 환경을 만들어 가고자 합니다.
diff --git a/doc/diagrams/development-en.mmd b/doc/diagrams/development-en.mmd
new file mode 100644
index 0000000..ad6c503
--- /dev/null
+++ b/doc/diagrams/development-en.mmd
@@ -0,0 +1,6 @@
+flowchart LR
+ Install["npm ci"] --> Start["npm start"]
+ Start --> Build["Build → apps/desktop/dist"]
+ Build --> Run["Launch Electron"]
+ Run --> Edit["Close app · edit source"]
+ Edit --> Start
diff --git a/doc/diagrams/development-en.svg b/doc/diagrams/development-en.svg
new file mode 100644
index 0000000..394b900
--- /dev/null
+++ b/doc/diagrams/development-en.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/doc/diagrams/development-ko.mmd b/doc/diagrams/development-ko.mmd
new file mode 100644
index 0000000..9189b25
--- /dev/null
+++ b/doc/diagrams/development-ko.mmd
@@ -0,0 +1,6 @@
+flowchart LR
+ Install["npm ci"] --> Start["npm start"]
+ Start --> Build["빌드 → apps/desktop/dist"]
+ Build --> Run["Electron 실행"]
+ Run --> Edit["앱 종료 · 소스 수정"]
+ Edit --> Start
diff --git a/doc/diagrams/development-ko.svg b/doc/diagrams/development-ko.svg
new file mode 100644
index 0000000..9d1d58f
--- /dev/null
+++ b/doc/diagrams/development-ko.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/doc/diagrams/git-policy.mmd b/doc/diagrams/git-policy.mmd
new file mode 100644
index 0000000..974fc45
--- /dev/null
+++ b/doc/diagrams/git-policy.mmd
@@ -0,0 +1,10 @@
+flowchart LR
+ Main["main"] --> Branch["짧은 작업 브랜치"]
+ Branch --> PR["PR · Conventional Commits 제목 · type 라벨"]
+ PR --> Checks["PR 정책 · Desktop 핵심 3개 OS 검사"]
+ Checks --> Squash["대화 해결 · 최신 main · Squash"]
+ Squash --> Protected["보호된 main"]
+ Protected --> Full["수동 전체 검증 · 3개 OS · 개발 및 패키지"]
+ Full --> Tag["성공한 동일 커밋에 릴리스 태그"]
+ Tag --> Gate["동일 SHA 전체 검증 성공 확인"]
+ Gate --> Release["기존 릴리스 패키징 · 발행"]
diff --git a/doc/diagrams/git-policy.svg b/doc/diagrams/git-policy.svg
new file mode 100644
index 0000000..0e00106
--- /dev/null
+++ b/doc/diagrams/git-policy.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/doc/diagrams/philosophy-en.mmd b/doc/diagrams/philosophy-en.mmd
new file mode 100644
index 0000000..3a77ff9
--- /dev/null
+++ b/doc/diagrams/philosophy-en.mmd
@@ -0,0 +1,4 @@
+flowchart TD
+ Source["AsciiDoc standards · semantics · source files"] --> Preserve["Tool independence · long-term preservation · user ownership"]
+ Preserve --> Knowledge["Local knowledge connections · exploration"]
+ Knowledge --> Features["Convenience · extensions · AI assistance"]
diff --git a/doc/diagrams/philosophy-en.svg b/doc/diagrams/philosophy-en.svg
new file mode 100644
index 0000000..881297e
--- /dev/null
+++ b/doc/diagrams/philosophy-en.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/doc/diagrams/philosophy-ko.mmd b/doc/diagrams/philosophy-ko.mmd
new file mode 100644
index 0000000..9610baa
--- /dev/null
+++ b/doc/diagrams/philosophy-ko.mmd
@@ -0,0 +1,4 @@
+flowchart TD
+ Source["AsciiDoc 표준 · 의미 · 원본 파일"] --> Preserve["도구 독립성 · 장기 보존 · 사용자 소유권"]
+ Preserve --> Knowledge["로컬 기반 지식 연결 · 탐색"]
+ Knowledge --> Features["편의 기능 · 확장 · AI 지원"]
diff --git a/doc/diagrams/philosophy-ko.svg b/doc/diagrams/philosophy-ko.svg
new file mode 100644
index 0000000..80a414e
--- /dev/null
+++ b/doc/diagrams/philosophy-ko.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/doc/git-strategy.adoc b/doc/git-strategy.adoc
new file mode 100644
index 0000000..d3e74f2
--- /dev/null
+++ b/doc/git-strategy.adoc
@@ -0,0 +1,110 @@
+= Git 전략
+
+image::diagrams/git-policy.svg[PR 검증과 보호된 main 병합 흐름]
+
+link:diagrams/git-policy.mmd[Mermaid 원본]
+
+== 브랜치와 병합
+
+`main`에서 짧게 유지하는 작업 브랜치를 만들고 PR을 통해 병합합니다.
+브랜치 이름은 `feature/*`, `fix/*`, `refactor/*`, `docs/*`, `chore/*`를 권장하며 Codex 작업은 `codex/*`를 사용합니다.
+장기 `develop`이나 릴리스 브랜치는 현재 도입하지 않습니다.
+
+PR 제목은 Conventional Commits 형식입니다. 예: `feat(editor): add split view`, `fix: correct parsing`, `refactor(core)!: change plugin contract`.
+허용 type은 `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`, `style`, `revert`입니다.
+scope와 `!`는 선택이며, 제목은 한 줄로 작성합니다.
+Squash만 허용하여 PR 하나가 main의 커밋 하나가 됩니다. Squash 커밋 제목은 PR 제목입니다.
+
+== 변경 분류
+
+[cols="1,3",options="header"]
+|===
+|라벨 |의미
+|`type:feature` |기능 추가
+|`type:bug` |버그 수정
+|`type:breaking` |호환성을 깨는 변경
+|`type:performance` |성능 개선
+|`type:refactor` |리팩터링
+|`type:docs` |문서 변경
+|`type:chore` |빌드·CI·의존성 등 유지보수
+|===
+
+지원되는 `type:*` 라벨을 하나 이상 붙입니다. `type:breaking`은 단독 또는 다른 분류와 함께 사용할 수 있습니다.
+알 수 없는 `type:*` 라벨은 검사를 실패시킵니다. `version:*`는 필수가 아니며 변경 분류를 대신하지 않습니다.
+`area:editor`, `area:asciidoc`, `area:plugin`, `area:ui`, `area:workspace`, `area:build`는 선택 항목입니다.
+제목·라벨 변경과 코드 갱신 시 PR 정책 검사를 다시 실행합니다.
+
+== main 보호와 검증
+
+PR을 통한 병합, 대화 해결, 최신 main 반영 및 다음 GitHub Actions 검사 통과를 요구합니다.
+
+* `PR policy`
+* `Desktop core / windows-2022`
+* `Desktop core / macos-14`
+* `Desktop core / ubuntu-22.04`
+
+1인 개발을 지원하기 위해 필수 승인 리뷰 수는 0명입니다.
+main 삭제, 강제 push 및 우회 사용자를 허용하지 않으며 선형 이력을 요구합니다.
+PR 핵심 검증은 3개 OS에서 `npm run check`와 `npm run test:desktop:core`를 실행합니다.
+GUI 검사는 앱 기동, 작업 공간 열기, 문서 생성·편집·저장·재열기, AsciiDoc 미리보기, 검색과 원문 보존을 확인합니다.
+PR에서 패키징이나 전체 GUI 회귀 검사를 실행하지 않습니다. 새 커밋이 올라오면 이전 핵심 검사를 취소합니다.
+PR 코드 실행은 읽기 권한의 `pull_request` 이벤트를 사용하고 secrets를 전달하지 않습니다.
+
+새 필수 검사는 실제 PR에서 성공한 후 등록합니다. 기존 CI 실패가 있으면 원인을 해결하고 성공을 확인한 다음 활성화합니다.
+도입 시 `main-protection`과 `release-tag-protection`을 먼저 활성화합니다.
+`main-required-checks`는 최신 main을 반영한 PR의 네 검사 통과가 확인될 때까지 비활성 상태로 유지합니다.
+따라서 이 도입 기간에는 필수 검사와 최신 main 반영이 아직 강제되지 않습니다.
+
+== 버전과 릴리스
+
+`Desktop full validation`은 수동 실행만 허용합니다. 기존 31개 GUI 시나리오와 핵심 시나리오를 개발 앱과 패키지 앱에서 모두 검증하며,
+3개 OS의 타입·단위·도구 검사 및 패키징을 유지합니다. 전체 검사는 PR 병합 조건이 아니라 릴리스 조건입니다.
+
+[source,sh]
+----
+gh workflow run desktop-validation.yml --ref main
+gh run list --workflow desktop-validation.yml --event workflow_dispatch --branch main
+gh run watch RUN_ID --exit-status
+gh run view RUN_ID --json headSha,url,conclusion
+----
+
+Actions 화면에서도 `Desktop full validation`의 `Run workflow`에서 main을 선택할 수 있습니다.
+성공한 실행의 `headSha`에 태그를 만듭니다. 검사 중 main이 전진해도 새 HEAD를 대신 사용하지 않습니다.
+release workflow는 태그의 실제 커밋 SHA에 대해 main에서 수동 실행한 전체 검증 중 가장 최근 실행과 그 최신 attempt의 3개 OS 결과를 확인합니다.
+진행 중·실패·취소·skipped·누락·SHA 불일치 또는 API 오류이면 패키징을 시작하지 않습니다.
+전체 검사 자체는 태그나 릴리스를 생성하지 않습니다.
+
+릴리스가 차단되면 해당 SHA의 기존 전체 검증 실행을 `gh run rerun RUN_ID`로 전체 job 재실행해 성공시킨 뒤 릴리스의 실패 job을 재실행합니다.
+전체 검증은 최신 attempt의 3개 OS 결과를 확인하므로 실패 job만 재실행하지 않습니다.
+기존 실행이 없고 main이 아직 해당 SHA라면 main에서 전체 검증을 시작합니다.
+main이 이미 전진했고 해당 SHA의 수동 검증 실행도 없다면 태그를 이동하지 않고 현재 main을 검증한 새 버전으로 릴리스합니다.
+
+PR은 변경 의미를 기록하고 SemVer는 릴리스 시점에 관리자가 결정합니다.
+Git 태그가 릴리스 식별자이며 병합마다 버전을 올리지 않습니다.
+내부 빌드 식별, 자동 SemVer 제안, Canary/RC 및 릴리스 자동화 확대는 후속 작업입니다.
+
+현재 release workflow는 안정 SemVer `X.Y.Z` 또는 `vX.Y.Z` 태그와 `package.json` 버전 일치를 검사합니다.
+태그 생성 권한은 유지하고 아래 패턴의 기존 태그 업데이트·삭제를 금지합니다. 우회 사용자는 없습니다.
+
+[source,text]
+----
+refs/tags/[0-9]*.[0-9]*.[0-9]*
+refs/tags/v[0-9]*.[0-9]*.[0-9]*
+----
+
+이 패턴은 보호 범위를 지정하는 glob이며 엄밀한 SemVer 검증은 release workflow가 담당합니다.
+릴리스 태그의 커밋을 바꾸는 대신 새 버전으로 수정 사항을 배포합니다.
+
+== 문서 다이어그램
+
+`doc/diagrams/*.mmd`를 원본으로 관리하고 같은 이름의 SVG를 함께 갱신합니다.
+README는 SVG를 표시하고 Mermaid 원본을 연결합니다.
+의존성 설치 후 `node scripts/render-doc-diagrams.mjs`로 SVG를 재생성합니다.
+이 명령은 Playwright Chromium을 사용합니다. 최초 실행 전 `npx playwright install chromium`으로 준비합니다.
+
+== GitHub 설정 관리
+
+ruleset과 병합 설정은 `gh api`로 적용합니다.
+변경 전 설정과 전체 ruleset 응답을 로컬에 보관하고 기존 규칙을 확인하여 중복 생성하지 않습니다.
+적용 후 대상 ref, 활성 상태, 필수 검사 이름·GitHub Actions 출처 및 우회 목록을 재조회합니다.
+규칙 세부 사항은 https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets[GitHub 공식 문서]를 참고하세요.
diff --git a/package.json b/package.json
index 4bc4808..beda999 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,8 @@
"eval:ai": "node scripts/m3-04-model-eval.mjs",
"test:integration": "node scripts/integration.mjs",
"test:integration:packaged": "node scripts/integration.mjs --packaged",
- "test:tooling": "node --test scripts/integration-support.test.mjs scripts/ai-evaluation-support.test.mjs scripts/package-cleanup.test.mjs scripts/test-cache-cleanup.test.mjs scripts/virustotal-release.test.mjs"
+ "test:tooling": "node --test scripts/integration-support.test.mjs scripts/ai-evaluation-support.test.mjs scripts/package-cleanup.test.mjs scripts/test-cache-cleanup.test.mjs scripts/virustotal-release.test.mjs scripts/pr-policy.test.mjs scripts/release-validation.test.mjs",
+ "test:desktop:core": "node scripts/desktop-core.mjs"
},
"devDependencies": {
"@asciidoctor/core": "4.0.11",
diff --git a/scripts/desktop-core-smoke.mjs b/scripts/desktop-core-smoke.mjs
new file mode 100644
index 0000000..7b1a9b6
--- /dev/null
+++ b/scripts/desktop-core-smoke.mjs
@@ -0,0 +1,84 @@
+import { electron } from './test-electron.mjs';
+import { uiCommand } from './ui-command.mjs';
+import executablePath from 'electron';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import assert from 'node:assert/strict';
+
+const root = path.resolve(import.meta.dirname, '..');
+const [run, packaged] = process.argv.slice(2);
+const fixture = path.join(run, 'workspace');
+await mkdir(fixture, { recursive: true });
+const original = '\uFEFF= Original\r\n\r\nOriginal body ';
+const target = path.join(fixture, 'note.adoc');
+await writeFile(target, original);
+const env = { ...process.env, METIS_USER_DATA: path.join(run, 'profile') }; delete env.ELECTRON_RUN_AS_NODE;
+const report = { status: 'running', stage: 'launch', platform: process.platform, packaged: !!packaged, commit: process.env.GITHUB_SHA ?? null, checks: [] };
+let app, page;
+try {
+ app = await electron.launch({ executablePath: packaged || executablePath, args: packaged ? ['--smoke'] : [path.join(root, 'apps/desktop'), '--smoke'], env, timeout: 30000 });
+ page = await app.firstWindow();
+ page.setDefaultTimeout(15000);
+ page.on('dialog', dialog => { void dialog.dismiss().catch(() => {}); });
+ await page.getByRole('heading', { name: '문서가 있는 곳에서 시작하세요.' }).waitFor();
+ assert.deepEqual(await page.evaluate(() => [typeof window.require, typeof window.process, typeof window.metis.invoke, typeof window.metis.openWorkspace]), ['undefined', 'undefined', 'undefined', 'function']);
+ report.checks.push('startup and renderer isolation');
+ report.stage = 'open workspace';
+ await app.evaluate(({ dialog }, fixture) => {
+ dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [fixture] });
+ dialog.showMessageBoxSync = () => 1;
+ }, fixture);
+ await uiCommand(page, '폴더 열기');
+ await page.getByRole('button', { name: '≡note.adoc', exact: true }).click();
+ const editor = page.locator('.editor-panel:not([hidden]) .cm-content');
+ const ready = async text => {
+ await editor.waitFor();
+ await page.waitForFunction(({ text }) => {
+ const element = document.querySelector('.editor-panel:not([hidden]) .cm-content');
+ return element?.getAttribute('contenteditable') === 'true' && element.textContent.includes(text);
+ }, { text });
+ };
+ await ready('Original');
+ assert.equal(await readFile(target, 'utf8'), original);
+ report.checks.push('workspace and source open without rewriting bytes');
+ report.stage = 'edit and preview';
+ await editor.click(); await page.keyboard.press('ControlOrMeta+a');
+ await page.keyboard.insertText('= Core document\n\n== Core section\n\nmetis-core-search-token ');
+ await ready('metis-core-search-token');
+ await page.getByRole('button', { name: '분할', exact: true }).click();
+ await page.frameLocator('iframe[title="AsciiDoc 미리보기"]').getByRole('heading', { name: 'Core section', exact: true }).waitFor();
+ assert.equal(await readFile(target, 'utf8'), original);
+ report.checks.push('unsaved edit preview preserves disk source');
+ report.stage = 'save and reopen';
+ await page.getByRole('button', { name: '저장', exact: true }).click();
+ await page.getByRole('status').filter({ hasText: '저장했습니다.' }).waitFor();
+ assert.equal(await readFile(target, 'utf8'), '\uFEFF= Core document\r\n\r\n== Core section\r\n\r\nmetis-core-search-token ');
+ await page.getByRole('button', { name: 'note.adoc 탭 닫기', exact: true }).click();
+ await page.getByRole('button', { name: '≡note.adoc', exact: true }).click();
+ await ready('metis-core-search-token');
+ report.checks.push('save preserves BOM CRLF trailing spaces and reopen content');
+ report.stage = 'create document';
+ await uiCommand(page, '새 문서');
+ const create = page.getByRole('dialog', { name: '새 문서', exact: true });
+ await create.getByLabel('문서 이름 (.adoc)').fill('created.adoc');
+ await create.getByRole('button', { name: '만들기', exact: true }).click();
+ await page.getByRole('heading', { name: 'created.adoc', exact: true }).waitFor();
+ await readFile(path.join(fixture, 'created.adoc'), 'utf8');
+ report.checks.push('new document created on disk');
+ report.stage = 'search';
+ await page.getByRole('button', { name: '검색', exact: true }).click();
+ await page.getByRole('searchbox', { name: '검색어' }).fill('metis-core-search-token');
+ await page.locator('.search-result').filter({ hasText: 'note.adoc' }).first().click();
+ await page.getByRole('heading', { name: 'note.adoc', exact: true }).waitFor();
+ await ready('metis-core-search-token');
+ report.checks.push('search opens saved document');
+ report.status = 'passed'; report.stage = 'complete';
+} catch (error) {
+ report.status = 'failed'; report.error = error.stack ?? error.message;
+ console.error(report.error); process.exitCode = 1;
+} finally {
+ if (page) await page.screenshot({ path: path.join(run, report.status === 'passed' ? 'window.png' : 'failure.png') }).catch(() => {});
+ if (app) await app.close().catch(error => { report.status = 'failed'; report.closeError = error.message; process.exitCode = 1; });
+ await writeFile(path.join(run, 'results.json'), JSON.stringify(report, null, 2));
+ console.log(JSON.stringify(report));
+}
diff --git a/scripts/desktop-core.mjs b/scripts/desktop-core.mjs
new file mode 100644
index 0000000..3741ae8
--- /dev/null
+++ b/scripts/desktop-core.mjs
@@ -0,0 +1,13 @@
+import { mkdir, access, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { runLogged } from './integration-support.mjs';
+
+const root = path.resolve(import.meta.dirname, '..');
+const run = path.join(root, '.pre04-runs', `core-${Date.now()}`);
+await mkdir(run, { recursive: true });
+const result = await runLogged(process.execPath, [path.join(root, 'scripts/desktop-core-smoke.mjs'), run, ...process.argv.slice(2)], { cwd: root, logPath: path.join(run, 'core.log') });
+if (result.code !== 0 || result.error) {
+ await access(path.join(run, 'results.json')).catch(() => writeFile(path.join(run, 'results.json'), JSON.stringify({ status: 'failed', stage: 'launch', ...result }, null, 2)));
+ process.exitCode = 1;
+}
+console.log(`Core report: ${run}`);
diff --git a/scripts/integration.mjs b/scripts/integration.mjs
index 46a044b..a1881e0 100644
--- a/scripts/integration.mjs
+++ b/scripts/integration.mjs
@@ -17,7 +17,7 @@ try {
}
if (process.exitCode) { console.log(`Integration report: ${run}`); } else {
await save();
-for (const name of ['smoke', ...Array.from({ length: 8 }, (_, i) => `m1-0${i + 1}-smoke`), 'm2-01-smoke', 'm2-02-smoke', 'm2-03-smoke', 'm2-04-smoke', 'm2-05-smoke', 'm2-06-smoke', 'm2-07-smoke', 'm3-01-smoke', 'm3-02-smoke', 'm3-03-smoke', 'm3-04-smoke', 'm3-04-ai-smoke', 'm3-05-smoke', 'm3-06-smoke', 'm3-07-smoke', 'm4-02-smoke', 'm4-03-smoke', 'm4-04-smoke', 'm4-05-smoke', 'm4-06-smoke', 'm4-07-smoke', 'ux-escape-smoke']) {
+for (const name of ['desktop-core', 'smoke', ...Array.from({ length: 8 }, (_, i) => `m1-0${i + 1}-smoke`), 'm2-01-smoke', 'm2-02-smoke', 'm2-03-smoke', 'm2-04-smoke', 'm2-05-smoke', 'm2-06-smoke', 'm2-07-smoke', 'm3-01-smoke', 'm3-02-smoke', 'm3-03-smoke', 'm3-04-smoke', 'm3-04-ai-smoke', 'm3-05-smoke', 'm3-06-smoke', 'm3-07-smoke', 'm4-02-smoke', 'm4-03-smoke', 'm4-04-smoke', 'm4-05-smoke', 'm4-06-smoke', 'm4-07-smoke', 'ux-escape-smoke']) {
const started = Date.now();
report.activeSuite = name; await save();
const result = await runLogged(process.execPath, [path.join(root, 'scripts', `${name}.mjs`), ...(executable ? [executable] : [])], { cwd: root, logPath: path.join(run, `${name}.log`) });
diff --git a/scripts/pr-policy.mjs b/scripts/pr-policy.mjs
new file mode 100644
index 0000000..40bff9a
--- /dev/null
+++ b/scripts/pr-policy.mjs
@@ -0,0 +1,23 @@
+import { readFileSync } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+export const changeTypes = ['feature', 'bug', 'breaking', 'performance', 'refactor', 'docs', 'chore'];
+
+export function validatePullRequest(pr) {
+ const errors = [];
+ if (!/^(feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)(\([^()\r\n]+\))?!?: \S[^\r\n]*$/.test(pr?.title ?? '')) {
+ errors.push('Use a Conventional Commits title, e.g. feat(editor): add split view.');
+ }
+ const types = (pr?.labels ?? []).map(label => label.name).filter(name => name?.startsWith('type:'));
+ if (!types.some(name => changeTypes.includes(name.slice(5)))) errors.push('Add at least one supported type:* label.');
+ if (types.some(name => !changeTypes.includes(name.slice(5)))) errors.push(`Supported labels: ${changeTypes.map(type => `type:${type}`).join(', ')}.`);
+ return errors;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
+ if (!event.pull_request) throw new Error('Expected a pull_request event.');
+ const errors = validatePullRequest(event.pull_request);
+ for (const error of errors) console.error(error);
+ process.exitCode = errors.length ? 1 : 0;
+}
diff --git a/scripts/pr-policy.test.mjs b/scripts/pr-policy.test.mjs
new file mode 100644
index 0000000..afc2968
--- /dev/null
+++ b/scripts/pr-policy.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { validatePullRequest } from './pr-policy.mjs';
+
+const pr = (title = 'feat(editor): add split view', labels = ['type:feature']) => ({ title, labels: labels.map(name => ({ name })) });
+test('Conventional Commits titles accept scopes and breaking markers', () => {
+ for (const title of ['feat(editor): add split view', 'fix: correct parsing', 'refactor(core)!: change plugin contract', 'docs: explain Git policy']) {
+ assert.deepEqual(validatePullRequest(pr(title)), []);
+ }
+});
+test('invalid and multiline titles fail', () => {
+ for (const title of ['Update docs', 'feat:', 'feat: ', 'feat(): empty scope', 'feat: valid\nsecond line']) assert.ok(validatePullRequest(pr(title)).length);
+});
+test('a supported classification is required; area and version are not substitutes', () => {
+ for (const labels of [[], ['area:editor'], ['version:minor']]) assert.ok(validatePullRequest(pr(undefined, labels)).length);
+});
+test('unknown classifications fail even alongside a supported type', () => {
+ for (const labels of [['type:unknown'], ['type:bug', 'type:unknown']]) assert.ok(validatePullRequest(pr(undefined, labels)).length);
+});
+test('breaking may stand alone or accompany another classification', () => {
+ for (const labels of [['type:breaking'], ['type:feature', 'type:breaking', 'area:editor']]) assert.deepEqual(validatePullRequest(pr(undefined, labels)), []);
+});
+test('unrelated labels and shell metacharacters are data', () => {
+ assert.deepEqual(validatePullRequest(pr('fix: handle $(example) and `code`', ['type:bug', 'help wanted'])), []);
+});
diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs
new file mode 100644
index 0000000..5f6f21b
--- /dev/null
+++ b/scripts/release-validation.mjs
@@ -0,0 +1,40 @@
+import { pathToFileURL } from 'node:url';
+
+export const fullJobs = ['windows-2022', 'macos-14', 'ubuntu-22.04'].map(os => `Desktop full / ${os}`);
+
+export async function verifyReleaseValidation({ repository, sha, request }) {
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !/^[a-f0-9]{40}$/.test(sha)) throw new Error('Expected repository and resolved commit SHA.');
+ const base = `/repos/${repository}/actions`;
+ async function all(endpoint, key) {
+ const values = [];
+ for (let page = 1; ; page++) {
+ const data = await request(`${endpoint}${endpoint.includes('?') ? '&' : '?'}per_page=100&page=${page}`);
+ if (!Array.isArray(data[key])) throw new Error(`Invalid GitHub response: ${key}`);
+ values.push(...data[key]);
+ if (data[key].length < 100) return values;
+ }
+ }
+ const runs = await all(`${base}/workflows/desktop-validation.yml/runs?branch=main&event=workflow_dispatch&head_sha=${sha}`, 'workflow_runs');
+ const run = runs.filter(run => run.head_sha === sha && run.head_branch === 'main' && run.event === 'workflow_dispatch')
+ .sort((a, b) => b.id - a.id)[0];
+ if (!run || run.status !== 'completed' || run.conclusion !== 'success') throw new Error('Latest manual full validation for this main commit has not succeeded.');
+ if (!Number.isInteger(run.id) || !Number.isInteger(run.run_attempt) || run.run_attempt < 1) throw new Error('Invalid validation run identity.');
+ const jobs = await all(`${base}/runs/${run.id}/attempts/${run.run_attempt}/jobs`, 'jobs');
+ for (const name of fullJobs) {
+ const matches = jobs.filter(job => job.name === name);
+ if (matches.length !== 1 || matches[0].status !== 'completed' || matches[0].conclusion !== 'success') throw new Error(`Full validation missing or unsuccessful: ${name}`);
+ }
+ return run;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ try {
+ const run = await verifyReleaseValidation({ repository: process.env.GITHUB_REPOSITORY, sha: process.env.RELEASE_COMMIT,
+ request: async endpoint => {
+ const response = await fetch(`https://api.github.com${endpoint}`, { headers: { Authorization: `Bearer ${process.env.GH_TOKEN}`, Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(30000) });
+ if (!response.ok) throw new Error(`GitHub validation lookup failed: HTTP ${response.status}`);
+ return response.json();
+ } });
+ console.log(`Validated ${run.head_sha}: ${run.html_url}`);
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
+}
diff --git a/scripts/release-validation.test.mjs b/scripts/release-validation.test.mjs
new file mode 100644
index 0000000..f52d20f
--- /dev/null
+++ b/scripts/release-validation.test.mjs
@@ -0,0 +1,34 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { verifyReleaseValidation, fullJobs } from './release-validation.mjs';
+const sha = 'a'.repeat(40);
+const success = { id: 20, run_attempt: 2, head_sha: sha, head_branch: 'main', event: 'workflow_dispatch', status: 'completed', conclusion: 'success' };
+const jobs = fullJobs.map(name => ({ name, status: 'completed', conclusion: 'success' }));
+const verify = (runs = [success], entries = jobs, request) => verifyReleaseValidation({ repository: 'owner/repo', sha, request: request ?? (async url => url.includes('/jobs?') ? { jobs: entries } : { workflow_runs: runs }) });
+test('accepts the matching manual main commit and all three successful OS jobs', async () => {
+ const urls = [];
+ assert.equal((await verify(undefined, undefined, async url => { urls.push(url); return url.includes('/jobs?') ? { jobs } : { workflow_runs: [success] }; })).id, 20);
+ assert.ok(urls[1].includes('/attempts/2/jobs'));
+});
+test('rejects absent, different SHA, non-main and non-manual evidence', async () => {
+ for (const runs of [[], [{ ...success, head_sha: 'b'.repeat(40) }], [{ ...success, head_branch: 'other' }], [{ ...success, event: 'pull_request' }]]) await assert.rejects(verify(runs));
+});
+test('latest run wins; an older success cannot mask failure or an active rerun', async () => {
+ for (const conclusion of ['failure', 'cancelled', 'skipped', null]) await assert.rejects(verify([success, { ...success, id: 21, conclusion }]));
+ await assert.rejects(verify([{ ...success, status: 'in_progress' }]));
+ await assert.rejects(verify([{ ...success, run_attempt: 3, conclusion: 'failure' }]));
+});
+test('requires each OS exactly once and never accepts skipped or incomplete jobs', async () => {
+ await assert.rejects(verify(undefined, jobs.slice(1)));
+ await assert.rejects(verify(undefined, [...jobs, jobs[0]]));
+ for (const conclusion of ['failure', 'cancelled', 'skipped', null]) await assert.rejects(verify(undefined, [{ ...jobs[0], conclusion }, ...jobs.slice(1)]));
+ await assert.rejects(verify(undefined, [{ ...jobs[0], status: 'in_progress' }, ...jobs.slice(1)]));
+});
+test('API failures and malformed responses fail closed', async () => {
+ await assert.rejects(verify(undefined, undefined, async () => { throw new Error('HTTP 403'); }), /403/);
+ await assert.rejects(verify(undefined, undefined, async () => ({})), /Invalid/);
+});
+test('paginates evidence and selects the newest matching run', async () => {
+ const run = await verify(undefined, undefined, async url => url.includes('/jobs?') ? { jobs } : { workflow_runs: url.endsWith('page=1') ? Array.from({ length: 100 }, (_, id) => ({ ...success, id, head_sha: 'b'.repeat(40) })) : [success] });
+ assert.equal(run.id, 20);
+});
diff --git a/scripts/render-doc-diagrams.mjs b/scripts/render-doc-diagrams.mjs
new file mode 100644
index 0000000..a565831
--- /dev/null
+++ b/scripts/render-doc-diagrams.mjs
@@ -0,0 +1,38 @@
+import { createHash } from 'node:crypto';
+import { readdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { createServer } from 'vite';
+import { chromium } from 'playwright';
+
+const root = path.resolve(import.meta.dirname, '..');
+const directory = path.join(root, 'doc/diagrams');
+const server = await createServer({ configFile: false, root, server: { host: '127.0.0.1', port: 0 } });
+let browser;
+try {
+ await server.listen();
+ browser = await chromium.launch({ headless: true });
+ const page = await browser.newPage();
+ await page.goto(server.resolvedUrls.local[0]);
+ for (const file of (await readdir(directory)).filter(file => file.endsWith('.mmd')).sort()) {
+ const source = (await readFile(path.join(directory, file), 'utf8')).replace(/\r\n/g, '\n');
+ const id = file.replace('.mmd', '');
+ const svg = await page.evaluate(async ({ source, id }) => {
+ const { default: mermaid } = await import('/node_modules/mermaid/dist/mermaid.esm.min.mjs');
+ mermaid.initialize({ startOnLoad: false, theme: 'default', deterministicIds: true, deterministicIDSeed: id, flowchart: { htmlLabels: false }, fontFamily: 'Arial, "Malgun Gothic", sans-serif' });
+ const { svg } = await mermaid.render(id, source);
+ const document = new DOMParser().parseFromString(svg, 'image/svg+xml');
+ const element = document.documentElement;
+ const [, , width, height] = element.getAttribute('viewBox').split(/\s+/);
+ // Explicit dimensions prevent image embeds from stretching tall diagrams.
+ element.setAttribute('width', width);
+ element.setAttribute('height', height);
+ return new XMLSerializer().serializeToString(element);
+ }, { source, id });
+ const hash = createHash('sha256').update(source).digest('hex');
+ await writeFile(path.join(directory, `${id}.svg`), `\n${svg}\n`);
+ console.log(`${file} -> ${id}.svg`);
+ }
+} finally {
+ await browser?.close();
+ await server.close();
+}