emdash-emit 0.1.14 → 0.1.16

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +259 -188
  3. package/dist/bin/emit.d.ts.map +1 -1
  4. package/dist/bin/emit.js +2 -0
  5. package/dist/bin/emit.js.map +1 -1
  6. package/dist/cli/commands/doctor.d.ts.map +1 -1
  7. package/dist/cli/commands/doctor.js +6 -0
  8. package/dist/cli/commands/doctor.js.map +1 -1
  9. package/dist/cli/commands/media.d.ts +4 -0
  10. package/dist/cli/commands/media.d.ts.map +1 -0
  11. package/dist/cli/commands/media.js +128 -0
  12. package/dist/cli/commands/media.js.map +1 -0
  13. package/dist/cli/commands/pull.d.ts +32 -0
  14. package/dist/cli/commands/pull.d.ts.map +1 -1
  15. package/dist/cli/commands/pull.js +136 -9
  16. package/dist/cli/commands/pull.js.map +1 -1
  17. package/dist/cli/commands/push.d.ts.map +1 -1
  18. package/dist/cli/commands/push.js +18 -0
  19. package/dist/cli/commands/push.js.map +1 -1
  20. package/dist/cli/commands/status.d.ts.map +1 -1
  21. package/dist/cli/commands/status.js +16 -5
  22. package/dist/cli/commands/status.js.map +1 -1
  23. package/dist/client/media.d.ts.map +1 -1
  24. package/dist/client/media.js +16 -5
  25. package/dist/client/media.js.map +1 -1
  26. package/dist/db/cache.d.ts +19 -4
  27. package/dist/db/cache.d.ts.map +1 -1
  28. package/dist/db/cache.js +127 -16
  29. package/dist/db/cache.js.map +1 -1
  30. package/dist/mcp/server.d.ts.map +1 -1
  31. package/dist/mcp/server.js +7 -3
  32. package/dist/mcp/server.js.map +1 -1
  33. package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
  34. package/dist/mcp/tools/sync-doctor.js +12 -0
  35. package/dist/mcp/tools/sync-doctor.js.map +1 -1
  36. package/dist/mcp/tools/sync-media-search.d.ts +4 -0
  37. package/dist/mcp/tools/sync-media-search.d.ts.map +1 -0
  38. package/dist/mcp/tools/sync-media-search.js +102 -0
  39. package/dist/mcp/tools/sync-media-search.js.map +1 -0
  40. package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
  41. package/dist/mcp/tools/sync-pull.js +85 -13
  42. package/dist/mcp/tools/sync-pull.js.map +1 -1
  43. package/dist/mcp/tools/sync-push.d.ts.map +1 -1
  44. package/dist/mcp/tools/sync-push.js +5 -0
  45. package/dist/mcp/tools/sync-push.js.map +1 -1
  46. package/dist/mcp/tools/sync-status.d.ts.map +1 -1
  47. package/dist/mcp/tools/sync-status.js +6 -2
  48. package/dist/mcp/tools/sync-status.js.map +1 -1
  49. package/dist/sync/delete.d.ts +2 -1
  50. package/dist/sync/delete.d.ts.map +1 -1
  51. package/dist/sync/delete.js +6 -19
  52. package/dist/sync/delete.js.map +1 -1
  53. package/dist/sync/diff.d.ts.map +1 -1
  54. package/dist/sync/diff.js +18 -2
  55. package/dist/sync/diff.js.map +1 -1
  56. package/dist/sync/doctor.d.ts +27 -1
  57. package/dist/sync/doctor.d.ts.map +1 -1
  58. package/dist/sync/doctor.js +309 -26
  59. package/dist/sync/doctor.js.map +1 -1
  60. package/dist/sync/guards.d.ts +45 -0
  61. package/dist/sync/guards.d.ts.map +1 -0
  62. package/dist/sync/guards.js +236 -0
  63. package/dist/sync/guards.js.map +1 -0
  64. package/dist/sync/media/body_scan.d.ts +15 -0
  65. package/dist/sync/media/body_scan.d.ts.map +1 -0
  66. package/dist/sync/media/body_scan.js +132 -0
  67. package/dist/sync/media/body_scan.js.map +1 -0
  68. package/dist/sync/media/companions.d.ts +32 -0
  69. package/dist/sync/media/companions.d.ts.map +1 -0
  70. package/dist/sync/media/companions.js +260 -0
  71. package/dist/sync/media/companions.js.map +1 -0
  72. package/dist/sync/media/index.d.ts +9 -0
  73. package/dist/sync/media/index.d.ts.map +1 -0
  74. package/dist/sync/media/index.js +9 -0
  75. package/dist/sync/media/index.js.map +1 -0
  76. package/dist/sync/media/mirror_item.d.ts +27 -0
  77. package/dist/sync/media/mirror_item.d.ts.map +1 -0
  78. package/dist/sync/media/mirror_item.js +207 -0
  79. package/dist/sync/media/mirror_item.js.map +1 -0
  80. package/dist/sync/media/naming_mirror.d.ts +36 -0
  81. package/dist/sync/media/naming_mirror.d.ts.map +1 -0
  82. package/dist/sync/media/naming_mirror.js +143 -0
  83. package/dist/sync/media/naming_mirror.js.map +1 -0
  84. package/dist/sync/media/pull_article.d.ts +37 -0
  85. package/dist/sync/media/pull_article.d.ts.map +1 -0
  86. package/dist/sync/media/pull_article.js +273 -0
  87. package/dist/sync/media/pull_article.js.map +1 -0
  88. package/dist/sync/media/pull_library.d.ts +12 -0
  89. package/dist/sync/media/pull_library.d.ts.map +1 -0
  90. package/dist/sync/media/pull_library.js +255 -0
  91. package/dist/sync/media/pull_library.js.map +1 -0
  92. package/dist/sync/media/remote_index.d.ts +49 -0
  93. package/dist/sync/media/remote_index.d.ts.map +1 -0
  94. package/dist/sync/media/remote_index.js +241 -0
  95. package/dist/sync/media/remote_index.js.map +1 -0
  96. package/dist/sync/media/types.d.ts +237 -0
  97. package/dist/sync/media/types.d.ts.map +1 -0
  98. package/dist/sync/media/types.js +11 -0
  99. package/dist/sync/media/types.js.map +1 -0
  100. package/dist/sync/media.d.ts +1 -74
  101. package/dist/sync/media.d.ts.map +1 -1
  102. package/dist/sync/media.js +1 -592
  103. package/dist/sync/media.js.map +1 -1
  104. package/dist/sync/orchestrator.d.ts +23 -0
  105. package/dist/sync/orchestrator.d.ts.map +1 -1
  106. package/dist/sync/orchestrator.js +85 -1
  107. package/dist/sync/orchestrator.js.map +1 -1
  108. package/dist/sync/preflight.d.ts +1 -1
  109. package/dist/sync/preflight.d.ts.map +1 -1
  110. package/dist/sync/preflight.js +129 -17
  111. package/dist/sync/preflight.js.map +1 -1
  112. package/dist/sync/pull.d.ts +19 -0
  113. package/dist/sync/pull.d.ts.map +1 -1
  114. package/dist/sync/pull.js +169 -15
  115. package/dist/sync/pull.js.map +1 -1
  116. package/dist/sync/push.d.ts +7 -0
  117. package/dist/sync/push.d.ts.map +1 -1
  118. package/dist/sync/push.js +37 -10
  119. package/dist/sync/push.js.map +1 -1
  120. package/dist/sync/status.d.ts +4 -0
  121. package/dist/sync/status.d.ts.map +1 -1
  122. package/dist/sync/status.js +68 -4
  123. package/dist/sync/status.js.map +1 -1
  124. package/dist/utils/config.d.ts +25 -0
  125. package/dist/utils/config.d.ts.map +1 -1
  126. package/dist/utils/config.js +28 -2
  127. package/dist/utils/config.js.map +1 -1
  128. package/dist/utils/frontmatter.d.ts +1 -1
  129. package/dist/utils/frontmatter.d.ts.map +1 -1
  130. package/dist/utils/frontmatter.js +15 -4
  131. package/dist/utils/frontmatter.js.map +1 -1
  132. package/dist/utils/media-body-refs.d.ts +81 -0
  133. package/dist/utils/media-body-refs.d.ts.map +1 -0
  134. package/dist/utils/media-body-refs.js +240 -0
  135. package/dist/utils/media-body-refs.js.map +1 -0
  136. package/dist/utils/media-refs.d.ts +44 -0
  137. package/dist/utils/media-refs.d.ts.map +1 -0
  138. package/dist/utils/media-refs.js +101 -0
  139. package/dist/utils/media-refs.js.map +1 -0
  140. package/dist/utils/naming.d.ts +31 -0
  141. package/dist/utils/naming.d.ts.map +1 -1
  142. package/dist/utils/naming.js +58 -0
  143. package/dist/utils/naming.js.map +1 -1
  144. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,7 @@
