@kaydash9999/banker-plugins 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.5.0"
9
+ "version": "0.6.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-07-15
4
+
5
+ ### Added
6
+ - **신규 스킬 `obsidizer`** (`target: both`): AI가 생성한 마크다운 위키를 의미 보존한 채 Obsidian 지식그래프로 정규화·상호링크·백링크한다. in-place 정규화(별도 export 트리 없음)·절대 rename 금지·LLM 의미론적 편집 + 제로-입력 결정적 캐노니컬라이저(`obsidize.mjs`) 2계층 엔진·OMC 트리에서는 bare-form(`[[slug]]`)만 허용하는 위키링크(`extractWikiLinks`가 피이프/헤딩 형태를 통째로 슬러그화해 `links[]`를 깨뜨림)·generic vault 한정 `aliases`(OMC 트리는 READ 시점 silent strip + 유일한 소비처인 피이프 링크가 금지라 destructive 실패 모드로 거부). `--enable`/`--disable`은 banker 최초의 **플러그인 선언 hook**(`hooks/hooks.json`)으로 Claude에서만 구조적으로 동작(위키 쓰기 후 원자적 쓰기 + read-back CAS로 동시쓰기를 skip)하고 Codex(MCP tool hook 없음)에서는 정직한 no-op이다. **hook은 banker가 활성화되어 있으면 항상 등록**되어 위키 쓰기(`wiki_ingest`/`wiki_add`)마다 실행되므로, `--enable`을 켜지 않은 사용자도 쓰기당 약 30ms의 node 프로세스 기동 비용을 치른다(플래그 확인 자체가 그 프로세스 내부에서 일어나기 때문); `--enable` 이전에는 아무 파일도 쓰지 않는 순수 no-op이다.
7
+ - **Deferred to 0.7.0 (품질 이유, 효율 이유 아님):** Canvas-MOC sidecar 생성 · inline Dataview `::` 생성 · MOC 허브 페이지 생성. 셋 다 in-place durable 은 이미 코드로 검증됐지만(좌표 결정성·필드 날조 방지·링크 인플레 방지 기준이 아직 없어) **생성만** 보류한다 — 호환성은 v1에서 이미 무료로 확보돼 있다.
8
+
9
+ ### Changed
10
+ - Codex 설치 스킬 수 **41 → 42**(+커맨드 2 유지, claude-only=0). `codex/manifest.json`·`README.md`·`codex/transform-matrix.md`·`scripts/smoke-test.js`·`.github/workflows/harness-setup-ci.yml`을 동기화했다.
11
+ - `package.json` `files[]`에 `hooks`를 추가해 `hooks/hooks.json`·`hooks/obsidize-hook.mjs`·`hooks/run.cjs`가 npm 패키지에 포함되도록 했다(`npm pack --dry-run`으로 확인).
12
+
3
13
  ## [0.5.0] - 2026-07-15
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  [빠른 시작](#빠른-시작) · [구성](#구성) · [설치 상세](#설치-상세-npm--codex) · [요구사항](#요구사항) · [업데이트 / 제거](#업데이트--제거) · [라이선스 / 서드파티](#라이선스--서드파티)
9
9
 
10
- banker는 QA, 보안 감사, 문서, 아키텍처, 위키 작업을 위한 스킬 41개와 의존성·개발환경(OS별) 설치 스킬을 묶은 Claude Code 플러그인입니다. 설치하면 스킬과 커맨드가 `/banker:<이름>` 네임스페이스로 노출됩니다. 이 저장소 자체가 Claude Code 마켓플레이스(`.claude-plugin/marketplace.json`)이자 플러그인(`.claude-plugin/plugin.json`, name `banker`)이며, 도구에 무관한 스킬은 Codex CLI에도 설치됩니다.
10
+ banker는 QA, 보안 감사, 문서, 아키텍처, 위키 작업을 위한 스킬 42개와 의존성·개발환경(OS별) 설치 스킬을 묶은 Claude Code 플러그인입니다. 설치하면 스킬과 커맨드가 `/banker:<이름>` 네임스페이스로 노출됩니다. 이 저장소 자체가 Claude Code 마켓플레이스(`.claude-plugin/marketplace.json`)이자 플러그인(`.claude-plugin/plugin.json`, name `banker`)이며, 도구에 무관한 스킬은 Codex CLI에도 설치됩니다.
11
11
 
12
12
  > npm 패키지(`@kaydash9999/banker-plugins`)와 GitHub 저장소(`smallOpenSource/banker_plugins`)는 같은 메인테이너가 관리합니다.
13
13
 
@@ -66,8 +66,11 @@ playwright(브라우저 QA)나 oh-my-claudecode(OMC/OMX)처럼 별도 의존성
66
66
  | `lineage` | 세션 대화를 카카오톡 스타일 단일 HTML로 export |
67
67
  | `append_wiki` | 프로젝트 위키 문서 추가/보강 |
68
68
  | `compact-wiki` | 위키 중복 제거·supersede·병합 (무손실) |
69
+ | `obsidizer` | AI 위키를 의미보존 Obsidian 그래프로 정규화·상호링크·백링크 |
69
70
  | `deep-init` | 코드베이스 전체에 계층형 AGENTS.md 문서 생성/갱신 |
70
71
 
72
+ > `obsidizer` 는 banker 최초의 플러그인 선언 hook(`hooks/hooks.json`)을 함께 설치합니다. banker가 활성화되어 있으면 이 hook은 **항상 등록**되어 위키 쓰기(`wiki_ingest`/`wiki_add`)마다 실행되고, `obsidizer --enable` 을 켜지 않은 사용자도 쓰기당 약 30ms의 node 프로세스 기동 비용을 치릅니다(플래그 확인 자체가 그 프로세스 안에서 일어나기 때문). `--enable` 전에는 아무것도 쓰지 않는 순수 no-op입니다.
73
+
71
74
  ### 스킬: 워크플로 · 유틸
72
75
 
73
76
  | 스킬 | 설명 |
@@ -125,7 +128,7 @@ banker uninstall # 제거
125
128
 
126
129
  - `--scope project` 로 프로젝트 로컬(`./.codex`)에 설치하고, `--dry-run` 으로 미리 볼 수 있습니다.
127
130
  - non-root 전용입니다(전역 sudo 설치 시 root 소유 파일을 방지). postinstall이 없으므로 `banker setup` 을 직접 실행합니다.
128
- - Codex에는 스킬 41개가 `~/.codex/skills/banker-<name>/` 에, 커맨드 2개가 `~/.codex/prompts/banker-<name>.md` 에 설치됩니다(`codex/manifest.json`). 디렉터리명과 일치하도록 프론트매터 `name:` 이 `banker-<name>` 으로 재작성되어 Codex가 `banker-<name>` 으로 인식합니다.
131
+ - Codex에는 스킬 42개가 `~/.codex/skills/banker-<name>/` 에, 커맨드 2개가 `~/.codex/prompts/banker-<name>.md` 에 설치됩니다(`codex/manifest.json`). 디렉터리명과 일치하도록 프론트매터 `name:` 이 `banker-<name>` 으로 재작성되어 Codex가 `banker-<name>` 으로 인식합니다.
129
132
  - OMC/Claude 에 결합됐던 오케스트레이터·설치·유틸 표면(all-in-one·ultra-init·front-qa·setup·setup-omc·setup-omc-hud·setup-stitch·omc-reference·compact-copy)은 본문이 **런타임 인식**이라 Codex에서도 동작합니다 — Codex에선 OMC 대신 **oh-my-codex(OMX)** 의 동명 스킬(ralplan·ralph·ultraqa·hud 등)과 `codex mcp`·내장 `/copy` 를 사용합니다(Codex는 `omx setup` 전제).
130
133
  - `~/.codex/AGENTS.md` 는 건드리지 않습니다(omx가 재생성하므로 `~/.codex/skills/` 자동 검색에 의존).
131
134
 
@@ -48,6 +48,8 @@
48
48
  { "name": "setup-sandbox", "type": "skill", "target": "both", "reason": "agent sandbox per-OS (bubblewrap+userns / AppContainer / Seatbelt) + rust/cargo + git safe.directory; Codex sandbox_mode", "supportingFiles": [] },
49
49
  { "name": "harness-factory", "type": "skill", "target": "both", "reason": "installs+configures+guides the revfactory/harness team-architecture factory plugin (Claude harness@harness-marketplace, Codex SaehwanPark/meta-harness); Apache-2.0 referenced not redistributed", "supportingFiles": [] },
50
50
 
51
+ { "name": "obsidizer", "type": "skill", "target": "both", "reason": "AI-wiki -> Obsidian graph normalize/interlink; LLM semantic edits + zero-input deterministic canonicalizer, runtime-agnostic; in-place durable", "supportingFiles": ["obsidize.mjs"], "caveat": "OMC-managed trees: 9-field frontmatter envelope + reserved files + no-rename + bare [[slug]] links only (extractWikiLinks slugifies piped/heading forms -> broken-ref); aliases written in generic vaults only; Canvas sidecars and body-inline :: are durable in-place; uses wiki_lint read-only. --enable is Claude-only (Codex has no MCP tool hooks) and is an honest no-op on Codex." },
52
+
51
53
  { "name": "front-qa", "type": "command", "target": "both", "reason": "ralplan->ralph frontend-QA pipeline; Claude=OMC, Codex=OMX", "supportingFiles": [] },
52
54
  { "name": "setup", "type": "command", "target": "both", "reason": "installer orchestrator; Claude AskUserQuestion multi-select, Codex prompt-based selection", "supportingFiles": [] }
53
55
  ]
@@ -39,3 +39,4 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
39
39
  - `ralph-qa` — needs a *different* LLM provider auth (`omc ask codex` / OMX `$ask`); falls back to same-runtime `critic` if unavailable.