26
26
  - [Multi-Site Management](#3-multi-site-management)
27
27
  - [Recommended `.gitignore`](#4-recommended-gitignore)
28
28
  - [📂 Directory Structure & Data Specifications](#-directory-structure--data-specifications)
29
+ - [Two Media ID Spaces: `id` vs `storageKey`](#two-media-id-spaces-id-vs-storagekey)
29
30
  - [📝 Markdown & YAML Frontmatter Specification](#-markdown--yaml-frontmatter-specification)
30
31
  - [Frontmatter Schema](#frontmatter-schema)
31
32
  - [Critical Content Rules](#critical-content-rules)
@@ -33,7 +34,7 @@
33
34
  - [Media Management & Upload Workflow](#media-management--upload-workflow)
34
35
  - [🤖 The Golden Path for AI & Humans (4-Step Cycle)](#-the-golden-path-for-ai--humans-4-step-cycle)
35
36
  - [The 4-Step Authoring Cycle](#the-4-step-authoring-cycle)
36
- - [Preflight Safety Guardrails (G1–G12)](#preflight-safety-guardrails-g1g12)
37
+ - [Preflight Safety Guardrails (G1–G19)](#preflight-safety-guardrails-g1g19)
37
38
  - [🛠 CLI Command Reference](#-cli-command-reference)
38
39
  - [`emit init`](#emit-init)
39
40
  - [`emit profile`](#emit-profile)
@@ -47,6 +48,7 @@
47
48
  - [`emit diff`](#emit-diff)
48
49
  - [`emit pull-taxonomies`](#emit-pull-taxonomies)
49
50
  - [`emit pull-media`](#emit-pull-media)
51
+ - [`emit media-search`](#emit-media-search)
50
52
  - [`emit guide`](#emit-guide)
51
53
  - [`emit mcp`](#emit-mcp)
52
54
  - [🤖 Model Context Protocol (MCP) Support](#-model-context-protocol-mcp-support)
@@ -80,20 +82,23 @@ EmDash is a modern serverless headless CMS built on Cloudflare Workers and D1. W
80
82
 
81
83
  To prevent user expectations from misaligning with the tool's purpose, it is important to clarify **what Emit is and what it is not**:
82
84
 
83
- * **What Emit IS (Core Goal)**:
84
- * An **active authoring and editing workbench (Staging Desk)** where human authors and AI coding agents collaborate seamlessly.
85
- * A tool focused on drafting, reviewing, polishing, diffing, and publishing current and recent articles with strict safety guards—including **automatic offline draft image upload** (`media/drafts/`) for images referenced in the article being pushed.
86
- * **What Emit is NOT (Non-Goals)**:
87
- * **Not a full-site database archiver / backup dumper**: Emit is not designed to clone an entire 15-year history of 20,000 blog posts or an entire multi-gigabyte media library onto your laptop.
88
- * **Not an offline bulk mass-migration converter**: Emit does not perform batch transformation across thousands of historical posts or bulk media migrations at once.
85
+ - **What Emit IS (Core Goal)**:
86
+ - An **active authoring and editing workbench (Staging Desk)** where human authors and AI coding agents collaborate seamlessly.
87
+ - A tool focused on drafting, reviewing, polishing, diffing, and publishing current and recent articles with strict safety guards—including **automatic offline draft image upload** (`media/drafts/`) for images referenced in the article being pushed.
88
+ - **What Emit is NOT (Non-Goals)**:
89
+ - **Not a full-site database archiver / backup dumper**: Emit is not designed to clone an entire 15-year history of 20,000 blog posts or an entire multi-gigabyte media library onto your laptop.
90
+ - **Not an offline bulk mass-migration converter**: Emit does not perform batch transformation across thousands of historical posts or bulk media migrations at once.
89
91
 
90
92
  If your site contains thousands of articles, they remain safe, searchable, and intact in your Cloudflare D1 database. Emit only pulls the active working set into your local workspace, keeping your directory clean and searches instant. Need to edit a specific older article outside the active window? Simply pull it on demand using `emit pull <article-id>`.
91
93
 
92
- These Non-Goals are **deliberate design decisions, not missing capabilities**: raising `postsSyncLimit` (see below) can in fact fetch tens of thousands of articles, so Emit *could* clone a whole site — it chooses not to expose that as its default workflow. The sliding window is a boundary on what stays *on disk*, never on what you can *reach*: every historical article remains editable through an on-demand `emit pull <id>`, and writing a long-form piece that cites or revises many past posts works exactly the same way — fetch each referenced article by ULID, edit it, push it. For operations that genuinely belong to the whole library (bulk media management, site-wide content queries), pair Emit with the [official EmDash MCP](#6-division-of-labor-with-the-official-emdash-mcp) instead of asking Emit to reinvent them.
94
+ These Non-Goals are **deliberate design decisions, not missing capabilities**: raising `postsSyncLimit` (see below) can in fact fetch tens of thousands of articles, so Emit _could_ clone a whole site — it chooses not to expose that as its default workflow. The sliding window is a boundary on what stays _on disk_, never on what you can _reach_: every historical article remains editable through an on-demand `emit pull <id>`, and writing a long-form piece that cites or revises many past posts works exactly the same way — fetch each referenced article by ULID, edit it, push it. For operations that genuinely belong to the whole library (bulk media management, site-wide content queries), pair Emit with the [official EmDash MCP](#6-division-of-labor-with-the-official-emdash-mcp) instead of asking Emit to reinvent them.
93
95
 
94
96
  > [!NOTE]
95
97
  > **Can the sync limit be adjusted — and why is there no `--all` for articles?**
96
- > Yes. Set `postsSyncLimit: 300` (or any larger number, e.g. your full article count) in `.emit/config.json`, and Emit's internal pagination engine automatically batch-fetches up to that configured limit. What Emit deliberately omits is a `--all` *flag* for articles: pulling the entire history into the workspace is an explicit configuration choice, not a routine default, so the "staging desk" discipline is preserved in day-to-day UX while remaining fully escapable when you truly want a larger working set. Media is intentionally different — `emit pull-media --all` does exist, because media files are inert assets that never carry unsynced writing risk.
98
+ >
99
+ > **Articles**: Set `postsSyncLimit: 300` (or any larger number, e.g. your full article count) in `.emit/config.json`, and Emit's internal pagination engine automatically batch-fetches up to that configured limit. What Emit deliberately omits is a `--all` _flag_ for articles: pulling the entire history into the workspace is an explicit configuration choice, not a routine default, so the "staging desk" discipline is preserved in day-to-day UX while remaining fully escapable when you truly want a larger working set.
100
+ >
101
+ > **Media**: Media is handled via an on-demand local-first pipeline — referenced media files in pulled articles are mirrored automatically. While `emit pull-media --all` exists for full offline backup or migration diagnostics, it is not intended for routine workflows because pulling thousands of binary files can overwhelm local disk and network bandwidth.
97
102
 
98
103
  ### 2. The Sliding Window "Staging Desk" Model
99
104
 
@@ -101,17 +106,17 @@ Traditional synchronization tools attempt to download the entire history of a si
101
106
 
102
107
  Emit adopts a **bounded sliding window**:
103
108
 
104
- * It maintains an active sliding window of recent posts (default: **latest 100 posts**).
105
- * When older posts fall outside the sliding window during `emit pull`, Emit cleanly **prunes them from disk**, keeping your local repository lean, fast, and organized.
106
- * Any pruned article can be pulled back into the workspace at any time with `emit pull <id>`.
109
+ - It maintains an active sliding window of recent posts (default: **latest 30 posts per status**, up to 60 files total; the legacy `syncLimit` config key is still accepted for backwards compatibility).
110
+ - When older posts fall outside the sliding window during `emit pull`, Emit cleanly **prunes them from disk**, keeping your local repository lean, fast, and organized.
111
+ - Any pruned article can be pulled back into the workspace at any time with `emit pull <id>`.
107
112
 
108
113
  ### 3. Unpushed Edits Safeguard
109
114
 
110
115
  What happens if an article with unpushed local modifications is pushed out of the sliding window?
111
116
  Emit guarantees **zero data loss**:
112
117
 
113
- * If an article has local modifications not yet pushed to the remote server, Emit's pruning engine **aborts the pull immediately** with a `SyncPruneConflictError`.
114
- * It lists the exact unpushed file and informs the user to either push the changes (`emit push <file>`) or discard them before proceeding.
118
+ - If an article has local modifications not yet pushed to the remote server, Emit's pruning engine **aborts the pull immediately** with a `SyncPruneConflictError`.
119
+ - It lists the exact unpushed file and informs the user to either push the changes (`emit push <file>`) or discard them before proceeding.
115
120
 
116
121
  ### 4. Pure TypeScript & Zero Native Builds
117
122
 
@@ -122,36 +127,37 @@ By leveraging Node.js 22's built-in `node:sqlite` module (`DatabaseSync`), Emit
122
127
 
123
128
  To prevent concurrent race conditions when multiple authors or AI agents collaborate:
124
129
 
125
- * Emit acquires a distributed 7-minute lease via `POST /_emdash/api/content/{collection}/{id}/lock` before writing to remote articles, with automatic heartbeats and `try/finally` release guarantees.
126
- * If concurrent edits occur, Emit utilizes a **Hybrid 3-Way Merge** engine (`node-diff3`), merging YAML frontmatter fields and Markdown body text independently.
130
+ - Emit acquires a distributed 7-minute lease via `POST /_emdash/api/content/{collection}/{id}/lock` before writing to remote articles, with automatic heartbeats and `try/finally` release guarantees.
131
+ - If concurrent edits occur, Emit utilizes a **Hybrid 3-Way Merge** engine (`node-diff3`), merging YAML frontmatter fields and Markdown body text independently.
127
132
 
128
133
  ### 6. Division of Labor with the Official EmDash MCP
129
134
 
130
135
  Emit is intentionally not a complete EmDash client. The [official EmDash MCP server](https://github.com/aroooy/emdash) (and the Admin Dashboard) remains the canonical surface for whole-library operations, and the two tools are designed to be used **side by side against the same site**:
131
136
 
132
- | Operation | Right Tool |
133
- | --- | --- |
134
- | Drafting, reviewing, diffing, publishing articles with safety guards | **Emit** (`emit pull` / `emit push`, MCP `sync_*` tools) |
135
- | On-demand retrieval of any single historical article by ULID | **Emit** (`emit pull <id>`) |
136
- | Bulk media library management (mass upload, mass delete, full-library listing) | **Official EmDash MCP** (e.g. `media_upload`) / Admin Dashboard |
137
- | Site-wide content queries, arbitrary collection operations outside the authoring loop | **Official EmDash MCP** raw content tools |
137
+ | Operation | Right Tool |
138
+ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
139
+ | Drafting, reviewing, diffing, publishing articles with safety guards | **Emit** (`emit pull` / `emit push`, MCP `sync_*` tools) |
140
+ | On-demand retrieval of any single historical article by ULID | **Emit** (`emit pull <id>`) |
141
+ | Finding a media asset to use in the article being drafted (keyword search over the remote library, with mirror status) | **Emit** (`emit media-search` / MCP `sync_media_search`) |
142
+ | Bulk media library management (mass upload, mass delete, full-library listing) | **Official EmDash MCP** (e.g. `media_upload`) / Admin Dashboard |
143
+ | Site-wide content queries, arbitrary collection operations outside the authoring loop | **Official EmDash MCP** raw content tools |
138
144
 
139
- This division is a design stance, not a limitation to work around: Emit focuses its guardrails (G1–G14, locks, 3-way merge, quarantine) on the *authoring loop* where data loss actually happens, and leaves fleet-scale operations to the server-native tooling that already handles them safely.
145
+ This division is a design stance, not a limitation to work around: Emit focuses its guardrails (G1–G14, locks, 3-way merge, quarantine) on the _authoring loop_ where data loss actually happens, and leaves fleet-scale operations to the server-native tooling that already handles them safely.
140
146
 
141
147
  ---
142
148
 
143
149
  ## 🌟 Key Features
144
150
 
145
- * **AI-First Authoring Workflow**: Built specifically for autonomous agents and humans to draft, inspect, and sync local Markdown files safely.
146
- * **Sliding Window Synchronization**: Automatically manages an active sliding window of recent posts (default: latest 100), pruning older cached entries from disk. The window bounds what stays on disk, not what you can reach — any historical article remains available via `emit pull <id>`, and the window size is configurable (`postsSyncLimit`) when a larger working set is genuinely needed.
147
- * **Unpushed Edits Safeguard**: Detects unpushed local changes outside the sync window and aborts sync safely before any files can be deleted.
148
- * **Git-Style Workspace Encapsulation**: Houses local SQLite cache (`node:sqlite`) and workspace configuration neatly inside a `.emit/` hidden folder.
149
- * **Multi-Site Profile Management**: Manage multiple EmDash sites and credentials in `~/.config/emit/profiles.yaml` with automatic workspace directory detection.
150
- * **Hybrid 3-Way Merge Engine**: Resolves concurrent changes between remote and local versions like Git, independently merging YAML frontmatter and Markdown body.
151
- * **Distributed Mutual Exclusion Lock**: Acquires 7-minute leases via `POST /lock` with `try/finally` release semantics to eliminate race conditions during publishing.
152
- * **Opaque Block Preservation**: Preserves EmDash custom blocks (e.g., `core:gallery`) losslessly across AST roundtrips via `<!--ec:block ... -->` fences.
153
- * **Taxonomy & Media Synchronization**: Sync category/tag YAML schemas and download media assets with metadata.
154
- * **Zero Native Dependencies**: Completely eliminates native C++ builds (`node-gyp`) by leveraging Node.js 22+ built-in `node:sqlite`.
151
+ - **AI-First Authoring Workflow**: Built specifically for autonomous agents and humans to draft, inspect, and sync local Markdown files safely.
152
+ - **Sliding Window Synchronization**: Automatically manages an active sliding window of recent posts (default: latest 30 published + 30 drafts = up to 60 files), pruning older cached entries from disk. The window bounds what stays on disk, not what you can reach — any historical article remains available via `emit pull <id>`, and the window size is configurable (`postsSyncLimit`) when a larger working set is genuinely needed.
153
+ - **Unpushed Edits Safeguard**: Detects unpushed local changes outside the sync window and aborts sync safely before any files can be deleted.
154
+ - **Git-Style Workspace Encapsulation**: Houses local SQLite cache (`node:sqlite`) and workspace configuration neatly inside a `.emit/` hidden folder.
155
+ - **Multi-Site Profile Management**: Manage multiple EmDash sites and credentials in `~/.config/emit/profiles.yaml` with automatic workspace directory detection.
156
+ - **Hybrid 3-Way Merge Engine**: Resolves concurrent changes between remote and local versions like Git, independently merging YAML frontmatter and Markdown body.
157
+ - **Distributed Mutual Exclusion Lock**: Acquires 7-minute leases via `POST /lock` with `try/finally` release semantics to eliminate race conditions during publishing.
158
+ - **Opaque Block Preservation**: Preserves EmDash custom blocks (e.g., `core:gallery`) losslessly across AST roundtrips via `<!--ec:block ... -->` fences.
159
+ - **Taxonomy & Media Synchronization**: Sync category/tag YAML schemas and download media assets with metadata. EmDash identifies each asset by two separate ULIDs (record `id` for the API, `storageKey` for the file route), and Emit keeps them strictly separated — see [Two Media ID Spaces](#two-media-id-spaces-id-vs-storagekey).
160
+ - **Zero Native Dependencies**: Completely eliminates native C++ builds (`node-gyp`) by leveraging Node.js 22+ built-in `node:sqlite`.
155
161
 
156
162
  ---
157
163
 
@@ -205,9 +211,9 @@ emit profile add my-blog \
205
211
  --workspace /path/to/local/blog-repo
206
212
  ```
207
213
 
208
- * `--origin`: The base URL of your EmDash Cloudflare Workers instance.
209
- * `--token`: Your Personal Access Token (PAT) generated from the EmDash dashboard.
210
- * `--workspace`: The local directory where articles and assets will be synchronized.
214
+ - `--origin`: The base URL of your EmDash Cloudflare Workers instance.
215
+ - `--token`: Your Personal Access Token (PAT) generated from the EmDash dashboard.
216
+ - `--workspace`: The local directory where articles and assets will be synchronized.
211
217
 
212
218
  > [!NOTE]
213
219
  > Profiles are securely stored with `0600` permissions at `~/.config/emit/profiles.yaml` (on macOS / Linux) or `%USERPROFILE%\.config\emit\profiles.yaml` (on Windows).
@@ -244,6 +250,7 @@ emit profile list
244
250
  ```
245
251
 
246
252
  **How Automatic Profile Resolution Works:**
253
+
247
254
  1. **Directory-Driven Auto Detection (Recommended)**: When you `cd ~/blogs/personal` and run `emit pull`, `emit push`, or launch an AI agent, Emit detects your current folder and automatically uses the `personal` profile. When you `cd ~/blogs/work`, it seamlessly connects to `work`. Directory comparison is symlink-safe (`fs.realpathSync`), so a workspace reached through a symlinked path (or a macOS `/tmp`-style alias) still resolves to the right profile.
248
255
  2. **Explicit Profile Flag**: You can explicitly specify `--profile <name>` (or pass `profile: "<name>"` in MCP) from any directory to override automatic detection.
249
256
  3. **Ambiguity Safeguard**: If you run a command outside of any registered workspace and omit `--profile`, Emit aborts with an `AmbiguousProfileError` instead of guessing or silently pushing to the wrong site.
@@ -265,7 +272,7 @@ When credentials include a `refresh_token` (such as OAuth sessions in `~/.config
265
272
  The target instance (`origin`) is resolved by one shared function (`resolveOriginPriority()` in `src/cli/context.ts`, reused by the MCP server) so CLI and MCP always agree:
266
273
 
267
274
  1. **Explicitly selected profile** — `--profile <name>` (or MCP `profile: "<name>"`)
268
- 2. **`EMDASH_ORIGIN`** — overrides an *implicitly* resolved profile so CI/staging overrides actually take effect
275
+ 2. **`EMDASH_ORIGIN`** — overrides an _implicitly_ resolved profile so CI/staging overrides actually take effect
269
276
  3. **Auto-resolved profile** — current-directory match, unique origin match, or the single registered profile
270
277
  4. **Resolved auth origin** — carried by `~/.config/emdash/auth.json` / `~/.config/emit/credentials.json`
271
278
  5. **Workspace `.emit/config.json`** → then the `https://example.com` sentinel default
@@ -282,20 +289,21 @@ Running `emit init` generates `.emit/config.json` with the following keys (`src/
282
289
  "origin": "https://your-blog.example.com",
283
290
  "defaultCollection": "posts",
284
291
  "defaultLocale": "ja",
285
- "postsSyncLimit": 100,
292
+ "postsSyncLimit": 30,
286
293
  "mediaSyncLimit": 300,
287
294
  "concurrency": 8
288
295
  }
289
296
  ```
290
297
 
291
- | Key | Default | Description |
292
- | --- | --- | --- |
293
- | `origin` | `"https://example.com"` | Base URL of the target EmDash instance, exactly as written to disk. It is never rewritten in-memory by `EMDASH_ORIGIN`; see [Origin Resolution Precedence](#origin-resolution-precedence) for how the effective origin is chosen |
294
- | `defaultCollection` | `"posts"` | Default content collection when `--collection` is omitted |
295
- | `defaultLocale` | `"ja"` | Default locale used when scaffolding new drafts via `emit new` / `sync_new` |
296
- | `postsSyncLimit` | `100` | Sliding window size for batch article pulls (when `status: all`, fetches up to `postsSyncLimit` `published` + up to `postsSyncLimit` `draft` articles in parallel; also supports legacy `syncLimit` fallback) |
297
- | `mediaSyncLimit` | `300` | Default maximum items fetched by `emit pull-media` / `sync_pull_media` |
298
- | `concurrency` | `8` | Parallel HTTP fetch concurrency during batch pulls |
298
+ | Key | Default | Description |
299
+ | ------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
300
+ | `origin` | `"https://example.com"` | Base URL of the target EmDash instance, exactly as written to disk. It is never rewritten in-memory by `EMDASH_ORIGIN`; see [Origin Resolution Precedence](#origin-resolution-precedence) for how the effective origin is chosen |
301
+ | `defaultCollection` | `"posts"` | Default content collection when `--collection` is omitted |
302
+ | `defaultLocale` | `"ja"` | Default locale used when scaffolding new drafts via `emit new` / `sync_new` |
303
+ | `postsSyncLimit` | `30` | Sliding window size for batch article pulls (when `status: all`, fetches up to `postsSyncLimit` `published` + up to `postsSyncLimit` `draft` articles in parallel; legacy `syncLimit` is also accepted) |
304
+ | `mediaSyncLimit` | `300` | Default maximum items fetched by `emit pull-media` / `sync_pull_media` |
305
+ | `localMediaRefs` | `true` | Default-ON. Mirrored body images are stored as article-relative `../../media/<storageKey>` references (depth derived from the article's own path) instead of canonical `/_emdash/api/media/file/<storageKey>` URLs, so Markdown preview resolves them offline. Opt out with `emit pull --no-local-media-refs`; pull, push, diff, and MCP all honour it |
306
+ | `concurrency` | `8` | Parallel HTTP fetch concurrency during batch pulls |
299
307
 
300
308
  ### 6. Recommended `.gitignore`
301
309
 
@@ -325,10 +333,11 @@ tmp/
325
333
  ```
326
334
 
327
335
  > [!TIP]
328
- > * `.emit/cache.db*` **must be excluded** from Git because it is a machine-local SQLite tracking database.
329
- > * `.emit/config.json` **can be safely committed** to Git because it contains workspace configuration (origin URL, default collections), while sensitive authentication tokens remain securely isolated in `~/.config/emit/profiles.yaml`.
330
- > * `media/*.yaml` metadata files can be committed to Git to track asset metadata (`storageKey`, `contentHash`, `status`, dimensions) alongside your Markdown articles.
331
- > * **Local-First Git Tracking for `media/`**: If you use your Git repository as a self-contained Local-First source of truth (including offline drafts in `media/drafts/`), keep `media/` tracked in Git (or via Git LFS). If you treat Cloudflare R2 as the sole binary store and want a lightweight Git repo, uncomment the `media/*.jpg` ignore rules above while keeping `media/*.yaml` tracked.
336
+ >
337
+ > - `.emit/cache.db*` **must be excluded** from Git because it is a machine-local SQLite tracking database.
338
+ > - `.emit/config.json` **can be safely committed** to Git because it contains workspace configuration (origin URL, default collections), while sensitive authentication tokens remain securely isolated in `~/.config/emit/profiles.yaml`.
339
+ > - `media/*.yaml` metadata files can be committed to Git to track asset metadata (`storageKey`, `contentHash`, `status`, dimensions) alongside your Markdown articles.
340
+ > - **Local-First Git Tracking for `media/`**: If you use your Git repository as a self-contained Local-First source of truth (including offline drafts in `media/drafts/`), keep `media/` tracked in Git (or via Git LFS). If you treat Cloudflare R2 as the sole binary store and want a lightweight Git repo, uncomment the `media/*.jpg` ignore rules above while keeping `media/*.yaml` tracked.
332
341
 
333
342
  ---
334
343
 
@@ -347,7 +356,7 @@ your-blog/
347
356
  ├── tags/ # Tag definition files (<id>-<label>.yaml)
348
357
  │ ├── 01M6TAG1-Typescript.yaml
349
358
  │ └── 01M6TAG2-Ai.yaml
350
- ├── media/ # Media files and companion metadata (<yyyyMMdd>_<id>-<name>.<ext>)
359
+ ├── media/ # Media files and companion metadata (`<markdownRef>.<ext>` + `.yaml`; markdownRef is derived from the storageKey)
351
360
  │ ├── drafts/ # Staging folder for offline draft images (auto-uploaded on push)
352
361
  │ │ └── sunset.png
353
362
  │ ├── 20261101_01M6ABCDEF-header.jpg
@@ -366,13 +375,26 @@ your-blog/
366
375
 
367
376
  Emit maintains a local SQLite database via Node.js built-in `node:sqlite` (`src/db/cache.ts`) with 5 tables:
368
377
 
369
- | Table | Purpose |
370
- | --- | --- |
371
- | `entries` | Caches the latest remote JSON envelope and revision (`rev`) for each synced article |
372
- | `base_snapshots` | Stores the baseline Markdown body and baseline frontmatter at the last synced revision to power Hybrid 3-Way Merge (`frontmatter_yaml` holds a **JSON-encoded** `Frontmatter` object, not YAML text) |
373
- | `sync_state` | Tracks each article's workspace-relative file path and SHA-256 content hash to detect local modifications (`synced` vs `modified` vs `untracked`, plus `invalid` when YAML frontmatter fails parsing; `invalid` files are excluded from `emit push --all`, cached `invalid` files are automatically self-healed on batch `emit pull` / `sync_pull`, and uncached `invalid` files outside the sliding window can be repaired via `emit pull <ULID>` or `sync_pull(id: "<ULID>")`) |
374
- | `bylines` | Caches remote author records (`id`, `slug`, `display_name`, `is_guest`) synced automatically via `/_emdash/api/mcp` (`byline_list`) on pull, enabling slug ⇔ ULID resolution and default author selection |
375
- | `media_state` | Tracks offline draft image uploads (`local_hash`, `media_id`, `storage_key`, `url`, `workspace_path`, `status`: `pending` \| `uploaded` \| `ready` \| `failed`) for idempotency, retry recovery, and cross-article asset reuse |
378
+ | Table | Purpose |
379
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
380
+ | `entries` | Caches the latest remote JSON envelope and revision (`rev`) for each synced article |
381
+ | `base_snapshots` | Stores the baseline Markdown body and baseline frontmatter at the last synced revision to power Hybrid 3-Way Merge (`frontmatter_yaml` holds a **JSON-encoded** `Frontmatter` object, not YAML text) |
382
+ | `sync_state` | Tracks each article's workspace-relative file path and SHA-256 content hash to detect local modifications (`synced` vs `modified` vs `untracked`, plus `invalid` when YAML frontmatter fails parsing; `invalid` files are excluded from `emit push --all`, cached `invalid` files are automatically self-healed on batch `emit pull` / `sync_pull`, and uncached `invalid` files outside the sliding window can be repaired via `emit pull <ULID>` or `sync_pull(id: "<ULID>")`) |
383
+ | `bylines` | Caches remote author records (`id`, `slug`, `display_name`, `is_guest`) synced automatically via `/_emdash/api/mcp` (`byline_list`) on pull, enabling slug ⇔ ULID resolution and default author selection |
384
+ | `media_state` | Tracks offline draft image uploads and mirrored assets with **`storage_key` as PRIMARY KEY** (Schema v5): `media_id`, `local_content_hash` (SHA-256 of the local mirror bytes), `remote_record_ref` (`media-record:<id>` for remote mirrors), `url`, `workspace_path`, `markdown_ref`, `uploaded_at`, and `status`: `pending` \| `uploaded` \| `ready` \| `failed` — for idempotency, retry recovery, cross-article asset reuse, and index-based lookups (indexed on `media_id`, `status`, and `local_content_hash`). Note the two ULIDs in one row: `media_id` is the D1 record id and `storage_key` is the R2 object key; `markdown_ref` is the workspace-relative path of the mirror named after that storageKey — see [Two Media ID Spaces](#two-media-id-spaces-id-vs-storagekey). |
385
+
386
+ ### Two Media ID Spaces: `id` vs `storageKey`
387
+
388
+ Every EmDash asset carries **two unrelated 26-character ULIDs**: the D1 media record `id` (primary key, used by `GET /_emdash/api/media/{id}`) and the R2 object `storageKey` (the filename served by `/_emdash/api/media/file/{storageKey}`). They are generated independently, so for the same image the local mirror name `media/<yyyyMMdd>_<record-id>-<name>.<ext>` never matches the storageKey baked into its own body URL — and neither value is a typo of the other.
389
+
390
+ Consequences worth internalizing before debugging media:
391
+
392
+ - A record `id` on the file route returns **404**; a `storageKey` on `GET /_emdash/api/media/{id}` returns **401** (no bearer token) or **404**. Both codes describe the _lookup_, never the health of the asset.
393
+ - Because a 404 proves only that one lookup failed, Emit never concludes a record is missing and never suggests inserting one — see `[UNRESOLVED_MEDIA_ASSET]` under [`emit pull-media`](#emit-pull-media).
394
+ - An old article whose images do not render **locally** is usually an unmirrored asset (the media window is newest-first, `mediaSyncLimit` default 300) rather than a broken reference. `emit doctor --check-media-library` performs a metadata-only walk comparing remote size against local mirrors, and `emit pull-media --article <id>` fetches referenced images on demand without pulling the full library (reserve `emit pull-media --all` for complete offline backups).
395
+ - Local mirrors are named after the **storageKey** (`media/<markdownRef>.<ext>`), so a body URL and the file on disk share one key. Workspaces built before this rule still hold legacy `media/<yyyyMMdd>_<id>-<name>.<ext>` names; pulling an article (or a one-time maintenance run of `emit pull-media --all`) renames them in place with no re-downloading, and Emit reads both forms meanwhile.
396
+
397
+ The full specification — identifier table, endpoint matrix, migrated-corpus behavior, and resolution order (`verifiedStorageKeys` → `orphanStorageKeys`) — lives in **[docs/media-id-spaces.md](docs/media-id-spaces.md)**.
376
398
 
377
399
  ---
378
400
 
@@ -384,40 +406,39 @@ Each article is stored as a Markdown file with a validated YAML frontmatter head
384
406
 
385
407
  ```yaml
386
408
  ---
387
- id: 01M6ABCDEF1234567890123456 # 26-char canonical ULID (auto-generated; DO NOT EDIT)
409
+ id: 01M6ABCDEF1234567890123456 # 26-char canonical ULID (auto-generated; DO NOT EDIT)
388
410
  slug: building-ai-powered-cms-tools
389
- title: "Building AI-Powered CMS Tools"
390
- collection: posts # Target collection (defaults to "posts")
391
- status: draft # "draft" or "published" (defaults to "draft")
392
- publishedAt: null # ISO 8601 timestamp string (e.g. "2026-11-04T12:00:00.000Z") or null
393
- locale: ja # Locale code (defaults to "ja")
394
- featuredImage: "01M67P0MBMJQQXH2K95HHCYRYB" # Optional: 26-char media ULID (uppercase-normalized) or null
395
- taxonomies: # Map of taxonomy key -> array of term slugs (or canonical term ULIDs)
411
+ title: 'Building AI-Powered CMS Tools'
412
+ collection: posts # Target collection (defaults to "posts")
413
+ status: draft # "draft" or "published" (defaults to "draft")
414
+ publishedAt: null # ISO 8601 timestamp string (e.g. "2026-11-04T12:00:00.000Z") or null
415
+ locale: ja # Locale code (defaults to "ja")
416
+ featuredImage: '01M67P0MBMJQQXH2K95HHCYRYB' # Optional: 26-char media record ULID (uppercase-normalized) or null — NOT a storageKey
417
+ taxonomies: # Map of taxonomy key -> array of term slugs (or canonical term ULIDs)
396
418
  categories:
397
419
  - tech
398
420
  tags:
399
421
  - typescript
400
422
  - ai
401
- seo: # Optional SEO metadata block
402
- title: null # Custom SEO title string or null
403
- description: null # Meta description string or null
404
- canonical: null # Absolute URL (e.g. "https://example.com/posts/...") or null
405
- noIndex: false # Boolean (optional)
406
- image: null # SEO image string or null
407
- bylines: # Author byline slugs (resolved via cached bylines table)
423
+ seo: # Optional SEO metadata block
424
+ title: null # Custom SEO title string or null
425
+ description: null # Meta description string or null
426
+ canonical: null # Absolute URL (e.g. "https://example.com/posts/...") or null
427
+ noIndex: false # Boolean (optional)
428
+ image: null # SEO image string or null
429
+ bylines: # Author byline slugs (resolved via cached bylines table)
408
430
  - aroooy
409
431
  ---
410
-
411
432
  Markdown body begins here...
412
-
413
433
  ```
414
434
 
415
435
  > [!IMPORTANT]
416
436
  > **Schema Validation & Serialization Rules (`src/utils/frontmatter.ts`)**:
417
- > * **Always nest categories and tags under `taxonomies:`**: `parseMarkdown()` validates frontmatter with Zod (`FrontmatterSchema`), which strips unknown top-level keys, and `serializeMarkdown()` regenerates the YAML header on every pull and push. Writing `categories:` or `tags:` at the top level will silently discard them without triggering Preflight Guard G4. `taxonomies` accepts `categories`, `tags`, and any additional custom taxonomy keys defined in EmDash (`z.record(z.array(z.string()))`).
418
- > * **`seo.canonical` must be an absolute URL**: Validated via `z.string().url().nullable().optional()`. Relative paths such as `"/posts/my-slug"` will fail frontmatter parsing; always specify a full `https://...` URL or `null`.
419
- > * **Author `bylines` auto-selection**: When scaffolding with `emit new` / `sync_new` without `--byline` / `byline`, Emit populates `bylines` with the first cached non-guest author ordered by `slug ASC` from `.emit/cache.db`. On single-author sites this is always your author slug; on multi-author sites, pass `-b, --byline <slug>` (or `byline` in MCP) or edit `bylines:` in frontmatter explicitly.
420
- > * **`id` and `featuredImage` normalization**: `id` must be an exact 26-character ULID. `featuredImage` values matching a 26-character Crockford base32 ULID are normalized to uppercase automatically.
437
+ >
438
+ > - **Always nest categories and tags under `taxonomies:`**: `parseMarkdown()` validates frontmatter with Zod (`FrontmatterSchema`), which strips unknown top-level keys, and `serializeMarkdown()` regenerates the YAML header on every pull and push. Writing `categories:` or `tags:` at the top level will silently discard them without triggering Preflight Guard G4. `taxonomies` accepts `categories`, `tags`, and any additional custom taxonomy keys defined in EmDash (`z.record(z.array(z.string()))`).
439
+ > - **`seo.canonical` must be an absolute URL**: Validated via `z.string().url().nullable().optional()`. Relative paths such as `"/posts/my-slug"` will fail frontmatter parsing; always specify a full `https://...` URL or `null`.
440
+ > - **Author `bylines` auto-selection**: When scaffolding with `emit new` / `sync_new` without `--byline` / `byline`, Emit populates `bylines` with the first cached non-guest author ordered by `slug ASC` from `.emit/cache.db`. On single-author sites this is always your author slug; on multi-author sites, pass `-b, --byline <slug>` (or `byline` in MCP) or edit `bylines:` in frontmatter explicitly.
441
+ > - **`id` and `featuredImage` normalization**: `id` must be an exact 26-character ULID. `featuredImage` values matching a 26-character Crockford base32 ULID are normalized to uppercase automatically.
421
442
 
422
443
  ### Critical Content Rules
423
444
 
@@ -434,7 +455,7 @@ Markdown body begins here...
434
455
  - Emit automatically updates the remote state and moves the file from `posts/draft/` to `posts/published/`.
435
456
  - **Never move or rename files manually using `mv`**.
436
457
  4. **Featured Image References**:
437
- - `featuredImage` must be a 26-character canonical media ULID (e.g. `01M67P0MBMJQQXH2K95HHCYRYB`) or `null`. Emit normalizes the value to uppercase, so lowercase input is accepted and canonicalized on pull, merge, and push.
458
+ - `featuredImage` must be a 26-character canonical media **record** ULID (e.g. `01M67P0MBMJQQXH2K95HHCYRYB`) or `null` — never a `storageKey`, which is a different ULID for the same asset ([Two Media ID Spaces](#two-media-id-spaces-id-vs-storagekey)). Emit normalizes the value to uppercase, so lowercase input is accepted and canonicalized on pull, merge, and push.
438
459
  - To remove the hero image, set `featuredImage: null`. Omitting the field leaves the remote value untouched.
439
460
  - When the referenced ULID matches the cached remote entry, Emit resends the original remote `featured_image` object verbatim (provider, `src`, `meta.storageKey`, dimensions, MIME type) so nothing is lost. When it does not match, Emit sends the bare `{ id: <ULID> }` and lets EmDash resolve the media record. **This contract is confirmed against a live EmDash instance:** `featured_image: { "id": "<ULID>" }` is accepted (HTTP 200) and the server stores the id and normalizes `"provider": "local"` itself.
440
461
  - Emit never fabricates media metadata from local `media/*.yaml` files: companion files are used only to cross-check a reference. In a workspace that mirrors media (`media/` exists and is non-empty), a missing or mismatched companion file is reported as a push warning — it means Emit could not confirm the media ID exists, not that the payload is invalid. Workspaces that do not mirror media produce no such warning.
@@ -447,19 +468,18 @@ EmDash supports rich visual blocks (e.g., galleries, callouts, audio players). W
447
468
  ```html
448
469
  <!--ec:block type="core:gallery" id="01M6XYZ..." data="{\"columns\":3,\"images\":[...]}" -->
449
470
  <!--/ec:block-->
450
-
451
471
  ```
452
472
 
453
- * **Rule**: Do not modify or delete these comment fences unless you intentionally want to remove the block. Emit preserves them losslessly on round-trips.
473
+ - **Rule**: Do not modify or delete these comment fences unless you intentionally want to remove the block. Emit preserves them losslessly on round-trips.
454
474
 
455
475
  ### Media Management & Upload Workflow
456
476
 
457
477
  EmDash stores media files (images, documents) in Cloudflare R2 and uses **two distinct identifier spaces**:
458
478
 
459
- | Usage | Identifier Used | Format / Example |
460
- | --- | --- | --- |
461
- | Frontmatter `featuredImage` | **Media Record `id`** | 26-character ULID (`01M67P0MBMJQQXH2K95HHCYRYB`) or `null` |
462
- | Body `![alt](...)` URL | **Media `storageKey`** | `/_emdash/api/media/file/<storageKey>` (e.g. `/_emdash/api/media/file/01M67P07X2VPW0MYRRRGMA6T5A.jpg` or double-ULID `01M6TF1XXBSC06QQG9M648B4K3.01M6TF1Y5YVG9YYQAY6H25270M.png`) |
479
+ | Usage | Identifier Used | Format / Example |
480
+ | --------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
481
+ | Frontmatter `featuredImage` | **Media Record `id`** | 26-character ULID (`01M67P0MBMJQQXH2K95HHCYRYB`) or `null` |
482
+ | Body `![alt](...)` URL | **Media `storageKey`** | `/_emdash/api/media/file/<storageKey>` (e.g. `/_emdash/api/media/file/01M67P07X2VPW0MYRRRGMA6T5A.jpg` or double-ULID `01M6TF1XXBSC06QQG9M648B4K3.01M6TF1Y5YVG9YYQAY6H25270M.png`) |
463
483
 
464
484
  > [!IMPORTANT]
465
485
  > Never put a media record `id` into `/_emdash/api/media/file/<id>.<ext>`—the public file route resolves by **`storageKey`**, so using a record `id` returns HTTP 404.
@@ -477,7 +497,7 @@ You can author articles completely offline using local images and let `emit push
477
497
  - Uploads the image via EmDash's 4-stage flow (`POST /_emdash/api/media/upload-url` with `sha1:<hex>` deduplication → `PUT` upload → `POST /_emdash/api/media/{id}/confirm`), cleaning up any orphan `pending` record if an upload fails midway.
478
498
  - Rewrites the Markdown line in place to the canonical root-relative `/_emdash/api/media/file/<storageKey>` URL.
479
499
  - Pushes the article to EmDash.
480
- - Promotes the local file from `media/drafts/<filename>` to `media/<yyyyMMdd>_<mediaId>-<filename>` alongside a companion `.yaml` metadata file (`storageKey`, `contentHash`, `status`). The `<yyyyMMdd>` prefix reflects the media record's remote `createdAt` when available; if Emit only had a provisional local date, the next `emit pull-media` canonicalizes the filename.
500
+ - Promotes the local file from `media/drafts/<filename>` to `media/<markdownRef>.<ext>` alongside a companion `media/<markdownRef>.yaml` metadata file (`id`, `storageKey`, `contentHash`, `status`). `markdownRef` is derived from the record's `storageKey` (basename, sanitized, extension removed), so the promoted file matches the body URL exactly and no date prefix or later renaming pass is needed. If two records sanitize to the same stem, the second deterministically takes a `<stem>--<mediaId>` suffix.
481
501
  4. **Reuse Across Articles (no re-upload):** The same `media/drafts/<filename>` line can be used in several articles. After the first push promotes the asset, every later push resolves that reference through `media_state` / `media/*.yaml` and rewrites it to the already-confirmed `/_emdash/api/media/file/<storageKey>` URL instead of uploading again (reported as `[LOCAL_IMAGE_AUTO_UPLOAD] Reused N already-promoted media asset(s)`). A direct reference to the promoted path works as well. Emit verifies each reused record with `GET /_emdash/api/media/{id}`: if the record was deleted on the EmDash side the push aborts with `PROMOTED_MEDIA_NOT_FOUND_REMOTE` (run `emit pull-media` or re-stage the image in `media/drafts/`), and servers without that endpoint push successfully but warn with `[MEDIA_EXISTENCE_UNVERIFIED]`.
482
502
 
483
503
  #### Workflow B: Pre-Uploaded Media & Featured Hero Images
@@ -490,10 +510,11 @@ You can author articles completely offline using local images and let `emit push
490
510
 
491
511
  > [!WARNING]
492
512
  > **Local Image Guardrails (G5 & G11)**:
513
+ >
493
514
  > - Arbitrary local paths (e.g. `![pic](./my-image.png)`, absolute filesystem paths, or a `media/` path that does not resolve to a promoted media record) are blocked by Preflight Guard **G5 (`LOCAL_IMAGE_DETECTED`)**. Paths under `media/drafts/` and already-promoted `media/<yyyyMMdd>_<id>-<name>.<ext>` paths are the only accepted local forms.
494
515
  > - Local images placed inline inside a paragraph (e.g. `Text ![pic](../../media/drafts/a.png) more text`) are blocked with `INLINE_LOCAL_IMAGE_NOT_SUPPORTED` because EmDash Portable Text requires images to be standalone top-level blocks.
495
516
  > - A `media/drafts/` reference whose file no longer exists and which has no promoted match is blocked with `MISSING_LOCAL_IMAGE_FILE` (the promoted location is reported in the error).
496
- > - Only **backtick** fences (```` ``` ````) are exempt from image scanning. Tilde fences (`~~~`) are not recognized as code by the Portable Text mapper, so they are scanned like normal prose (and emit a `G8` warning because they degrade to plain paragraphs). Write code examples with backticks.
517
+ > - Only **backtick** fences (` ``` `) are exempt from image scanning. Tilde fences (`~~~`) are not recognized as code by the Portable Text mapper, so they are scanned like normal prose (and emit a `G8` warning because they degrade to plain paragraphs). Write code examples with backticks.
497
518
 
498
519
  ---
499
520
 
@@ -517,34 +538,40 @@ Whether you are a human developer or an autonomous AI agent (Antigravity, Claude
517
538
  1. **CHECK (`emit status`)**: Check your workspace status to see synced, modified, and untracked files.
518
539
  2. **PULL (`emit pull`)**: Synchronize the latest articles and taxonomies from the server into your local workspace. Taxonomies (categories/tags) are synced automatically in batch mode.
519
540
  3. **EDIT (Direct Editing)**:
520
- * For new posts: run `emit new <slug> --title "My Title"` to scaffold a canonical draft.
521
- * Edit the Markdown body and YAML frontmatter in `posts/draft/` or `posts/published/`.
541
+
542
+ - For new posts: run `emit new <slug> --title "My Title"` to scaffold a canonical draft.
543
+ - Edit the Markdown body and YAML frontmatter in `posts/draft/` or `posts/published/`.
522
544
 
523
545
  4. **PUSH (`emit push`)**:
524
- * To push a draft: `emit push posts/draft/<filename>.md`
525
- * To publish live: update `status: published` and run `emit push posts/draft/<filename>.md --allow-publish`.
526
-
527
- ### Preflight Safety Guardrails (G1–G14)
528
-
529
- To ensure autonomous AI agents and automated scripts cannot corrupt workspace state or accidentally publish unintended content, Emit enforces 14 strict preflight guardrails before executing any write operation:
530
-
531
- | Guard | Code Name | Description | Severity |
532
- | --- | --- | --- | --- |
533
- | **G1** | `SINGLE_TARGET_GUARD` | Enforces explicit target (`filePath`, `id`, or `--all`) for push/pull; forbids blind bulk push without inspection | Error |
534
- | **G2** | `ID_MISMATCH_ERROR` | Verifies that frontmatter `id` exactly matches the filename ULID; forbids manual `mv` renames | Error |
535
- | **G3** | `UNINTENDED_PUBLISH_GUARD` | Rejects promoting `draft` to `published` unless `--allow-publish` (`allowPublish: true`) is explicitly given | Error |
536
- | **G4** | `INVALID_TAXONOMY_ERROR` | Validates taxonomy terms against local definitions (transparently resolves valid term ULIDs) to stop AI taxonomy hallucination | Error |
537
- | **G5** | `LOCAL_IMAGE_DETECTED` / `INLINE_LOCAL_IMAGE_NOT_SUPPORTED` / `MISSING_LOCAL_IMAGE_FILE` | Rejects local image paths other than `media/drafts/<file>` (or an already-promoted `media/<yyyyMMdd>_<id>-<name>.<ext>` asset) and rejects inline paragraph local images (must be a standalone line) | Error |
538
- | **G6** | `UNRESOLVED_CONFLICT_MARKERS` | Scans for Git 3-way merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and blocks push | Error |
539
- | **G7** | `TITLE_DUPLICATION_AUTOTRIM` | Detects redundant `# Title` headings at the top of the body and automatically trims them safely | Warning |
540
- | **G8** | `UNSUPPORTED_SYNTAX_WARNING` | Warns if unsupported block HTML or incompatible constructs are detected in Markdown | Warning |
541
- | **G9** | `MODIFIED_OPAQUE_BLOCK` | Verifies that `<!--ec:block ... -->` opaque fences have not been tampered with or corrupted | Error |
542
- | **G10** | `INVALID_FEATURED_IMAGE` | Verifies that `featuredImage` is a 26-character canonical media ULID (normalized to uppercase) or `null`. A remote media URL that contains a ULID is resolved with a warning; local file paths and malformed values are rejected | Error |
546
+
547
+ - To push a draft: `emit push posts/draft/<filename>.md`
548
+ - To publish live: update `status: published` and run `emit push posts/draft/<filename>.md --allow-publish`.
549
+
550
+ ### Preflight Safety Guardrails (G1–G19)
551
+
552
+ To ensure autonomous AI agents and automated scripts cannot corrupt workspace state or accidentally publish unintended content, Emit enforces 19 strict safety guardrails before executing any write operation:
553
+
554
+ | Guard | Code Name | Description | Severity |
555
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
556
+ | **G1** | `SINGLE_TARGET_GUARD` | Enforces explicit target (`filePath`, `id`, or `--all`) for push/pull; forbids blind bulk push without inspection | Error |
557
+ | **G2** | `ID_MISMATCH_ERROR` | Verifies that frontmatter `id` exactly matches the filename ULID; forbids manual `mv` renames | Error |
558
+ | **G3** | `UNINTENDED_PUBLISH_GUARD` | Rejects promoting `draft` to `published` unless `--allow-publish` (`allowPublish: true`) is explicitly given | Error |
559
+ | **G4** | `INVALID_TAXONOMY_ERROR` | Validates taxonomy terms against local definitions (transparently resolves valid term ULIDs) to stop AI taxonomy hallucination | Error |
560
+ | **G5** | `LOCAL_IMAGE_DETECTED` / `INLINE_LOCAL_IMAGE_NOT_SUPPORTED` | Rejects local image paths other than `media/drafts/<file>` (or an already-promoted `media/<markdownRef>.<ext>` asset) and rejects inline paragraph local images (must be a standalone line) | Error |
561
+ | **G6** | `UNRESOLVED_CONFLICT_MARKERS` | Scans for Git 3-way merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and blocks push | Error |
562
+ | **G7** | `TITLE_DUPLICATION_AUTOTRIM` | Detects redundant `# Title` headings at the top of the body and automatically trims them safely | Warning |
563
+ | **G8** | `UNSUPPORTED_SYNTAX_WARNING` | Warns if unsupported block HTML, tables, or tilde code fences are detected in Markdown (`detectUnsupportedMarkdownSyntax`) | Warning |
564
+ | **G9** | `MODIFIED_OPAQUE_BLOCK` | Verifies that `<!--ec:block ... -->` opaque fences have not been tampered with or corrupted | Error |
565
+ | **G10** | `INVALID_FEATURED_IMAGE` | Verifies that `featuredImage` is a 26-character canonical media ULID (normalized to uppercase) or `null`. A remote media URL that contains a ULID is resolved with a warning; local file paths and malformed values are rejected | Error |
543
566
  | **G11** | `LOCAL_IMAGE_AUTO_UPLOAD` (`MISSING_LOCAL_IMAGE_FILE` / `INVALID_LOCAL_IMAGE_TYPE` / `LOCAL_IMAGE_SIZE_EXCEEDED` / `PROMOTED_MEDIA_NOT_FOUND_REMOTE` / `[MEDIA_EXISTENCE_UNVERIFIED]`) | Detects standalone `media/drafts/<file>` image lines (or reuses an already-promoted `media/` asset across articles), validates file existence, extension, and size (≤ 50 MiB), queues them for automatic upload, URL rewrite, and `media/` promotion during `push`, and verifies every reused asset against `GET /_emdash/api/media/{id}` before its URL is baked into the body | Info / Warning / Error |
544
- | **G12** | `QUARANTINED_COPY_NOT_PUSHABLE` | Refuses to push a rescue copy under `posts/conflicts/` (written by a pull that found divergent local content) before any network request, because it would overwrite the canonical article of the same ULID with stale content and recreate duplicate local files | Error |
545
- | **G13** | `CANONICAL_HAS_UNPUSHED_EDITS` | Protects canonical articles from being silently overwritten during conflict resolution (`accept-local`) when unpushed local modifications exist; requires explicit `--force` (`force: true`) | Error |
546
- | **G14** | `QUARANTINED_PATH_REQUIRED` | Restricts conflict resolution targets strictly within `<collection>/conflicts/`, preventing accidental overwrites or unlinks outside the quarantine directory | Error |
547
- | **Guard** | `DUPLICATE_SLUG_GUARD` | Scans local drafts, published posts, cache index, and remote collection during `emit new` / `sync_new` to strictly block creating scaffold articles with already-existing slugs | Error |
567
+ | **G12** | `QUARANTINED_COPY_NOT_PUSHABLE` | Refuses to push a rescue copy under `posts/conflicts/` (written by a pull that found divergent local content) before any network request, because it would overwrite the canonical article of the same ULID with stale content and recreate duplicate local files | Error |
568
+ | **G13** | `CANONICAL_HAS_UNPUSHED_EDITS` | Protects canonical articles from being silently overwritten during conflict resolution (`accept-local`) when unpushed local modifications exist; requires explicit `--force` (`force: true`) | Error |
569
+ | **G14** | `QUARANTINED_PATH_REQUIRED` | Restricts conflict resolution targets strictly within `<collection>/conflicts/`, preventing accidental overwrites or unlinks outside the quarantine directory | Error |
570
+ | **G15** | `DELETE_PENDING_ARTICLE` | Blocks pushing articles marked delete-pending when remote deletion was unconfirmed; requires `--revive` or `--force` | Error |
571
+ | **G16** | `TAXONOMY_CONFLICT_PENDING` | Blocks re-pushing articles with unresolved taxonomy conflicts from a previous sync; requires `--resolve-taxonomy` or `--force` | Error |
572
+ | **G17** | `TAXONOMY_APPLY_PENDING` | Blocks pushing when previous taxonomy assignment failed remotely; requires `--allow-unapplied-taxonomies` or `--force` | Error |
573
+ | **G18** | `DUPLICATE_SLUG_CONFLICT` | Scans local drafts, published posts, cache index, and remote collection during `emit new` / `sync_new` to strictly block creating scaffold articles with already-existing slugs | Error |
574
+ | **G19** | `DESTRUCTIVE_TAXONOMY_WIPE` | Blocks pushing empty taxonomy arrays that would wipe existing remote assignments; requires `--allow-wipe-taxonomies` or `--force` | Error |
548
575
 
549
576
  ---
550
577
 
@@ -559,7 +586,7 @@ emit init [--origin <url>]
559
586
 
560
587
  ```
561
588
 
562
- * `--origin <url>`: Optional EmDash origin URL. If omitted, uses the active profile.
589
+ - `--origin <url>`: Optional EmDash origin URL. If omitted, uses the active profile.
563
590
 
564
591
  **Example Output:**
565
592
 
@@ -604,12 +631,12 @@ emit new <slug> [-t, --title <title>] [-c, --collection <collection>] [-l, --loc
604
631
 
605
632
  ```
606
633
 
607
- * `<slug>`: URL slug for the article (e.g., `modern-web-architecture`).
608
- * `-t, --title <title>`: Human-readable article title.
609
- * `-c, --collection <name>`: Collection name (defaults to `posts`).
610
- * `-l, --locale <locale>`: Locale code (defaults to `ja` or config).
611
- * `-b, --byline <slug>`: Author byline slug (defaults to the first cached non-guest author ordered by `slug ASC`).
612
- * `-p, --profile <name>`: Explicitly target a specific connection profile.
634
+ - `<slug>`: URL slug for the article (e.g., `modern-web-architecture`).
635
+ - `-t, --title <title>`: Human-readable article title.
636
+ - `-c, --collection <name>`: Collection name (defaults to `posts`).
637
+ - `-l, --locale <locale>`: Locale code (defaults to `ja` or config).
638
+ - `-b, --byline <slug>`: Author byline slug (defaults to the first cached non-guest author ordered by `slug ASC`).
639
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
613
640
 
614
641
  **Example:**
615
642
 
@@ -647,19 +674,27 @@ emit pull [id] [options]
647
674
 
648
675
  ```
649
676
 
650
- * `[id]`: Optional article ID or ULID. If specified, pulls only that specific article (even if outside the window). Taxonomies are not pulled in single-article mode.
651
- * `-s, --status <draft|published|all>`: Filter by publication status (default: `all`). When set to `all`, the sliding window (`postsSyncLimit`, default `100`) is applied **per status** (fetching up to `postsSyncLimit` `published` + up to `postsSyncLimit` `draft` articles in parallel).
652
- * `-c, --collection <collection>`: Collection name (default: `posts`).
653
- * `--no-prune`: Do not prune local articles that have fallen outside the sliding window (escape hatch; not intended for routine use).
654
- * `--force`: Force overwrite local files and bypass differential hash checking.
655
- * `--concurrency <n>`: Parallel processing task limit (default: `8`).
656
- * `--posts-only`: Skip automatic remote taxonomy sync. Pulls articles only (local taxonomy candidates hint is still displayed from cached definitions).
657
- * `--media`: Also pull media assets and companion metadata into `media/` (bounded by `mediaSyncLimit`, default `300`). Use `emit pull-media` for `--limit`, `--all`, and `--prune` control.
658
- * `--metadata-only`: With `--media`: download only metadata YAML, skip binary file downloads.
677
+ - `[id]`: Optional article ID or ULID. If specified, pulls only that specific article (even if outside the window). Taxonomies are not pulled in single-article mode.
678
+ - `-s, --status <draft|published|all>`: Filter by publication status (default: `all`). When set to `all`, the sliding window (`postsSyncLimit`, default `30`) is applied **per status** (fetching up to `postsSyncLimit` `published` + up to `postsSyncLimit` `draft` articles in parallel).
679
+ - `-c, --collection <collection>`: Collection name (default: `posts`).
680
+ - `--no-prune`: Do not prune local articles that have fallen outside the sliding window (escape hatch; not intended for routine use).
681
+ - `--force`: Force overwrite local files and bypass differential hash checking.
682
+ - `--concurrency <n>`: Parallel processing task limit (default: `8`).
683
+ - `--posts-only`: Skip automatic remote taxonomy sync. Pulls articles only (local taxonomy candidates hint is still displayed from cached definitions).
684
+ - `--media`: Bounded media-library pass: pull media assets and companion metadata into `media/` (bounded by `mediaSyncLimit`, default `300`). Note that referenced media in pulled articles is mirrored on-demand by default (local-first media pipeline) without requiring this flag. Use `emit pull-media` for `--limit`, `--all`, and `--prune` control.
685
+ - `--metadata-only`: With `--media`: download only metadata YAML, skip binary file downloads.
686
+ - `--local-media-refs`: Write mirrored body images as article-relative `../../media/<storageKey>` references instead of the canonical `/_emdash/api/media/file/<storageKey>` URL, so Markdown preview and local tooling resolve them offline from the article's own directory. The choice is **remembered in `.emit/config.json`** and is **default-ON**, so every later pull, push, and diff keeps the same spelling without repeating the flag.
687
+ - `--no-local-media-refs`: Clear a previously remembered preference and store canonical EmDash URLs on disk again.
659
688
 
660
689
  > [!NOTE]
661
690
  > **Taxonomies are pulled by default in batch mode.** Categories and tags are lightweight (2 API calls) and required for valid push operations (Preflight Guard G4 validates taxonomy terms against these local definitions). This eliminates a common source of push failures. Use `--posts-only` if you need articles only.
662
691
 
692
+ > [!NOTE]
693
+ > **Local media references change only the disk spelling, never the remote payload.** With `localMediaRefs` enabled (the default), `base_snapshots` and the body sent to EmDash keep the canonical `/_emdash/api/media/file/<storageKey>` URL, while the Markdown file carries `../../media/<storageKey>` spelled from its own directory; only `sync_state.local_hash` tracks those disk bytes. Because the baseline stays canonical, `emit diff` renders it in whichever spelling the disk already uses — an untouched article reports no change, and only a genuine edit appears.
694
+
695
+ > [!NOTE]
696
+ > **A workspace written by an older Emit heals itself.** Files that still carry the depth-free `media/<storageKey>` spelling are re-spelled to the article-relative form on the next `emit pull`, and re-tracked against their own repaired hash — no content changes, no phantom "modified", and the following pull of the same revision skips.
697
+
663
698
  > [!NOTE]
664
699
  > **Why `--no-prune` is rarely needed:**
665
700
  > Automatic pruning is mathematically loss-free: Emit's unpushed edits safeguard inspects both the canonical file and any duplicate filenames sharing the same 26-character ULID, strictly aborting the pull (`SyncPruneConflictError`) if any of them contain local modifications. Clean duplicate files are removed alongside the canonical file. You only need `--no-prune` in exceptional circumstances, such as temporarily suppressing Git working-tree changes during a quick pull.
@@ -700,11 +735,11 @@ Available Categories:
700
735
  Available Tags:
701
736
  - ai, cloudflare, emit, typescript
702
737
 
703
- Fetching articles from remote (posts, status: all, window: 100/status, concurrency: 8)...
704
- [1/169] ✅ Pulled hello-world -> posts/published/20261101_01M5...-Hello-World.md
705
- [2/169] ✅ Pulled draft-post -> posts/draft/20261104_01M6...-Draft-Post.md
738
+ Fetching articles from remote (posts, status: all, window: 30/status, concurrency: 8)...
739
+ [1/50] ✅ Pulled hello-world -> posts/published/20261101_01M5...-Hello-World.md
740
+ [2/50] ✅ Pulled draft-post -> posts/draft/20261104_01M6...-Draft-Post.md
706
741
 
707
- 🎉 Articles: 169 total [100 published, 69 draft] (12 updated, 157 skipped/up-to-date)
742
+ 🎉 Articles: 50 total [30 published, 20 draft] (12 updated, 38 skipped/up-to-date)
708
743
 
709
744
  ```
710
745
 
@@ -719,16 +754,17 @@ emit push [file] [options]
719
754
 
720
755
  ```
721
756
 
722
- * `[file]`: Relative or absolute path to the Markdown file.
723
- * `--allow-publish`: Required when promoting an article to `status: published`.
724
- * `--all`: Pushes all modified files in the workspace.
725
- * `--no-taxonomy-audit`: Skips pre-PUT remote taxonomy divergence audit.
726
- * `--allow-unapplied-taxonomies`: Pushes article body even if taxonomy term assignment fails (bypasses Guard G17).
727
- * `--allow-wipe-taxonomies`: Confirms intentional deletion of all assigned categories or tags.
728
- * `--resolve-taxonomy <strategy>`: Resolves taxonomy conflict explicitly (`union`, `local`, or `remote`).
729
- * `--revive`: Pushes an article that was previously marked delete_pending.
730
- * `-f, --force`: Bypasses non-critical safety guardrails.
731
- * `-p, --profile <name>`: Explicitly target a specific connection profile.
757
+ - `[file]`: Relative or absolute path to the Markdown file.
758
+ - `--allow-publish`: Required when promoting an article to `status: published`.
759
+ - `--all`: Pushes all modified files in the workspace.
760
+ - `--no-taxonomy-audit`: Skips pre-PUT remote taxonomy divergence audit.
761
+ - `--allow-unapplied-taxonomies`: Pushes article body even if taxonomy term assignment fails (bypasses Guard G17).
762
+ - `--allow-wipe-taxonomies`: Confirms intentional deletion of all assigned categories or tags.
763
+ - `--resolve-taxonomy <strategy>`: Resolves taxonomy conflict explicitly (`union`, `local`, or `remote`).
764
+ - `--revive`: Pushes an article that was previously marked delete_pending.
765
+ - `-f, --force`: Bypasses non-critical safety guardrails.
766
+ - `--local-media-refs` / `--no-local-media-refs`: Override the remembered workspace preference for how mirrored body images are spelled on disk (see [`emit pull`](#emit-pull)). Push always sends the canonical URL to remote; the flag only decides what stays in the local file, and passing it persists the choice to `.emit/config.json`.
767
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
732
768
 
733
769
  **Example:**
734
770
 
@@ -751,11 +787,11 @@ Safely deletes a local Markdown article and its remote counterpart on EmDash.
751
787
  emit delete <filePathOrId> [--allow-delete-published] [--dry-run] [-y, --yes] [-p, --profile <name>]
752
788
  ```
753
789
 
754
- * `<filePathOrId>`: Relative/absolute path to the Markdown file, or 26-character article ULID.
755
- * `--allow-delete-published`: Required when deleting a published article (safety guardrail).
756
- * `--dry-run`: Previews the deletion, inspecting referenced media and lines without modifying disk or remote.
757
- * `-y, --yes`: Skips interactive confirmation prompt.
758
- * `-p, --profile <name>`: Explicitly target a specific connection profile.
790
+ - `<filePathOrId>`: Relative/absolute path to the Markdown file, or 26-character article ULID.
791
+ - `--allow-delete-published`: Required when deleting a published article (safety guardrail).
792
+ - `--dry-run`: Previews the deletion, inspecting referenced media and lines without modifying disk or remote.
793
+ - `-y, --yes`: Skips interactive confirmation prompt.
794
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
759
795
 
760
796
  > [!NOTE]
761
797
  > **Rescue Backups on Deletion**: When an article is deleted, Emit automatically creates a safety rescue backup at `posts/conflicts/<name>.deleted.md` before removing the file, preserving your work in case of accidental deletion.
@@ -778,10 +814,10 @@ emit conflicts resolve <filePath> --accept-local [-f, --force] [-c, --collection
778
814
  emit conflicts resolve <filePath> --accept-canonical [-c, --collection <collection>]
779
815
  ```
780
816
 
781
- * `list`: Scans `<collection>/conflicts/`, classifying items as `⚠️ SYNC CONFLICT` or `🗑️ DELETED RESCUE`, showing sizes and dates.
782
- * `diff`: Displays a unified diff against the current canonical article.
783
- * `resolve --accept-local`: Restores the quarantined content to the canonical location and purges the quarantine file. Guard **G13** blocks overwriting canonical articles that contain unpushed local modifications unless `-f, --force` is explicitly provided.
784
- * `resolve --accept-canonical`: Safely discards the quarantined copy, retaining the canonical version.
817
+ - `list`: Scans `<collection>/conflicts/`, classifying items as `⚠️ SYNC CONFLICT` or `🗑️ DELETED RESCUE`, showing sizes and dates.
818
+ - `diff`: Displays a unified diff against the current canonical article.
819
+ - `resolve --accept-local`: Restores the quarantined content to the canonical location and purges the quarantine file. Guard **G13** blocks overwriting canonical articles that contain unpushed local modifications unless `-f, --force` is explicitly provided.
820
+ - `resolve --accept-canonical`: Safely discards the quarantined copy, retaining the canonical version.
785
821
 
786
822
  ---
787
823
 
@@ -790,10 +826,11 @@ emit conflicts resolve <filePath> --accept-canonical [-c, --collection <collecti
790
826
  Runs an automated diagnostic health check across the local workspace and cache database.
791
827
 
792
828
  ```bash
793
- emit doctor [-c, --collection <collection>] [-p, --profile <name>]
829
+ emit doctor [-c, --collection <collection>] [--strict-media] [--check-media-urls] [--check-media-library] [-p, --profile <name>]
794
830
  ```
795
831
 
796
832
  Validates 6 critical health vectors:
833
+
797
834
  1. **Version Compatibility**: Checks tool version against `createdWithVersion` / `lastSyncedVersion` in config.
798
835
  2. **Origin Consistency**: Verifies `.emit/config.json`, profile origin, and environment variables.
799
836
  3. **Frontmatter Integrity**: Ensures all local Markdown files have valid parseable frontmatter.
@@ -801,6 +838,11 @@ Validates 6 critical health vectors:
801
838
  5. **Sync Status**: Verifies local baseline hash tracking.
802
839
  6. **Referenced Media Existence**: Validates that all local media referenced in article Markdown bodies exist on disk.
803
840
 
841
+ Media flags:
842
+
843
+ - `--check-media-urls`: Probe `HEAD /_emdash/api/media/file/<storageKey>` for references no local source resolves, downgrading ones the origin still serves to informational notes.
844
+ - `--check-media-library`: Walk the whole remote library page by page via metadata only (no binary downloads) and state how many records it holds against how many this workspace mirrors, so a missing-file count reads as coverage rather than data loss.
845
+
804
846
  ---
805
847
 
806
848
  ### `emit status`
@@ -841,9 +883,9 @@ emit diff [filePath] [-i, --id <id>] [-p, --profile <name>]
841
883
 
842
884
  ```
843
885
 
844
- * `[filePath]`: Relative path to the local Markdown file (e.g., `posts/draft/20261104_01M6...-my-article.md`).
845
- * `-i, --id <id>`: Inspect by 26-character article ULID instead of file path.
846
- * `-p, --profile <name>`: Explicitly target a specific connection profile.
886
+ - `[filePath]`: Relative path to the local Markdown file (e.g., `posts/draft/20261104_01M6...-my-article.md`).
887
+ - `-i, --id <id>`: Inspect by 26-character article ULID instead of file path.
888
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
847
889
 
848
890
  ---
849
891
 
@@ -878,14 +920,40 @@ Available Tags:
878
920
  Downloads media assets and their companion metadata YAML files into `media/`. Supports pulling all media, a limited batch, or targeted media referenced by a specific article.
879
921
 
880
922
  ```bash
881
- emit pull-media [-a, --article <id-or-path>] [-l, --limit <n>] [--all] [--prune] [--metadata-only]
923
+ emit pull-media [-a, --article <id-or-path>] [--storage-key <key>] [-l, --limit <n>] [--all] [--prune] [--metadata-only]
882
924
  ```
883
925
 
884
- * `-a, --article <id-or-path>`: Pulls only media assets referenced by the specified article (featured image and body images), saving bandwidth and disk space.
885
- * `-l, --limit <n>`: Maximum number of media items to fetch (default: `300`).
886
- * `--all`: Fetch all media items without limit.
887
- * `--prune`: Remove local media files that no longer exist on remote.
888
- * `--metadata-only`: Download metadata YAML files only, skipping large binary downloads.
926
+ - `-a, --article <id-or-path>`: Pulls only media assets referenced by the specified article (featured image and body images), saving bandwidth and disk space. The summary separates what Emit actually mirrored from what it merely verified, and states plainly that a migrated body's ULIDs are `storageKey`s rather than record ids — so an id lookup resolving nothing is reported as expected, never as `failed`.
927
+ - `--storage-key <key>`: Mirror a specific storageKey directly (can be specified multiple times; mutually exclusive with `--article` and `--all`).
928
+ - `-l, --limit <n>`: Maximum number of media items to fetch (default: `300`).
929
+ - `--all`: Fetch all media items without limit, walking the remote library cursor-by-cursor until it is exhausted. **Maintenance / Backup Only**: Routine workflows do not need this because `emit pull` automatically mirrors referenced media on-demand. Fetching the full library can download thousands of images and consume substantial bandwidth and disk space.
930
+ - `--prune`: Remove local media files whose companion `.yaml` names a media record that no longer exists on remote. Ownership is read from the companion, never guessed from a filename, so a file with no companion is left untouched. Residue written by older Emit versions is never pruned and must be deleted manually.
931
+ - `--metadata-only`: Download metadata YAML files only, skipping large binary downloads.
932
+
933
+ > **Two ID spaces, one lookup path:** Body URLs carry the R2 `storageKey`, while `GET /_emdash/api/media/{id}` searches the D1 primary key, so an existing record can 404 when queried with a body URL's ULID. The list operation is the only endpoint that reports each record's own `storageKey`, and it exposes no storageKey filter (`q` matches filenames only) — which is why `emit doctor --check-media-library` (or `emit pull-media --all` for maintenance backup) is how Emit reaches migrated assets, not a targeted lookup.
934
+
935
+ > **How a migrated article reads:** for an article whose body bakes R2 `storageKey`s, `emit pull-media --article` reports something like `8 referenced item(s) (0 downloaded, 0 skipped/up-to-date, 4 verified/mirrored). (4 body ULID(s) are R2 storageKeys, not media record ids, so no id lookup was expected to match)`. The assets already under `media/` are named after the **storageKey** the body references (or, in a workspace that has not been re-pulled yet, after the legacy **record id**), and each companion `.yaml` carries both identifiers, so those images resolve through local mirror metadata instead of re-downloading. Nothing in that line is a failure: `failed` counts only references that no id lookup _and_ no storageKey check could match.
936
+
937
+ > **Unresolved assets:** When an article body references a `/_emdash/api/media/file/<storageKey>` image that Emit cannot match to a media record or to confirmed local mirror metadata, Emit reports it as `⚠️ [UNRESOLVED_MEDIA_ASSET]` and mirrors nothing. That is a _lookup_ failure, not proof that the record is missing. Index the whole library first with `emit doctor --check-media-library` (metadata-only walk) or `emit pull-media --all` (offline backup), which resolves these keys without any server-side query; only then verify anything left over by storageKey before registering it — a blind INSERT can duplicate a record that already exists. Registering records is an EmDash/Wrangler task; once Emit can resolve the record, a normal `emit pull-media` mirrors the asset normally. See [docs/media-id-spaces.md](docs/media-id-spaces.md) for the full two-ID-space specification.
938
+
939
+ ---
940
+
941
+ ### `emit media-search`
942
+
943
+ Search the remote EmDash media library by keyword across filename, alt text, caption, and storageKey, displaying whether each hit is already mirrored locally:
944
+
945
+ ```bash
946
+ emit media-search <query> [-l, --limit <n>] [--max-scan <n>] [--mirrored-only] [--suggest-keywords] [-p, --profile <name>]
947
+ ```
948
+
949
+ Options:
950
+ - `-l, --limit <n>`: Maximum hits to return (default: 20, max: 50).
951
+ - `--max-scan <n>`: Maximum remote records to read before stopping (default: 500; `0` = walk entire library).
952
+ - `--mirrored-only`: Show only assets already mirrored in this workspace.
953
+ - `--suggest-keywords`: Append top local tag slugs as search hints.
954
+ - `-p, --profile <name>`: Explicitly select a connection profile.
955
+
956
+ Absence is only proven when `walkComplete: true` (a complete walk). When stopped early, `[WALK_INCOMPLETE]` indicates that an asset may still exist in the unread portion of the library.
889
957
 
890
958
  ---
891
959
 
@@ -928,7 +996,6 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
928
996
  }
929
997
  }
930
998
  }
931
-
932
999
  ```
933
1000
 
934
1001
  #### 2. Cursor (`.cursor/mcp.json`)
@@ -942,7 +1009,6 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
942
1009
  }
943
1010
  }
944
1011
  }
945
-
946
1012
  ```
947
1013
 
948
1014
  #### 3. Google Antigravity (`~/.gemini/antigravity/mcp_config.json`)
@@ -956,27 +1022,27 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
956
1022
  }
957
1023
  }
958
1024
  }
959
-
960
1025
  ```
961
1026
 
962
1027
  > **Tip:** When profiles are registered using `emit profile add --workspace <dir>`, the MCP server automatically discovers credentials and paths without requiring secrets in your agent configuration.
963
1028
 
964
1029
  ### Available MCP Tools
965
1030
 
966
- | Tool | Purpose | Key Parameters |
967
- | --- | --- | --- |
968
- | `sync_status` | Inspect local sync state (summary-first structure with capped actionRequired, articles, and search filters) | `collection`, `status`, `search`, `profile` |
969
- | `sync_pull` | Pull remote articles **and taxonomies** into local workspace (includes taxonomy hints; preserves divergent local copies under `posts/conflicts/`) | `collection`, `id`, `filePath`, `status`, `force`, `noPrune`, `prune`, `postsOnly`, `includeMedia`, `metadataOnly`, `profile` |
970
- | `sync_new` | Scaffold new article with canonical ULID, frontmatter, default byline, duplicate slug guard, and taxonomy hints | `slug` (required), `title`, `collection`, `locale`, `byline`, `profile` |
971
- | `sync_push` | Push local article changes to remote with preflight checks and `media/drafts/*` auto-upload; refuses `posts/conflicts/` rescue copies (G12) | `filePath` (required), `allowPublish`, `profile` |
972
- | `sync_delete` | Safely delete article locally and remotely with rescue backup (`.deleted.md`) and unpushed edit protection | `filePath`, `id`, `allowDeletePublished`, `dryRun`, `profile` |
973
- | `sync_conflicts` | List, diff, and resolve quarantined conflict copies and deletion backups in `posts/conflicts/` (G13 unpushed edit guard with `force`) | `action` ("list"\|"diff"\|"resolve"), `filePath`, `strategy` ("accept-local"\|"accept-canonical"), `force`, `profile` |
974
- | `sync_doctor` | Run comprehensive workspace diagnostics (version, origin, frontmatter, conflicts, sync, referenced media) | `collection`, `profile` |
975
- | `sync_diff` | Inspect diff between working file and baseline snapshot | `filePath`, `id`, `profile` |
976
- | `sync_pull_taxonomies` | Pull remote category and tag definitions (standalone; returns taxonomy hints) | `profile` |
977
- | `sync_pull_media` | Pull remote media files and metadata into `media/` (supports batch or targeted article media) | `article`, `limit`, `all`, `downloadFiles`, `profile` |
978
- | `profile_list` | List all configured connection profiles | (none) |
979
- | `profile_switch` | Switch active profile for multi-site environments | `name` (required), `persist` |
1031
+ | Tool | Purpose | Key Parameters |
1032
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1033
+ | `sync_status` | Inspect local sync state (summary-first structure with capped actionRequired, articles, and search filters) | `collection`, `status`, `search`, `profile` |
1034
+ | `sync_pull` | Pull remote articles **and taxonomies** into local workspace (includes taxonomy hints; preserves divergent local copies under `posts/conflicts/`) | `collection`, `id`, `filePath`, `status`, `force`, `noPrune`, `prune`, `postsOnly`, `includeMedia`, `metadataOnly`, `profile` |
1035
+ | `sync_new` | Scaffold new article with canonical ULID, frontmatter, default byline, duplicate slug guard, and taxonomy hints | `slug` (required), `title`, `collection`, `locale`, `byline`, `profile` |
1036
+ | `sync_push` | Push local article changes to remote with preflight checks and `media/drafts/*` auto-upload; refuses `posts/conflicts/` rescue copies (G12) | `filePath` (required), `allowPublish`, `profile` |
1037
+ | `sync_delete` | Safely delete article locally and remotely with rescue backup (`.deleted.md`) and unpushed edit protection | `filePath`, `id`, `allowDeletePublished`, `dryRun`, `profile` |
1038
+ | `sync_conflicts` | List, diff, and resolve quarantined conflict copies and deletion backups in `posts/conflicts/` (G13 unpushed edit guard with `force`) | `action` ("list"\|"diff"\|"resolve"), `filePath`, `strategy` ("accept-local"\|"accept-canonical"), `force`, `profile` |
1039
+ | `sync_doctor` | Run comprehensive workspace diagnostics (version, origin, frontmatter, conflicts, sync, referenced media) | `collection`, `profile` |
1040
+ | `sync_diff` | Inspect diff between working file and baseline snapshot | `filePath`, `id`, `profile` |
1041
+ | `sync_pull_taxonomies` | Pull remote category and tag definitions (standalone; returns taxonomy hints) | `profile` |
1042
+ | `sync_pull_media` | Pull remote media files and metadata into `media/` (supports batch, targeted article media, or targeted storageKey) | `article`, `storageKey`, `limit`, `all`, `downloadFiles`, `profile` |
1043
+ | `sync_media_search` | Search remote media library by keyword across filename, alt, caption, and storageKey with local mirror decoration | `query` (required), `limit`, `maxScan`, `mirroredOnly`, `suggestKeywords`, `profile` |
1044
+ | `profile_list` | List all configured connection profiles | (none) |
1045
+ | `profile_switch` | Switch active profile for multi-site environments | `name` (required), `persist` |
980
1046
 
981
1047
  > [!NOTE]
982
1048
  > **Uniform `_meta` Context & Structured Diagnostics**: Every MCP tool response (success and error) includes structured `_meta` context containing `profile`, `workspace`, `origin`, and `resolvedBy` resolution rationale. Handlers return structured error payloads with actionable suggestions, HTTP status, and preflight codes rather than uncaught raw exceptions.
@@ -985,9 +1051,9 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
985
1051
 
986
1052
  With Emit connected via MCP, you can give high-level natural language instructions to your AI:
987
1053
 
988
- * *"Check the workspace status, pull the latest posts, and show me what was changed recently."*
989
- * *"Draft a new blog post titled 'Next-Gen Serverless Architectures' with slug `serverless-next`, add the `tech` category, and write a comprehensive outline."*
990
- * *"Review the draft in `posts/draft/...`, proofread the content, and push it to remote as a draft."*
1054
+ - _"Check the workspace status, pull the latest posts, and show me what was changed recently."_
1055
+ - _"Draft a new blog post titled 'Next-Gen Serverless Architectures' with slug `serverless-next`, add the `tech` category, and write a comprehensive outline."_
1056
+ - _"Review the draft in `posts/draft/...`, proofread the content, and push it to remote as a draft."_
991
1057
 
992
1058
  ---
993
1059
 
@@ -1024,17 +1090,17 @@ Push your changes with 'emit push <file>' or discard them before pulling.
1024
1090
 
1025
1091
  ```
1026
1092
 
1027
- **Cause**: An article that has fallen outside the sliding window (e.g., beyond the latest 100 posts) contains local edits that have not been pushed to the remote server. To prevent accidental data loss, Emit aborts the pull.
1093
+ **Cause**: An article that has fallen outside the sliding window (e.g., beyond the latest 30 posts per status) contains local edits that have not been pushed to the remote server. To prevent accidental data loss, Emit aborts the pull.
1028
1094
 
1029
1095
  **Solution**:
1030
1096
 
1031
- * If you want to keep the changes: Push them to remote using `emit push posts/draft/...`.
1032
- * If you want to discard local edits: Delete or revert the file, then re-run `emit pull`.
1033
- * If you want to temporarily skip pruning: Run `emit pull --no-prune`.
1097
+ - If you want to keep the changes: Push them to remote using `emit push posts/draft/...`.
1098
+ - If you want to discard local edits: Delete or revert the file, then re-run `emit pull`.
1099
+ - If you want to temporarily skip pruning: Run `emit pull --no-prune`.
1034
1100
 
1035
1101
  ### Q: I want to write a comprehensive piece that references my entire back-catalog — is that supported?
1036
1102
 
1037
- **Yes.** The sliding window limits which articles *stay on disk*, not which articles you can *work with*. To reference, quote, or revise any historical post, fetch it individually: `emit pull <article-ULID>` (MCP: `sync_pull` with `id`). Each fetched article enters the normal authoring loop — edit, diff, push — with all guardrails applied. If you prefer a permanently larger local working set instead, raise `postsSyncLimit` in `.emit/config.json`; Emit's pagination engine will fetch up to that size automatically. What Emit deliberately does not offer is a default "clone the whole site" mode; for genuine whole-library operations (bulk media management, site-wide queries), use the official EmDash MCP alongside Emit — see [Division of Labor with the Official EmDash MCP](#6-division-of-labor-with-the-official-emdash-mcp).
1103
+ **Yes.** The sliding window limits which articles _stay on disk_, not which articles you can _work with_. To reference, quote, or revise any historical post, fetch it individually: `emit pull <article-ULID>` (MCP: `sync_pull` with `id`). Each fetched article enters the normal authoring loop — edit, diff, push — with all guardrails applied. If you prefer a permanently larger local working set instead, raise `postsSyncLimit` in `.emit/config.json`; Emit's pagination engine will fetch up to that size automatically. What Emit deliberately does not offer is a default "clone the whole site" mode; for genuine whole-library operations (bulk media management, site-wide queries), use the official EmDash MCP alongside Emit — see [Division of Labor with the Official EmDash MCP](#6-division-of-labor-with-the-official-emdash-mcp).
1038
1104
 
1039
1105
  ### Q: `PushValidationError`: Cannot publish article live without `--allow-publish`
1040
1106
 
@@ -1048,6 +1114,12 @@ Push your changes with 'emit push <file>' or discard them before pulling.
1048
1114
 
1049
1115
  **Solution**: Run `emit pull` or `sync_pull` (taxonomies are synced automatically), or use `emit pull-taxonomies` standalone to refresh your local category and tag list. Preflight Guard G4 will guide you with valid options.
1050
1116
 
1117
+ ### Q: Images in an older article do not render in a local Markdown preview
1118
+
1119
+ **Cause**: Two different things look identical here. (1) The asset was never mirrored locally: `emit pull-media` walks the remote library newest-first and stops at `mediaSyncLimit` (default 300), so images referenced by older articles have no file under `media/`. (2) You are trying to resolve a body URL against local filenames in a workspace that still holds legacy names: body URLs carry the R2 `storageKey`, while a legacy `media/<yyyyMMdd>_<ULID>-<name>.<ext>` is named after the media record `id`, and those two ULIDs never match for the same image. Run `emit pull-media --article <id>` (or a one-time maintenance run of `emit pull-media --all`) and mirrors are renamed to the storageKey-derived stem, so the body URL and the file on disk share one key.
1120
+
1121
+ **Solution**: Run `emit doctor --check-media-library` first (a metadata-only walk) — its `Media References` check reports how many references have no local file _and_ how many records the remote library holds against how many this workspace mirrors, which distinguishes "not mirrored" from "broken reference". To fetch missing images without pulling the entire library, run `emit pull-media --article <id>`. Reserve `emit pull-media --all` strictly for deliberate full offline backups. Only for what remains unresolved after indexing should you verify by `storageKey` through the EmDash admin UI or `wrangler d1 SELECT`, never by guessing a record id. The live site is unaffected in either case, because the public file route serves the R2 object directly. See [docs/media-id-spaces.md](docs/media-id-spaces.md).
1122
+
1051
1123
  ### Q: `[NETWORK_ERROR]`: Network request failed (`fetch failed`)
1052
1124
 
1053
1125
  **Cause**: The configured origin URL is unreachable due to DNS resolution failure, offline network/VPN state, or an incorrect origin in `~/.config/emit/profiles.yaml` or `.emit/config.json`.
@@ -1085,4 +1157,3 @@ npm run check
1085
1157
  This project is open-source software licensed under the [MIT License](LICENSE).
1086
1158
 
1087
1159
  Copyright (c) 2026 SPORT-SERVICE RS★R. All rights reserved.
1088
-