40
40
  - `deep-research` — needs web search/fetch (Claude WebSearch/WebFetch or bundled workflow; Codex OMX autoresearch).
41
41
  - `smart-compact` — Claude statusLine exposes context%; Codex signal unconfirmed → heuristic fallback; `/copy`·`/compact`·paste stay user-driven (TUI).
42
+ - `obsidizer` — OMC-managed trees respect the 9-field frontmatter whitelist + reserved files + no-rename + bare `[[slug]]` links only; `aliases` are written in generic vaults only; Canvas sidecars and body-inline `::` are durable in-place; no `wiki_*` write dependency, `wiki_lint` read-only for verification. `--enable` is Claude-only (plugin-declared PostToolUse hook); Codex has no MCP tool hooks → honest no-op.
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "banker obsidizer: canonicalize an OMC wiki page right after it is written, but only when the vault is armed via `obsidizer --enable`. Inert otherwise.",
3
+ "hooks": {
4
+ "PostToolUse": [
5
+ {
6
+ "matcher": "^mcp__.*wiki_(ingest|add)$",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"$CLAUDE_PLUGIN_ROOT\"/hooks/run.cjs \"$CLAUDE_PLUGIN_ROOT\"/hooks/obsidize-hook.mjs",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker obsidizer PostToolUse hook — thin gate + dispatcher.
4
+ *
5
+ * Fires after an OMC wiki write (the matcher lives in hooks.json) and canonicalizes only
6
+ * the page(s) that write touched, by delegating to skills/obsidizer/obsidize.mjs. There is
7
+ * exactly one canonicalizer: no frontmatter/link/tag logic belongs in this file, and the
8
+ * armed hook and a bare `obsidizer` run therefore apply a byte-identical ruleset.
9
+ *
10
+ * Invariants (each is enforced by a test in obsidize-hook.test.mjs):
11
+ * - Pure filesystem. It never calls back into an MCP tool — this hook fires ON wiki
12
+ * writes, so issuing one would re-enter and loop. No tool identifier appears below.
13
+ * - Armed by <wikiDir>/.obsidizer. Absent => exit 0 having changed nothing, logged nothing.
14
+ * - Atomicity and read-back CAS live inside the canonicalizer, so both entry points
15
+ * inherit them. This file must not defeat them: it never pre-processes a page and
16
+ * never writes one itself.
17
+ * - Exits 0 on every path. A hook cannot block or alter a tool result and must not try.
18
+ * - Every error appends exactly one line to <wikiDir>/.obsidizer-hook.log. Exit 0 hides
19
+ * stderr too, so that log is the only channel by which a permanently broken hook is
20
+ * discoverable rather than silently doing nothing forever.
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+ import { appendFileSync, existsSync, readdirSync, statSync } from 'node:fs';
24
+ import { basename, dirname, join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const CANONICALIZER = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'obsidizer', 'obsidize.mjs');
28
+ const FLAG = '.obsidizer';
29
+ const LOG = '.obsidizer-hook.log';
30
+ // OMC owns these three. index.md is rewritten on every ingest, so it is always the freshest
31
+ // file in the vault — the scan fallback below would target it first without this guard.
32
+ const RESERVED = new Set(['index.md', 'log.md', 'environment.md']);
33
+ // Scan fallback: how recently a page must have changed to count as "this write touched it".
34
+ const RECENT_MS = 10_000;
35
+ // Stay inside the `timeout: 5` declared in hooks.json.
36
+ const CANONICALIZE_TIMEOUT_MS = 4000;
37
+
38
+ function readStdin() {
39
+ return new Promise((resolvePromise, rejectPromise) => {
40
+ let data = '';
41
+ process.stdin.setEncoding('utf8');
42
+ process.stdin.on('data', (chunk) => { data += chunk; });
43
+ process.stdin.on('end', () => resolvePromise(data));
44
+ process.stdin.on('error', rejectPromise);
45
+ });
46
+ }
47
+
48
+ function log(wikiDir, tool, reason) {
49
+ const line = `${new Date().toISOString()}\t${tool}\t${String(reason).replace(/\s+/g, ' ').trim()}\n`;
50
+ try {
51
+ appendFileSync(join(wikiDir, LOG), line);
52
+ } catch {
53
+ // The log is best-effort; the failure path must never throw.
54
+ }
55
+ }
56
+
57
+ function isDir(path) {
58
+ try { return statSync(path).isDirectory(); } catch { return false; }
59
+ }
60
+
61
+ function wikiDirCandidates(root) {
62
+ const dirs = [];
63
+ // OMC relocates state to <OMC_STATE_DIR>/<projectId>/wiki. projectId is an OMC internal
64
+ // we must not reimplement, so enumerate the candidates instead of deriving the name.
65
+ const stateDir = process.env.OMC_STATE_DIR;
66
+ if (stateDir) {
67
+ try {
68
+ for (const entry of readdirSync(stateDir)) dirs.push(join(stateDir, entry, 'wiki'));
69
+ } catch {
70
+ // Unreadable OMC_STATE_DIR — fall through to the default layout.
71
+ }
72
+ }
73
+ if (root) dirs.push(join(root, '.omc', 'wiki'));
74
+ return dirs;
75
+ }
76
+
77
+ // The flag both arms the hook and identifies which vault it may touch.
78
+ function findArmedWikiDir(root) {
79
+ return wikiDirCandidates(root).find((dir) => isDir(dir) && existsSync(join(dir, FLAG))) ?? null;
80
+ }
81
+
82
+ function namesFromResponse(toolResponse) {
83
+ // The payload carries no file path; the tool's own response text names the slugs it wrote
84
+ // ("- Created: a.md" / "Path: .omc/wiki/a.md"). Stringify first — the response arrives as
85
+ // text or as a structured content array depending on how the server is wired.
86
+ const text = typeof toolResponse === 'string' ? toolResponse : JSON.stringify(toolResponse ?? '');
87
+ return [...new Set([...text.matchAll(/[\w.-]+\.md/g)].map((match) => match[0]))];
88
+ }
89
+
90
+ function namesFromScan(wikiDir) {
91
+ const cutoff = Date.now() - RECENT_MS;
92
+ return readdirSync(wikiDir).filter((file) => {
93
+ if (!file.endsWith('.md')) return false;
94
+ try { return statSync(join(wikiDir, file)).mtimeMs >= cutoff; } catch { return false; }
95
+ });
96
+ }
97
+
98
+ function resolvePages(wikiDir, toolResponse) {
99
+ const names = namesFromResponse(toolResponse);
100
+ return (names.length > 0 ? names : namesFromScan(wikiDir))
101
+ .filter((name) => !RESERVED.has(name))
102
+ .map((name) => join(wikiDir, name))
103
+ .filter((path) => existsSync(path));
104
+ }
105
+
106
+ async function main() {
107
+ let payload = null;
108
+ let stdinError = null;
109
+ try {
110
+ payload = JSON.parse(await readStdin());
111
+ } catch (err) {
112
+ stdinError = err;
113
+ }
114
+
115
+ // A write can be redirected with workingDirectory; fall back to the session cwd.
116
+ const root = payload?.tool_input?.workingDirectory || payload?.cwd || process.cwd();
117
+ const wikiDir = findArmedWikiDir(root);
118
+ if (!wikiDir) return; // Not armed: change nothing, log nothing.
119
+
120
+ const tool = payload?.tool_name ?? 'unknown';
121
+ try {
122
+ if (stdinError) return log(wikiDir, tool, `unreadable hook payload: ${stdinError.message}`);
123
+ if (!existsSync(CANONICALIZER)) return log(wikiDir, tool, `canonicalizer missing at ${CANONICALIZER}`);
124
+
125
+ for (const page of resolvePages(wikiDir, payload.tool_response)) {
126
+ const run = spawnSync(process.execPath, [CANONICALIZER, page, '--profile=omc', '--json'], {
127
+ encoding: 'utf8',
128
+ timeout: CANONICALIZE_TIMEOUT_MS,
129
+ windowsHide: true,
130
+ });
131
+ if (run.error) {
132
+ log(wikiDir, tool, `${basename(page)}: canonicalizer did not run: ${run.error.message}`);
133
+ } else if (run.status !== 0) {
134
+ log(wikiDir, tool, `${basename(page)}: canonicalizer exited ${run.status}: ${run.stderr || run.stdout}`);
135
+ } else {
136
+ try {
137
+ JSON.parse(run.stdout);
138
+ } catch {
139
+ log(wikiDir, tool, `${basename(page)}: canonicalizer report was not json`);
140
+ }
141
+ }
142
+ }
143
+ } catch (err) {
144
+ log(wikiDir, tool, `unexpected: ${err.message}`);
145
+ }
146
+ }
147
+
148
+ main()
149
+ .catch(() => {}) // An un-canonicalized page is not-yet-formatted; the next bare run repairs it.
150
+ .finally(() => process.exit(0));
package/hooks/run.cjs ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * banker cross-platform hook runner.
5
+ *
6
+ * Re-spawns the target hook script with process.execPath — the Node binary already
7
+ * running this file — instead of letting the hook's shell resolve `node` from PATH.
8
+ * nvm/fnm shims and Windows both fail that resolution; OMC hit it in hooks (#909,
9
+ * #899, #892, #869) and answers it with the same pattern.
10
+ *
11
+ * Never blocks a tool: exits 0 whenever it cannot run the target.
12
+ *
13
+ * Usage (hooks.json):
14
+ * node "$CLAUDE_PLUGIN_ROOT"/hooks/run.cjs "$CLAUDE_PLUGIN_ROOT"/hooks/<hook>.mjs [args...]
15
+ */
16
+ const { spawnSync } = require('child_process');
17
+ const { existsSync, realpathSync } = require('fs');
18
+
19
+ const target = process.argv[2];
20
+ if (!target) process.exit(0);
21
+
22
+ function resolveTarget(scriptPath) {
23
+ if (existsSync(scriptPath)) return scriptPath;
24
+ try {
25
+ // A plugin update can leave CLAUDE_PLUGIN_ROOT pointing at a version dir that is now
26
+ // a symlink; realpath follows it.
27
+ const real = realpathSync(scriptPath);
28
+ if (existsSync(real)) return real;
29
+ } catch {
30
+ // realpathSync throws when the path is gone entirely — expected, fall through.
31
+ }
32
+ return null;
33
+ }
34
+
35
+ const resolved = resolveTarget(target);
36
+ if (!resolved) process.exit(0);
37
+
38
+ // stdio: 'inherit' hands our stdin to the child — that is how the hook payload reaches it.
39
+ const result = spawnSync(process.execPath, [resolved, ...process.argv.slice(3)], {
40
+ stdio: 'inherit',
41
+ env: process.env,
42
+ windowsHide: true,
43
+ });
44
+
45
+ process.exit(result.status ?? 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -16,7 +16,9 @@
16
16
  "commands",
17
17
  ".claude-plugin",
18
18
  "codex",
19
+ "hooks",
19
20
  "bin",
21
+ "!**/*.test.mjs",
20
22
  "LICENSE",
21
23
  "README.md",
22
24
  "CHANGELOG.md"
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: obsidizer
3
+ description: "(banker) AI 생성 마크다운 위키를 의미 보존한 채 Obsidian 지식그래프로 정규화·상호링크·백링크. '--enable'/'--disable' 로 이후 위키 생성 자동 정규화 켜기/끄기(Claude 한정). 'obsidizer'/'옵시디언화'/'obsidian 최적화'/'위키 그래프 정리' 시 사용."
4
+ ---
5
+
6
+ # obsidizer — AI 위키를 Obsidian 지식그래프로 정규화
7
+
8
+ AI가 생성한 마크다운 위키를 **의미 보존**한 채 그래프 관점(`tags`·`[[slug]]` 링크·`links[]`
9
+ 백링크·Related 푸터)에서 정규화한다 — "vault over file", 노트 하나가 아니라 그래프 속 자리를
10
+ 최적화한다. 의미 판단(어떤 언급이 진짜 링크인지, 어떤 섹션이 진짜 중복인지)은 LLM이 파일을 직접
11
+ 편집해 내리고, **결정적 캐노니컬라이저**(`obsidize.mjs`)가 마지막 기계적 패스로 바이트 단위 결과를
12
+ 고정한다. `wiki_*` 쓰기 의존 없음(검증에만 `wiki_lint` 읽기 전용 사용).
13
+
14
+ ## ⚠️ 정직한 실현 한계
15
+
16
+ `--enable`은 "이후 모든 위키 생성이 obsidizer 양식을 따른다"를 뜻하지 않는다. 실제 경계는 세 갈래다:
17
+
18
+ - **깊이 — 기계적 정규화만, 의미 보강은 절대 자동화하지 않는다.** `--enable`은 프론트매터
19
+ envelope·`tags`·줄바꿈/헤딩·`links[]` 정렬·Related 푸터 같은 **기계적 규칙**만 자동 적용한다.
20
+ 어떤 언급을 링크할지, 어떤 백링크가 진짜인지는 자동화 대상이 아니다.
21
+ ⚠️ hook은 실제로 `hookSpecificOutput.additionalContext`로 LLM에 지시를 주입**할 수 있다**(OMC
22
+ 자체 hook 10개가 이미 이 필드를 쓴다 — "hook은 LLM을 못 돌린다"는 말은 틀렸다). obsidizer는 이
23
+ 경로를 **품질 근거로 거부한다**(불가능이 아니라 선택): (1) soft·assistant-honored일 뿐 강제가
24
+ 아님, (2) 미리보기 없는 자동 변형 → dry-run-기본 원칙과 정면 충돌, (3) 자동 경로에 LLM 판단이
25
+ 들어가 결정성 축이 깨짐, (4) 세션을 가로챔(저장만 요청했는데 그래프 큐레이션이 끼어듦).
26
+ - **런타임 — Claude 전용.** Codex CLI엔 MCP 도구 hook 자체가 없다(PostToolUse=Bash·apply_patch
27
+ 만). `--enable`/`--disable`은 Codex에서 **정직한 no-op**: 명확한 메시지만 내고, 아무도 읽지
28
+ 않을 플래그를 쓰지 않으며, 성공을 보고하지도 않는다.
29
+ - **🔴 커버리지 — session-log 페이지는 절대 걸리지 않는다.** `session-hooks.ts:133`이 MCP 도구
30
+ 없이 `writePageUnsafe`를 직접 호출 → `PostToolUse` 이벤트 자체가 없다 → hook이 **영원히 안
31
+ 켜진다**. session-log는 이 vault에서 **가장 흔한 페이지 유형**(오늘 12개 중 3개, 세션마다 +1).
32
+ `--enable`의 약속은 **`wiki_ingest`/`wiki_add`로 쓰인 페이지에 한정**된다. session-log는 bare
33
+ `obsidizer` 실행으로만 정규화된다.
34
+
35
+ | | `--enable`로 자동 | bare `obsidizer` 필요 |
36
+ |---|---|---|
37
+ | 기계적 규칙 (`wiki_ingest`/`wiki_add` 경로) | ✅ | — |
38
+ | 기계적 규칙 (`session-log-*` 페이지) | ❌ 절대 안 걸림 | ✅ |
39
+ | 의미 보강 (링크·백링크·중복 판단) | ❌ (거부, 한계 아님) | ✅ |
40
+
41
+ ## 런타임 배선
42
+ - **엔진은 동일**: Claude·Codex 모두 LLM이 파일을 직접 편집(Read/Glob/Edit/Write)하고, 마지막에
43
+ `obsidize.mjs`(zero-dep 순수 Node 스크립트)가 기계적 패스로 마무리한다. `wiki_*` 쓰기 의존이
44
+ 없어 두 런타임에 동형으로 동작한다 — `target:both`가 정직한 이유.
45
+ - **기본 타깃이 다르다**: Claude → 감지된 `.omc/wiki/`. Codex/OMX → 사용자가 지정한 vault 경로
46
+ (자동 감지 대상이 없음).
47
+ - **제약 프로파일이 조직 축이다.** 타깃을 감지 즉시 **OMC-managed 위키**(`index.md`+`log.md`+
48
+ 9-필드 프론트매터 시그니처)와 **일반 Obsidian vault**로 분류하고, 이 분류가 이후 전부를
49
+ 게이팅한다: 프론트매터 envelope(9-key 한정 vs 자유) · 위키링크 형식(bare-only vs piped 허용) ·
50
+ reserved files · no-rename · `aliases`(OMC=거부, 일반=작성).
51
+ - **검증은 읽기 전용**: OMC-managed일 때만 `wiki_lint`를 ToolSearch로 로드해 before/after 베이스라인
52
+ 대조에 쓴다. **쓰기 의존은 없다** — `wiki_ingest`/`wiki_add`를 호출하지 않고 파일을 직접
53
+ 정규화한다.
54
+
55
+ ## 모드
56
+
57
+ **1) bare `obsidizer [path]` — 리뷰 & 수정 (기본 = dry-run)**
58
+ 1. **Phase 0 — 타깃 감지**: 경로 인자 우선, 없으면 `.omc/wiki/` 감지, 둘 다 없으면 중단. 프로파일
59
+ 분류(위 참조). OMC-managed면 `wiki_lint` 베이스라인 기록 + `<wikiDir>/.obsidizer-hook.log` 존재
60
+ 시 읽음.
61
+ 2. **Phase 1 — 전체 vault 스캔(읽기 전용)**: 모든 `.md` glob(reserved 파일은 개념맵용으로만 읽고
62
+ 변경 대상에서 제외). 프론트매터/제목/H1/헤딩/본문 링크/`tags`/`links[]`/Related 푸터 파싱 →
63
+ 슬러그 인덱스·H1 인덱스·링크 그래프·태그 어휘·orphan 집합으로 vault 모델 구축.
64
+ 3. **Phase 2 — 의미 분석(LLM, dry-run에선 쓰기 없음)**: 페이지별로 어떤 언급을 기존 슬러그에
65
+ 링크할지, 어떤 역방향 백링크가 진짜인지, 어떤 섹션이 진짜 중복인지 판단. Canvas-MOC·inline-`::`·
66
+ MOC-hub **생성**은 0.7.0 보류 — 이번 판에서 적용하지 않음.
67
+ 4. **Phase 3 — 미리보기(기본)**: 페이지별 변경 요약 + vault 카운트 제시. `--apply` 없이는 그대로
68
+ 종료.
69
+ 5. **Phase 4 — 안전 뮤테이션(`--apply` 시)**: 스냅샷(`cp -r`) → LLM이 의미 편집 적용 → **그 다음
70
+ `obsidize.mjs`가 최종 캐노니컬라이즈 1회** 실행. reserved 파일은 손대지 않음. rename 없음.
71
+ 6. **Phase 5 — 검증**: `wiki_lint` 재실행(신규 broken-ref 0, orphan ≤ 베이스라인), 슬러그 집합
72
+ 불변 확인, OMC 프로파일이면 bare-form 전용 grep + PM-1 프론트매터 grep, 캐노니컬라이저 멱등
73
+ 자가검사.
74
+
75
+ **2) `obsidizer --enable` / `--disable` — 지속 정규화 (Claude 전용, structural)**
76
+ banker의 **첫 플러그인 선언 hook**(`hooks/hooks.json`)이 OMC 위키 쓰기 도구(`wiki_ingest`/
77
+ `wiki_add`)에 `PostToolUse`로 걸려 항상 등록되어 있다. **`<wikiDir>/.obsidizer` 플래그 파일이
78
+ 스위치**다 — `--enable`은 이 파일을 쓰고 `--disable`은 지운다, 그게 전부다(`settings.json` 변경
79
+ 없음). 플래그가 없으면 hook은 즉시 exit 0. 방금 banker를 설치/갱신한 세션이면 완료 보고에
80
+ **`/reload-plugins`** 실행을 안내한다(hot-reload 경로). Codex에서는 위 한계 섹션대로 정직한 no-op.
81
+
82
+ ## 변환 규칙
83
+
84
+ | # | 변환 | v1 동작 | Durable | 레이어 |
85
+ |---|---|---|---|---|
86
+ | T1 | H1==title 단일화, 헤딩레벨 스킵 금지, 빈줄 정규화 | 적용 | 대체로(멱등) | canonicalizer |
87
+ | T2 | `tags` 정규화(선행 `#` 제거·dedup·결정적 순서, Unicode 과잉 제거 금지) | 적용 | YES | canonicalizer |
88
+ | T3 | 본문 `[[slug]]` 보강 — 기존 슬러그만, ghost node 금지. **OMC 프로파일: bare form만** | 적용 | YES | LLM(의미) |
89
+ | T4 | `links[]` 상호성 — 누락된 진짜 역방향 백링크 추가 → de-orphan. 본문의 해소 가능한 링크는 canonicalizer가 기계적으로 흡수(monotonic — 늘기만 하고 삭제 없음) | 적용 | YES | LLM 결정·canonicalizer 기록 |
90
+ | T5 | 페이지 내 섹션 dedup — 바이트 동일 중복 블록만 축약, 고유 사실 전량 보존 | 적용 | YES(재-ingest 전까지) | LLM(의미) |
91
+ | T6 | Related 푸터(OMC 프로파일 한정) — 본문의 해소 가능한 링크를 `links[]`에 흡수(T4)한 **뒤**, 그 해소 가능한 부분집합을 정렬·dedup·bare form으로 렌더. cap은 **추가분만** 제한(기존 푸터 링크는 캡 초과여도 유지 — 아래 안전 울타리) | 적용 | YES | canonicalizer |
92
+ | T7 | rename | **안 함** | n/a | — |
93
+ | T8 | `aliases` | 일반 vault만 적용(아래 별칭 정책) · OMC 트리 거부 | 일반 YES · OMC NO(destructive) | canonicalizer |
94
+ | T9 | inline `Key:: Value` 생성 | 0.7.0 보류(생성만); v1은 보존만 | YES(이미 durable) | — |
95
+ | T10 | Canvas-MOC 사이드카 생성 | 0.7.0 보류(생성만); v1은 보존만 | YES(이미 durable) | — |
96
+ | T11 | Excalidraw | **매 버전 보존만**, 절대 생성 안 함 | n/a(스키마 OPEN) | — |
97
+ | T12 | MOC 허브 페이지 생성 | 0.7.0 보류 | (ingest되면 durable) | — |
98
+
99
+ **T3 프로필 주석 (가독성 비용, 정직히 명시).** OMC 트리에선 mid-prose 슬러그 삽입보다 **Related
100
+ 푸터(T6)를 우선한다** — piped link가 금지라 문장 중간에 `claude-code-plugin-skill-...` 같은 원시
101
+ 슬러그가 끼면 가독성이 떨어진다. 그래프 엣지는 어디 있든 동일한 값이므로 푸터가 무비용으로 같은
102
+ 엣지를 전달한다. **일반 vault**에선 piped link가 허용되므로 mid-prose 링크가 가독성 있고 오히려
103
+ 선호된다.
104
+
105
+ ## 별칭(aliases) 정책
106
+
107
+ **일반 vault → v1 적용.** 캐노니컬라이저가 쓴다(기계적 ⇒ 결정적 ⇒ 바이트 테스트 가능). 유일한
108
+ 출처는 노트 자신의 **H1** — 절대 날조하지 않는다. 아래 6조건을 **모두** 만족해야
109
+ `aliases: ["<H1 텍스트>"]`를 쓴다:
110
+
111
+ | # | 조건 | 이유 |
112
+ |---|---|---|
113
+ | 1 | 프로파일 == 일반 vault | OMC 트리에선 항상 거부 |
114
+ | 2 | 프론트매터 블록 존재 + H1 정확히 1개 | 없는 블록을 새로 만들지 않음 |
115
+ | 3 | `norm(H1) !== norm(basename)` (의미 있게 다름) | 대소문자/구두점 차이는 무가치 |
116
+ | 4 | `norm(H1)`이 다른 노트의 basename과 겹치지 않음 | 별칭이 실제 파일을 가리는 것 방지 |
117
+ | 5 | H1 텍스트가 vault 전체에서 유일함 | N-way 충돌 방지 |
118
+ | 6 | 기존 `aliases` 키 없음 | 사용자 콘텐츠 보존 + 고정점 유지 |
119
+
120
+ `norm(x)` = 소문자화 → `[^a-z0-9]+`를 `-`로 축약 → 앞뒤 `-` 제거(64자 cap **없음** — OMC
121
+ `titleToSlug`의 cap을 흉내내면 긴 제목의 유효한 별칭을 부당하게 억제한다). 항상 **큰따옴표로
122
+ quote**(YAML 이스케이핑, `[[…]]` 오파싱 방지) + **FLOW 형식**(`aliases: ["X"]`) + 기존 키·순서를
123
+ 그대로 둔 채 **맨 끝에 추가**.
124
+
125
+ **OMC-managed 트리 → 쓰지 않음. 거부이지 보류가 아니다.** 이 키는 `parseFrontmatter`가 9-key를
126
+ 재구성하는 **READ 시점**에 조용히 사라지는 **silent loss**이고, 유일한 소비처(piped link)가
127
+ `links[]`를 오염시키는 **silent corruption**이다 — 파괴적 실패 모드는 어떤 노력으로도 정당화되지
128
+ 않는다. (측정상 OMC 트리에도 유용하고 충돌 없는 별칭 후보가 실제 존재한다 — 64자 cap에 잘린 H1
129
+ 1건이 오늘 살아있고, CJK 제목의 해시 폴백은 구조적으로 발생 가능. 그럼에도 거부하는 이유는 정확히
130
+ 이것 — 유용성이 있어도 실패가 파괴적이면 쓰지 않는다.)
131
+
132
+ **`cssclasses` → 어느 프로파일에서도 쓰지 않음.** OMC 트리에선 같은 destructive strip. 일반
133
+ vault에서도 문서 안에 출처가 없는 값이라 쓰면 **날조**가 된다 — 어떤 버전에서도 정당화되지 않는다.
134
+
135
+ ## 결정성·멱등
136
+
137
+ - **고정 변환 순서**: LLM 의미 편집(T3/T4-결정/T5)이 먼저 전부 끝나고, 그 다음 캐노니컬라이저가
138
+ T1→T2→T4-기록→T6→T8-일반 순으로 적용.
139
+ - **프론트매터 순서**: OMC 프로파일은 9-필드 고정 순서 보존(`serializePage`와 다투지 않음). 일반
140
+ 프로파일은 기존 키·순서를 그대로 두고 `aliases`만 맨 끝에 조건부 추가.
141
+ - **타임스탬프 무잡음**: `created`는 절대 안 건드림. `updated`는 직렬화된 내용이 실제로 바뀔 때만
142
+ (바이트 diff 게이트) 갱신 — no-op 페이지는 `updated`도 그대로.
143
+ - **하위 정렬도 결정적**: Related 슬러그·`tags`·`links[]` 모두 안정적 키로 정렬·dedup.
144
+ - **멱등 = 구조로 보장**: 캐노니컬라이저는 고정점(정규화된 입력 → 동일 바이트)이라 2회차 실행은
145
+ 마커 없이도 diff 0. `.obsidizer-state.json`(슬러그→정규화해시)은 속도 최적화일 뿐 정확성에
146
+ 의존하지 않는 선택적 캐시.
147
+ - **보존 전용 표면**: `## Update (<ts>)` 섹션·body-inline `Key:: Value`·sibling `.canvas`·
148
+ `.excalidraw.md`는 절대 재포맷/재작성하지 않는다.
149
+ - **bare-form-only(OMC 프로파일)**: 캐노니컬라이저와 LLM 모두 `[[slug]]`만 쓴다.
150
+ - **결정성 주장의 범위**: 바이트 멱등은 **캐노니컬라이저에만** 귀속(armed hook도 동일 코드라 포함).
151
+ **의미** 판단(링크·dedup 선택)은 설계상 실행마다 달라질 수 있어 **Unknown**으로 보고하며, 절대
152
+ 바이트로 단언하지 않는다.
153
+
154
+ ## 안전 울타리
155
+
156
+ - **기본 = dry-run.** `--apply` 전까지 아무것도 쓰지 않는다. 미리보기 없는 자동 변형은 금지(위
157
+ 한계 섹션에서 hook 경로를 거부하는 이유이기도 하다).
158
+ - **`--apply` 전 필수 스냅샷**: `cp -r <vault> <vault>.bak-<ts>` (compact-wiki와 동일 선례).
159
+ - **모든 쓰기는 원자적**: 유니크 temp(`randomUUID`) + `wx`(배타 생성) + `fsync` + `rename`. 절대
160
+ in-place bare write가 아니다 — OMC 자신의 `atomicWriteFileSync`와 같은 형태를, 빌려 쓰지 않고
161
+ 독립 구현한다. **read-back CAS는 hook이 아니라 캐노니컬라이저에 있고, 두 진입점(bare 실행·armed
162
+ hook)이 함께 상속**한다: temp를 먼저 다 쓰고 → 재읽어 바뀌었으면 skip → `rename`.
163
+ **`rename`은 torn read를 확실히 없앤다. CAS는 lost update의 창을 `rename` 한 번으로 줄일 뿐
164
+ 없애지 못한다** — 그 창에 낀 writer는 여전히 덮인다. 이 잔여 race는 **해결이 아니라 수용**이다:
165
+ 닫으려면 상대가 지켜주는 lock이 필요한데 OMC의 내부 `.wiki-lock`에 기대는 건 거부한다(남의
166
+ 플러그인 내부 파일은 이름이 바뀌는 날 조용히 깨진다). 없앴다고 읽지 말 것.
167
+ - **해소 가능한 bare-form 링크는 삭제하지 않는다**(두 예외가 아래 (1)·(2)이고, 제목이 그 둘을 이미
168
+ 배제하도록 좁혀 쓴 것이다): 푸터를 다시 그리기 **전에** 본문의 해소 가능한(on-disk)
169
+ 위키링크를 `links[]`에 **합집합으로 흡수**한다(`[[a|A]]`·`[[a#S]]`도 대상 `a`로 흡수. 코드펜스
170
+ 안은 제외 — Obsidian이 엣지로 치지 않으므로 흡수하면 없는 엣지를 만드는 셈이다). 푸터는 `links[]`
171
+ 만 보고 렌더링되므로, 이 흡수가 없으면 `links[]`에 없던 **수기 푸터 링크**가 조용히 사라진다
172
+ (OMC가 쓴 푸터는 `links[]` ⊇ 푸터가 항상 성립해 안 걸리고, 사람이 더한 링크만 걸린다).
173
+ `links[]` 항목은 어느 경로에서도 지우지 않으며(정렬·dedup만), 본문 중간 링크도 손대지 않는다.
174
+ 다시 그리는 건 푸터 줄 하나뿐이고(OMC 프로파일 한정 — 일반 vault에선 푸터를 렌더링조차 하지
175
+ 않으므로 있는 그대로 둔다), 거기서 링크가 빠지는 경우는 둘 — (1) 대상 파일이 없는 **broken-ref**,
176
+ (2) OMC 프로파일에서 **bare slug가 아닌 형태**(D8: `[[Weird Name]]`은 OMC `extractWikiLinks`가
177
+ `weird-name.md`로 슬러그화해 phantom을 만든다).
178
+ (1)은 "해소 가능한 링크 삭제"에 해당하지 않는다 — 가리키는 파일이 없으니 없앨 엣지 자체가 없고,
179
+ broken-ref 0은 검증 목표다. `links[]`에 같은 대상의 올바른 슬러그가 있으면 그게 대신 렌더돼
180
+ 결과적으로 **수리**가 된다(측정 확인: 깨진 `[[…-automation-ceiling]]` → 실재하는
181
+ `[[…-automation-ceil]]`). 없으면 그냥 빠진다. 캐노니컬라이저가 하는 일이라 **OMC 트리에서도**
182
+ 일어나며, bare 실행은 `--apply` 게이트 뒤에 있지만 **무장된 hook 경로엔 미리보기가 없다**.
183
+ (2)에서 살아남는 건 **OMC 그래프 하나뿐이다** — `links: ["Weird Name.md"]`는 따옴표 친 YAML
184
+ 문자열일 뿐 Obsidian이 엣지로 읽는 `[[…]]`가 아니다. 즉 **OMC 엣지(`links[]`)는 남고 Obsidian
185
+ 엣지는 사라진다**(본문 중간에 같은 링크가 또 있으면 거긴 손대지 않으므로 그쪽 Obsidian 엣지는
186
+ 유지된다). OMC가 만드는 파일명은 전부 bare slug라 (2)의 대상은 사람이 non-slug 파일명을 직접 넣은
187
+ 경우뿐이다.
188
+ **`FOOTER_CAP`(12)은 세 번째 경우가 아니다** — 캡은 obsidizer가 푸터에 **새로 더하는 양**만
189
+ 제한하고, 이미 푸터에 있는 해소 가능한 링크는 캡을 넘겨도 전부 유지한다(수기 푸터 15개 → 15개
190
+ 그대로). 그래서 `links[]`는 20인데 푸터엔 12만 보이는 상태가 정상이다: `links[]`는 OMC 그래프의
191
+ durable 전량이고 푸터는 그 위에 얹은 **표현**이며, 캡은 표현 예산이지 엣지 예산이 아니다.
192
+ - **본문은 재작성하지 않는다**: 추가·포맷만 하고 산문을 고쳐쓰지 않는다.
193
+ - **rename 없음**: 절대적. 예쁜 표시 이름이 필요해도 하지 않는다.
194
+ - **reserved 파일은 in-place 편집하지 않는다**: `index.md`/`log.md`/`environment.md`.
195
+ - **OMC 프로파일은 9-key envelope에 갇힌다**: 그 외 프론트매터 키는 절대 추가하지 않는다(body
196
+ `::`와 `.canvas`는 이 울타리 밖 — durable하므로 허용).
197
+ - **일반 프로파일은 사용자 콘텐츠를 덮지 않는다**: 기존 `aliases` 보존, 프론트매터 키 순서 보존,
198
+ 없는 프론트매터 블록을 새로 만들지 않음.
199
+ - **`%%obsidizer:ignore%%` 옵트아웃**: 파일 어디든 이 마커가 있으면 **프로파일 무관** 그 페이지를
200
+ 통째로 건너뛴다 — 읽기-수정-쓰기를 아예 하지 않고 리포트에 `ignored`로 집계한다. 건너뛴 페이지도
201
+ vault 모델에는 그대로 남는다(슬러그가 링크를 해소하고, H1이 다른 노트의 별칭 조건을 계속
202
+ 게이팅한다) — "고치지 마라"는 "없는 셈 쳐라"가 아니다. Obsidian은 `%%…%%`를 주석으로 렌더링하므로
203
+ 마커는 화면에 보이지 않는다.
204
+ - **사실을 날조하지 않는다**: 존재하지 않는 슬러그로 링크하지 않고, 별칭은 H1에서만 파생하며,
205
+ `cssclasses`·Excalidraw·Canvas·MOC를 절대 지어내지 않는다 — OPEN 항목은 보존-전용/미적용 +
206
+ Unknown 보고로 degrade.
207
+
208
+ ## 검증 (보고 의무)
209
+
210
+ 4-field 보고:
211
+ - **변경**: 적용된 변환 요약(어떤 페이지에 어떤 T#).
212
+ - **Evidence**: 카운트(스캔한 페이지 수·추가된 링크 수·해소된 orphan 수·병합된 중복 수·추가된
213
+ alias 수·불변 파일 수) + `wiki_lint` before→after.
214
+ - **검증**: 신규 broken-ref 0, rename 0, OMC 프로파일이면 bare-form grep 0 + PM-1 프론트매터
215
+ grep 0, 캐노니컬라이저 멱등 자가검사(2회 실행 diff 0).
216
+ - **Unknown**: 0.7.0 보류 기능·진짜 관계가 없어 남겨둔 orphan·OPEN 한계에 걸린 항목·바이트로
217
+ 단언하지 않는 의미 판단 편차.
218
+
219
+ **hook 건강 상태 보고(무장 시).** Phase 0에서 `<wikiDir>/.obsidizer-hook.log`를 읽는다: 로그
220
+ 없음/비어있음 + 플래그 존재 → Evidence("hook 무장, 에러 없음"). 로그에 항목 있음 →
221
+ Unknown("hook이 N회 에러 — 이 페이지들은 이번 bare 실행이 대신 정규화함, `--enable` 신뢰 전 조사
222
+ 필요"). 플래그 없음 → Evidence("hook 비무장"). hook은 모든 에러에서 exit 0이라 이 로그가 **유일한**
223
+ 발견 경로다.
224
+
225
+ ## 함정
226
+
227
+ - **비-bare 위키링크가 OMC 트리에서 `links[]`를 오염시킨다** — `[[slug|display]]`,
228
+ `[[slug#heading]]`은 Obsidian에선 완전히 정상이지만, OMC `extractWikiLinks`는 inner text
229
+ **전체**를 슬러그화한다(`titleToSlug("slug|display")` → `slug-display.md`). obsidizer 자신의
230
+ 검증은 통과하고(Edit/Write로 쓰고 OMC 파서를 재실행하지 않으므로) **나중에
231
+ `append_wiki`/`wiki_ingest`가 같은 페이지를 건드릴 때** broken-ref로 터진다. 규칙은 "inner text가
232
+ 정확히 bare slug인 형태만"이라는 whitelist다. **`![[slug]]`는 이 함정에 안 걸린다** — `!`가
233
+ 정규식 매치 밖이라 `titleToSlug("slug")` = 올바른 `slug.md`로 해석된다(측정 확인). OMC 트리에서
234
+ `![[…]]`를 피하는 이유는 오염이 아니라 **transclusion 렌더링**(대상 페이지 전문이 인라인됨)
235
+ 때문이다.
236
+ - **프론트매터 whitelist는 거짓 내구성을 준다** — `aliases`/`cssclasses`는 OMC
237
+ `parseFrontmatter`가 9-key를 재구성하는 **READ 시점**에 조용히 사라진다. 쓴 직후엔 보이지만
238
+ 다음 `wiki_*` 쓰기에서 증발한다. OMC 트리에는 절대 쓰지 않는다.
239
+ - **rename 절대 금지, 예쁜 표시 이름은 OMC 트리에서 어떤 수단으로도 불가능** — `wiki_rename`이
240
+ 없어 파일명을 바꾸면 모든 참조자의 `links[]`가 broken-ref가 된다. 예쁜 표시 이름은 rename도,
241
+ alias도, piped link도 전부 막혀 있어 OMC 트리에서 **어떤 방법으로도 전달 불가능**하다 — 이건
242
+ 실제 한계이지 숨길 일이 아니다.
243
+ - **MOC/de-orphan 링크 인플레 금지** — v1은 MOC 허브를 생성하지 않는다. 태그가 같다고 일괄 링크
244
+ 하지 않는다. 진짜 관계가 없는 orphan은 **orphan인 채로 두고 보고한다** — 억지로 연결하지 않는다.
245
+ - **Excalidraw는 절대 날조하지 않는다** — `.excalidraw.md` 스키마가 미검증(OPEN)이므로 모든
246
+ 버전에서 **보존만** 한다. 존재하는 파일을 그대로 두는 것 외엔 손대지 않는다.
247
+ - **body-`::`와 sibling `.canvas`는 durable — 지우지 마라** — `listPages`는 `.md`만 열거하므로
248
+ `.canvas`는 OMC에 안 보이고, `mergePage`/`serializePage`는 본문을 그대로 보존하므로 body-inline
249
+ `Key:: Value`도 안전하다. v1이 이들을 **생성**하지는 않지만(0.7.0 보류), 이미 있는 것은 절대
250
+ 재작성·삭제하지 않는다.
251
+ - **결정성은 캐노니컬라이저 한정이다** — 바이트 멱등을 주장할 수 있는 건 `obsidize.mjs` 뿐이다.
252
+ LLM이 내린 의미 판단(어떤 링크가 진짜인지 등)은 실행마다 달라질 수 있어 **Unknown**으로
253
+ 보고하며, 절대 바이트로 단언하지 않는다.
254
+
255
+ ARGUMENTS: [path] [--apply] | --enable | --disable
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * obsidizer canonicalizer: markdown -> canonical markdown, zero per-page input.
4
+ *
5
+ * Byte-deterministic and idempotent by construction: a 2nd run is a no-op.
6
+ * Pure filesystem, zero deps, node >=16.7. The LLM makes the semantic edits
7
+ * (which mentions to link, which backlinks are genuine) BEFORE this runs; this
8
+ * file only makes the result canonical. It never calls a wiki_* tool.
9
+ *
10
+ * Write discipline (PM-7), inherited by both entry points (bare run and the
11
+ * --enable hook): an unchanged page is never written at all; a changed one is
12
+ * staged to a temp file, re-read and SKIPPED if it moved under us (CAS), then
13
+ * published by rename. A torn read makes a page vanish from OMC's index.md; a
14
+ * lost update silently eats a concurrent wiki_ingest merge. The rename kills
15
+ * the torn read outright; the CAS only narrows the lost-update window to that
16
+ * one syscall — a residual race remains, and is accepted (see writeIfUnchanged).
17
+ *
18
+ * Usage: node obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]
19
+ */
20
+ import crypto from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const RESERVED_FILES = new Set(['index.md', 'log.md', 'environment.md']);
26
+ const FM_KEYS = ['title', 'tags', 'created', 'updated', 'sources', 'links', 'category', 'confidence', 'schemaVersion'];
27
+ const SCHEMA_VERSION = 1;
28
+ // Bounds GROWTH, not the footer: how many slugs obsidizer may ADD to one. It never costs a
29
+ // footer a link it already carries — see renderFooter.
30
+ const FOOTER_CAP = 12;
31
+ const FOOTER_PREFIX = '관련: ';
32
+ const FOOTER_SEP = ' · ';
33
+ // mergePage appends `\n\n---\n\n## Update (<ts>)\n\n...`; everything from here on is preserve-only.
34
+ const UPDATE_BOUNDARY = /\n\n---\n\n## Update \(/;
35
+ const TMP_SUFFIX = '.obsidizer.tmp';
36
+ // Any `[[...]]`, matching OMC's extractWikiLinks regex (ingest.ts:132-139) exactly.
37
+ const WIKILINK = /\[\[([^\]]+)\]\]/g;
38
+ // D8's positive whitelist, as a form test rather than "whatever names a file on disk".
39
+ // titleToSlug can only ever emit this shape, so it costs an OMC tree nothing.
40
+ const BARE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
41
+ // Opt-out marker. Obsidian renders `%%...%%` as a comment, so it is invisible in the vault.
42
+ const IGNORE_MARKER = '%%obsidizer:ignore%%';
43
+
44
+ /**
45
+ * Slug-ish normalizer for the alias divergence test. Deliberately WITHOUT
46
+ * titleToSlug's 64-char cap: the cap is an OMC filename artifact, and mirroring
47
+ * it here would suppress the alias that restores a cap-truncated title.
48
+ */
49
+ export function norm(x) {
50
+ return String(x).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
51
+ }
52
+
53
+ /** Escape a string for use inside YAML double quotes (mirrors OMC escapeYaml). */
54
+ function escapeYaml(s) {
55
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
56
+ }
57
+
58
+ function unescapeYaml(s) {
59
+ return s.replace(/\\(\\|"|n|r)/g, (_, ch) => (ch === 'n' ? '\n' : ch === 'r' ? '\r' : ch));
60
+ }
61
+
62
+ /** Line-based `key: value` parse, mirroring OMC parseSimpleYaml (a line with no `:` is skipped). */
63
+ function parseSimpleYaml(yaml) {
64
+ const map = {};
65
+ const order = [];
66
+ for (const line of yaml.split('\n')) {
67
+ const colonIdx = line.indexOf(':');
68
+ if (colonIdx === -1) continue;
69
+ const key = line.slice(0, colonIdx).trim();
70
+ if (!key) continue;
71
+ let value = line.slice(colonIdx + 1).trim();
72
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
73
+ value = unescapeYaml(value.slice(1, -1));
74
+ }
75
+ if (!(key in map)) order.push(key);
76
+ map[key] = value;
77
+ }
78
+ return { map, order };
79
+ }
80
+
81
+ /** Parse a FLOW YAML array `[a, b]` (mirrors OMC parseYamlArray — flow form only). */
82
+ function parseYamlArray(value) {
83
+ if (!value) return [];
84
+ const trimmed = value.trim();
85
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
86
+ return trimmed.slice(1, -1).split(',')
87
+ .map((s) => unescapeYaml(s.trim().replace(/^["']|["']$/g, '')))
88
+ .filter(Boolean);
89
+ }
90
+ return trimmed ? [trimmed] : [];
91
+ }
92
+
93
+ /**
94
+ * Split a `---\n...\n---\n` frontmatter block off the body.
95
+ * Returns null when the note has no frontmatter block (we never create one).
96
+ */
97
+ export function parseFrontmatter(raw) {
98
+ const normalized = String(raw).replace(/\r\n/g, '\n');
99
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
100
+ if (!match) return null;
101
+ const { map, order } = parseSimpleYaml(match[1]);
102
+ return { lines: match[1].split('\n'), map, order, body: match[2] };
103
+ }
104
+
105
+ /** The 9 OMC keys, parsed. Returns null unless every key is present — we never fabricate one. */
106
+ export function parseOmcFrontmatter(map) {
107
+ if (!FM_KEYS.every((k) => k in map)) return null;
108
+ return {
109
+ title: String(map.title || ''),
110
+ tags: parseYamlArray(map.tags),
111
+ created: String(map.created || ''),
112
+ updated: String(map.updated || ''),
113
+ sources: parseYamlArray(map.sources),
114
+ links: parseYamlArray(map.links),
115
+ category: String(map.category || 'reference'),
116
+ confidence: String(map.confidence || 'medium'),
117
+ schemaVersion: Number(map.schemaVersion) || SCHEMA_VERSION,
118
+ };
119
+ }
120
+
121
+ /** Emit EXACTLY the 9 keys in OMC's order (mirrors serializePage). Never add, drop, or reorder. */
122
+ export function serializeFrontmatter(fm) {
123
+ return [
124
+ `title: "${escapeYaml(fm.title)}"`,
125
+ `tags: [${fm.tags.map((t) => `"${escapeYaml(t)}"`).join(', ')}]`,
126
+ `created: ${fm.created}`,
127
+ `updated: ${fm.updated}`,
128
+ `sources: [${fm.sources.map((s) => `"${escapeYaml(s)}"`).join(', ')}]`,
129
+ `links: [${fm.links.map((l) => `"${escapeYaml(l)}"`).join(', ')}]`,
130
+ `category: ${fm.category}`,
131
+ `confidence: ${fm.confidence}`,
132
+ `schemaVersion: ${fm.schemaVersion}`,
133
+ ].join('\n');
134
+ }
135
+
136
+ /** Walk lines tracking fenced-code state, so ``` blocks are never mistaken for prose. */
137
+ function walkOutsideFences(text, visit) {
138
+ let fence = null;
139
+ for (const line of text.split('\n')) {
140
+ const m = line.match(/^\s{0,3}(`{3,}|~{3,})/);
141
+ if (m) {
142
+ const ch = m[1][0];
143
+ if (!fence) fence = ch;
144
+ else if (fence === ch) fence = null;
145
+ visit(line, true);
146
+ continue;
147
+ }
148
+ visit(line, fence !== null);
149
+ }
150
+ }
151
+
152
+ /** H1 texts, ignoring `#` lines inside code fences. */
153
+ export function findH1s(body) {
154
+ const out = [];
155
+ walkOutsideFences(body, (line, inFence) => {
156
+ if (inFence) return;
157
+ const m = line.match(/^# (.+)$/);
158
+ if (m) out.push(m[1].trim());
159
+ });
160
+ return out;
161
+ }
162
+
163
+ /** Collapse runs of 3+ blank lines to 2, outside fences only. */
164
+ function collapseBlankLines(text) {
165
+ const out = [];
166
+ let blanks = 0;
167
+ walkOutsideFences(text, (line, inFence) => {
168
+ if (!inFence && line.trim() === '') {
169
+ blanks += 1;
170
+ if (blanks > 2) return;
171
+ } else {
172
+ blanks = 0;
173
+ }
174
+ out.push(line);
175
+ });
176
+ return out.join('\n');
177
+ }
178
+
179
+ /** A trailing `관련: [[a]] · [[b]]` line — the vault's existing footer convention. */
180
+ function isFooterLine(line) {
181
+ return /^관련:\s*\[\[/.test(line);
182
+ }
183
+
184
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
185
+
186
+ /**
187
+ * Obsidian's link target: everything before the first `#` (heading/block anchor) or `|`
188
+ * (display text). `[[a#S]]` and `[[a|A]]` carry the same graph edge as `[[a]]`.
189
+ */
190
+ const linkTarget = (inner) => inner.split(/[#|]/)[0].trim();
191
+
192
+ /**
193
+ * Body wikilinks that resolve to a page on disk, as `<basename>.md`.
194
+ *
195
+ * Read liberally, write strictly: every form Obsidian resolves is recognized here, so no
196
+ * edge is missed; renderFooter still emits bare form only. Fenced code is skipped —
197
+ * Obsidian renders `[[x]]` inside a fence literally and puts no edge in the graph, so
198
+ * absorbing it would mint an edge the vault does not have. Inner text that resolves to
199
+ * nothing on disk is dropped rather than slugified into a phantom (PM-4).
200
+ */
201
+ function extractBodyLinks(body, basenames) {
202
+ const found = new Set();
203
+ walkOutsideFences(body, (line, inFence) => {
204
+ if (inFence) return;
205
+ for (const [, inner] of line.matchAll(WIKILINK)) {
206
+ const file = `${linkTarget(inner)}.md`;
207
+ if (basenames.has(file)) found.add(file);
208
+ }
209
+ });
210
+ return found;
211
+ }
212
+
213
+ /** The link targets the head's current footer already carries; empty when it has no footer. */
214
+ function existingFooterSlugs(head) {
215
+ const lines = head.split('\n');
216
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
217
+ const last = lines.length ? lines[lines.length - 1] : '';
218
+ if (!isFooterLine(last)) return new Set();
219
+ return new Set([...last.matchAll(WIKILINK)].map(([, inner]) => linkTarget(inner)));
220
+ }
221
+
222
+ /**
223
+ * Render the Related footer from the resolvable subset of links[] — bare [[slug]]
224
+ * form only (D8: OMC's extractWikiLinks slugifies any inner text, so a piped or
225
+ * heading form mints a phantom slug into links[]).
226
+ *
227
+ * BARE_SLUG makes D8 a test of the form itself rather than "whatever happens to name a
228
+ * file on disk". Nothing an OMC tree can hold is rejected by it (titleToSlug emits only
229
+ * this shape), and an entry it does reject keeps its OMC edge in links[] rather than being
230
+ * emitted in a form that mints a phantom on next ingest. Precisely: OMC's graph keeps that
231
+ * edge, Obsidian's loses it — `links: ["Weird Name.md"]` is a quoted YAML string, not a
232
+ * `[[…]]` Obsidian reads as an edge. Two graphs, and only one of them survives here.
233
+ *
234
+ * `present` (what the footer already shows) is kept whole however far past FOOTER_CAP it
235
+ * runs; the cap only limits what gets ADDED to it. Truncating instead would delete a
236
+ * resolvable bare [[link]] out of the body — a third way for a link to leave the footer,
237
+ * which "never delete a resolvable bare-form link" has no room for (its only two exceptions
238
+ * are a broken ref and the D8 rejection above, and both are enumerated). The two rules never
239
+ * actually compete: only a hand-written footer arrives over the cap, obsidizer's never does.
240
+ *
241
+ * Idempotent either way: run 2 reads run 1's output as `present`, which leaves no room
242
+ * under the cap, so it adds nothing and re-renders that same sorted set.
243
+ */
244
+ function renderFooter(links, basenames, present = new Set()) {
245
+ const resolvable = [...new Set(
246
+ links.filter((l) => basenames.has(l))
247
+ .map((l) => l.replace(/\.md$/, ''))
248
+ .filter((slug) => BARE_SLUG.test(slug)),
249
+ )].sort(byCodeUnit);
250
+ const kept = resolvable.filter((slug) => present.has(slug));
251
+ const room = Math.max(0, FOOTER_CAP - kept.length);
252
+ const added = resolvable.filter((slug) => !present.has(slug)).slice(0, room);
253
+ const slugs = [...kept, ...added].sort(byCodeUnit);
254
+ return slugs.length ? FOOTER_PREFIX + slugs.map((s) => `[[${s}]]`).join(FOOTER_SEP) : null;
255
+ }
256
+
257
+ /** Replace/append the footer at the end of the head region (never inside a `## Update` section). */
258
+ function applyFooter(head, footer) {
259
+ const lines = head.split('\n');
260
+ const dropTrailingBlanks = () => { while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); };
261
+ dropTrailingBlanks();
262
+ if (lines.length && isFooterLine(lines[lines.length - 1])) {
263
+ lines.pop();
264
+ dropTrailingBlanks();
265
+ }
266
+ if (footer) lines.push('', footer);
267
+ return lines.join('\n');
268
+ }
269
+
270
+ /** tags: strip a leading `#`, dedupe, keep authorial order (plan §3.6: "tags deduped + stable"). */
271
+ function normalizeTags(tags) {
272
+ const seen = new Set();
273
+ const out = [];
274
+ for (const t of tags) {
275
+ const tag = String(t).replace(/^#/, '').trim();
276
+ if (!tag || seen.has(tag)) continue;
277
+ seen.add(tag);
278
+ out.push(tag);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * A dir is OMC-managed when it has index.md AND log.md AND at least one
285
+ * non-reserved page carrying the full 9-key frontmatter signature.
286
+ */
287
+ export function detectProfile(target) {
288
+ const dir = fs.statSync(target).isDirectory() ? target : path.dirname(target);
289
+ if (!fs.existsSync(path.join(dir, 'index.md')) || !fs.existsSync(path.join(dir, 'log.md'))) return 'generic';
290
+ for (const f of listMarkdown(dir)) {
291
+ if (RESERVED_FILES.has(f)) continue;
292
+ const fm = parseFrontmatter(read(path.join(dir, f)));
293
+ if (fm && FM_KEYS.every((k) => k in fm.map)) return 'omc';
294
+ }
295
+ return 'generic';
296
+ }
297
+
298
+ const read = (p) => fs.readFileSync(p, 'utf8');
299
+
300
+ /** Sorted explicitly — readdir order is not deterministic across filesystems. */
301
+ function listMarkdown(dir) {
302
+ return fs.readdirSync(dir)
303
+ .filter((f) => f.endsWith('.md') && !f.endsWith('.excalidraw.md'))
304
+ .sort(byCodeUnit);
305
+ }
306
+
307
+ /** Slug index + H1 index for the whole vault: alias uniqueness and footer resolvability need both. */
308
+ export function buildVaultModel(dir) {
309
+ const files = listMarkdown(dir);
310
+ const basenames = new Set(files);
311
+ const pages = new Map();
312
+ const h1Counts = new Map();
313
+ for (const file of files) {
314
+ const raw = read(path.join(dir, file));
315
+ const fm = parseFrontmatter(raw);
316
+ const h1s = findH1s(fm ? fm.body : raw);
317
+ pages.set(file, { file, base: file.replace(/\.md$/, ''), raw, fm, h1s });
318
+ for (const h1 of h1s) h1Counts.set(h1, (h1Counts.get(h1) || 0) + 1);
319
+ }
320
+ return { dir, files, basenames, pages, h1Counts };
321
+ }
322
+
323
+ /**
324
+ * Which notes get `aliases: ["<H1>"]` — generic profile only, all six conditions.
325
+ * The value is the note's own H1, byte for byte; it is never invented.
326
+ */
327
+ export function computeAliases(model, profile) {
328
+ const out = new Map();
329
+ if (profile !== 'generic') return out; // (a)
330
+ for (const page of model.pages.values()) {
331
+ if (!page.fm || page.h1s.length !== 1) continue; // (b)
332
+ const h1 = page.h1s[0];
333
+ if (norm(h1) === norm(page.base)) continue; // (c)
334
+ const others = [...model.pages.values()].filter((p) => p.file !== page.file);
335
+ if (others.some((p) => p.base === norm(h1))) continue; // (d)
336
+ if ((model.h1Counts.get(h1) || 0) > 1) continue; // (e)
337
+ if (page.fm.order.some((k) => k.toLowerCase() === 'aliases')) continue; // (f)
338
+ out.set(page.file, h1);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * markdown -> canonical markdown. Pure: no clock unless `now` is passed, and
345
+ * `now` is only consumed when the content actually changed (§3.6).
346
+ */
347
+ export function canonicalizeText(raw, { profile, model, alias = null, now = null } = {}) {
348
+ const source = String(raw).replace(/\r\n/g, '\n');
349
+ const fm = parseFrontmatter(source);
350
+ if (!fm) return { text: source, changed: source !== String(raw), skipped: 'no-frontmatter' };
351
+
352
+ const basenames = model && model.basenames ? model.basenames : new Set();
353
+ let out;
354
+
355
+ if (profile === 'omc') {
356
+ const omc = parseOmcFrontmatter(fm.map);
357
+ if (!omc) return { text: source, changed: source !== String(raw), skipped: 'not-9-key' };
358
+ omc.tags = normalizeTags(omc.tags);
359
+ // Absorb the body's resolvable wikilinks into links[] BEFORE the footer is rebuilt.
360
+ // The footer is rendered from links[] alone, so without this a footer link that never
361
+ // entered links[] — a hand-added one; OMC-written footers always satisfy
362
+ // links[] ⊇ footer because OMC derives links[] via extractWikiLinks(content) — would
363
+ // be silently dropped and the graph edge lost. Same monotonic shape as OMC's own
364
+ // mergePage (`[...existing, ...extractWikiLinks(new)]`): links[] only ever grows, so
365
+ // this stays idempotent (run 2 finds them present ⇒ no-op) and invents nothing — the
366
+ // edge is already in the body, and only resolvable targets are absorbed.
367
+ omc.links = [...new Set([...omc.links, ...extractBodyLinks(fm.body, basenames)])].sort(byCodeUnit);
368
+ const footer = renderFooter(omc.links, basenames, existingFooterSlugs(splitUpdate(fm.body).head));
369
+ const body = canonicalizeBody(fm.body, footer);
370
+ out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
371
+ if (now && out !== source) {
372
+ omc.updated = now;
373
+ out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
374
+ }
375
+ } else {
376
+ // Generic: existing keys and their order are preserved verbatim; `aliases` is
377
+ // the only permitted addition, appended last, always double-quoted, FLOW form.
378
+ const lines = fm.lines.slice();
379
+ if (alias) lines.push(`aliases: ["${escapeYaml(alias)}"]`);
380
+ const body = canonicalizeBody(fm.body, null);
381
+ out = `---\n${lines.join('\n')}\n---\n${body}`;
382
+ }
383
+
384
+ return { text: out, changed: out !== String(raw), skipped: null };
385
+ }
386
+
387
+ /** Split off the head: everything from a `## Update (<ts>)` heading on is preserve-only. */
388
+ function splitUpdate(body) {
389
+ const m = body.match(UPDATE_BOUNDARY);
390
+ return m ? { head: body.slice(0, m.index), tail: body.slice(m.index) } : { head: body, tail: '' };
391
+ }
392
+
393
+ /** Head region is canonicalized; a `## Update (<ts>)` section is copied through byte for byte. */
394
+ function canonicalizeBody(body, footer) {
395
+ const { head, tail } = splitUpdate(body);
396
+ let out = collapseBlankLines(head);
397
+ if (footer !== null) out = applyFooter(out, footer);
398
+ return `${out}${tail}`.replace(/\s+$/, '') + '\n';
399
+ }
400
+
401
+ /** Remove a temp file. A no-op once it has been renamed into place. */
402
+ function discardTemp(tmp) {
403
+ try { fs.unlinkSync(tmp); } catch { /* renamed away, or never created */ }
404
+ }
405
+
406
+ /**
407
+ * Write `text` into a fresh temp file beside `file` and fsync it; the caller renames it
408
+ * into place. Never a partial in-place write (PM-7).
409
+ *
410
+ * Mirrors the shape of OMC's atomicWriteFileSync (atomic-write.ts:166-206) — the bar the
411
+ * plan holds obsidizer's own writes to — reimplemented rather than borrowed (§6.2):
412
+ * randomUUID so two concurrent obsidizer processes can never collide on one temp path,
413
+ * `wx` so the open fails instead of truncating a temp file somebody else created, fsync
414
+ * so a rename cannot publish a short write (NFS).
415
+ */
416
+ function writeTemp(file, text) {
417
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}${TMP_SUFFIX}.${crypto.randomUUID()}`);
418
+ // A failed `wx` open either found somebody else's file or created nothing, so there is
419
+ // deliberately no cleanup on this line — only past it do we own `tmp`.
420
+ const fd = fs.openSync(tmp, 'wx', 0o600);
421
+ try {
422
+ fs.writeFileSync(fd, text, 'utf8');
423
+ fs.fsyncSync(fd);
424
+ } catch (err) {
425
+ fs.closeSync(fd);
426
+ discardTemp(tmp);
427
+ throw err;
428
+ }
429
+ fs.closeSync(fd);
430
+ return tmp;
431
+ }
432
+
433
+ /**
434
+ * Stage the write, then read-back CAS, then rename (PM-7). `baseline` is the exact bytes
435
+ * the canonical text was computed from; if the file no longer matches, another writer
436
+ * (a concurrent wiki_ingest merging a `## Update` section) touched it since we read it,
437
+ * so writing would silently clobber that merge. Skip instead — the next run converges the
438
+ * page to identical bytes (one canonicalizer, one fixed point).
439
+ *
440
+ * The temp file is written BEFORE the CAS re-read on purpose: it moves the whole staging
441
+ * write out of the race window, leaving a single `rename` syscall between the check and
442
+ * the publish.
443
+ *
444
+ * That window is narrowed, NOT closed. A writer landing between the re-read and the
445
+ * rename is still clobbered. The residue is accepted rather than solved: closing it needs
446
+ * a lock the other writer honors, and depending on OMC's internal `.wiki-lock` is refused
447
+ * (§6.2 / ADR-c) — borrowing another plugin's private file breaks silently the day it is
448
+ * renamed. So this is a real reduction, not an elimination; do not read it as one.
449
+ *
450
+ * Returns false when the write was skipped.
451
+ */
452
+ function writeIfUnchanged(file, baseline, text) {
453
+ const tmp = writeTemp(file, text);
454
+ try {
455
+ let current;
456
+ try {
457
+ current = fs.readFileSync(file, 'utf8');
458
+ } catch {
459
+ return false; // deleted mid-run; nothing of ours to preserve
460
+ }
461
+ if (current !== baseline) return false;
462
+ fs.renameSync(tmp, file);
463
+ return true;
464
+ } finally {
465
+ discardTemp(tmp); // a no-op once the rename above consumed it
466
+ }
467
+ }
468
+
469
+ export function run(target, { profile = 'auto', dryRun = false, now = null } = {}) {
470
+ if (!fs.existsSync(target)) throw new Error(`path not found: ${target}`);
471
+ const isFile = fs.statSync(target).isFile();
472
+ const dir = isFile ? path.dirname(target) : target;
473
+ const resolved = profile === 'auto' ? detectProfile(target) : profile;
474
+ const model = buildVaultModel(dir);
475
+ const aliases = computeAliases(model, resolved);
476
+ const stamp = now || new Date().toISOString();
477
+
478
+ const targets = isFile ? [path.basename(target)] : model.files;
479
+ const report = { profile: resolved, root: dir, dryRun, scanned: 0, changed: [], unchanged: 0, skipped: [], aliasesAdded: 0 };
480
+
481
+ for (const file of targets) {
482
+ const page = model.pages.get(file);
483
+ if (!page) throw new Error(`not a markdown file in ${dir}: ${file}`);
484
+ if (resolved === 'omc' && RESERVED_FILES.has(file)) {
485
+ report.skipped.push({ file, reason: 'reserved' });
486
+ continue;
487
+ }
488
+ report.scanned += 1;
489
+ // Opt-out: the marker anywhere in the file means hands off — no canonicalize, no
490
+ // write. The page still counts in the vault model (its slug resolves links, its H1
491
+ // still gates an alias elsewhere): "do not modify" is not "does not exist".
492
+ if (page.raw.includes(IGNORE_MARKER)) {
493
+ report.skipped.push({ file, reason: 'ignored' });
494
+ continue;
495
+ }
496
+ const alias = aliases.get(file) || null;
497
+ const res = canonicalizeText(page.raw, { profile: resolved, model, alias, now: stamp });
498
+ if (res.skipped) {
499
+ report.skipped.push({ file, reason: res.skipped });
500
+ continue;
501
+ }
502
+ if (!res.changed) {
503
+ report.unchanged += 1;
504
+ continue;
505
+ }
506
+ if (!dryRun && !writeIfUnchanged(path.join(dir, file), page.raw, res.text)) {
507
+ report.skipped.push({ file, reason: 'changed concurrently' });
508
+ continue;
509
+ }
510
+ report.changed.push(file);
511
+ if (alias) report.aliasesAdded += 1;
512
+ }
513
+ return report;
514
+ }
515
+
516
+ function main(argv) {
517
+ const args = argv.filter((a) => !a.startsWith('--'));
518
+ const flag = (name) => argv.includes(`--${name}`);
519
+ const opt = (name, def) => {
520
+ const hit = argv.find((a) => a.startsWith(`--${name}=`));
521
+ return hit ? hit.slice(name.length + 3) : def;
522
+ };
523
+ if (args.length !== 1) {
524
+ console.error('usage: obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]');
525
+ return 2;
526
+ }
527
+ const profile = opt('profile', 'auto');
528
+ if (!['auto', 'omc', 'generic'].includes(profile)) {
529
+ console.error(`unknown profile: ${profile}`);
530
+ return 2;
531
+ }
532
+ const report = run(path.resolve(args[0]), { profile, dryRun: flag('dry-run') });
533
+ if (flag('json')) {
534
+ console.log(JSON.stringify(report));
535
+ } else {
536
+ const tag = report.dryRun ? ' (dry-run)' : '';
537
+ console.log(`profile: ${report.profile}${tag}`);
538
+ console.log(`scanned: ${report.scanned} · changed: ${report.changed.length} · unchanged: ${report.unchanged} · aliases: ${report.aliasesAdded} · skipped: ${report.skipped.length}`);
539
+ for (const f of report.changed) console.log(` changed ${f}`);
540
+ for (const s of report.skipped) console.log(` skipped ${s.file} (${s.reason})`);
541
+ }
542
+ return 0;
543
+ }
544
+
545
+ // fileURLToPath, not URL.pathname: the latter yields "/C:/..." on Windows and never matches argv[1].
546
+ const invoked = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
547
+ if (invoked) {
548
+ try {
549
+ process.exit(main(process.argv.slice(2)));
550
+ } catch (err) {
551
+ console.error(`obsidize: ${err.message}`);
552
+ process.exit(1);
553
+ }
554
+ }