emdash-emit 0.1.14 → 0.1.15

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 (133) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +235 -187
  3. package/dist/cli/commands/doctor.d.ts.map +1 -1
  4. package/dist/cli/commands/doctor.js +6 -0
  5. package/dist/cli/commands/doctor.js.map +1 -1
  6. package/dist/cli/commands/pull.d.ts +32 -0
  7. package/dist/cli/commands/pull.d.ts.map +1 -1
  8. package/dist/cli/commands/pull.js +108 -9
  9. package/dist/cli/commands/pull.js.map +1 -1
  10. package/dist/cli/commands/push.d.ts.map +1 -1
  11. package/dist/cli/commands/push.js +18 -0
  12. package/dist/cli/commands/push.js.map +1 -1
  13. package/dist/cli/commands/status.d.ts.map +1 -1
  14. package/dist/cli/commands/status.js +15 -5
  15. package/dist/cli/commands/status.js.map +1 -1
  16. package/dist/client/media.d.ts.map +1 -1
  17. package/dist/client/media.js +16 -5
  18. package/dist/client/media.js.map +1 -1
  19. package/dist/db/cache.d.ts +19 -4
  20. package/dist/db/cache.d.ts.map +1 -1
  21. package/dist/db/cache.js +127 -16
  22. package/dist/db/cache.js.map +1 -1
  23. package/dist/mcp/server.d.ts.map +1 -1
  24. package/dist/mcp/server.js +5 -3
  25. package/dist/mcp/server.js.map +1 -1
  26. package/dist/mcp/tools/sync-doctor.d.ts.map +1 -1
  27. package/dist/mcp/tools/sync-doctor.js +12 -0
  28. package/dist/mcp/tools/sync-doctor.js.map +1 -1
  29. package/dist/mcp/tools/sync-pull.d.ts.map +1 -1
  30. package/dist/mcp/tools/sync-pull.js +49 -12
  31. package/dist/mcp/tools/sync-pull.js.map +1 -1
  32. package/dist/mcp/tools/sync-push.d.ts.map +1 -1
  33. package/dist/mcp/tools/sync-push.js +5 -0
  34. package/dist/mcp/tools/sync-push.js.map +1 -1
  35. package/dist/mcp/tools/sync-status.d.ts.map +1 -1
  36. package/dist/mcp/tools/sync-status.js +6 -2
  37. package/dist/mcp/tools/sync-status.js.map +1 -1
  38. package/dist/sync/delete.d.ts +2 -1
  39. package/dist/sync/delete.d.ts.map +1 -1
  40. package/dist/sync/delete.js +6 -19
  41. package/dist/sync/delete.js.map +1 -1
  42. package/dist/sync/diff.d.ts.map +1 -1
  43. package/dist/sync/diff.js +18 -2
  44. package/dist/sync/diff.js.map +1 -1
  45. package/dist/sync/doctor.d.ts +27 -1
  46. package/dist/sync/doctor.d.ts.map +1 -1
  47. package/dist/sync/doctor.js +309 -26
  48. package/dist/sync/doctor.js.map +1 -1
  49. package/dist/sync/guards.d.ts +45 -0
  50. package/dist/sync/guards.d.ts.map +1 -0
  51. package/dist/sync/guards.js +235 -0
  52. package/dist/sync/guards.js.map +1 -0
  53. package/dist/sync/media/body_scan.d.ts +15 -0
  54. package/dist/sync/media/body_scan.d.ts.map +1 -0
  55. package/dist/sync/media/body_scan.js +132 -0
  56. package/dist/sync/media/body_scan.js.map +1 -0
  57. package/dist/sync/media/companions.d.ts +20 -0
  58. package/dist/sync/media/companions.d.ts.map +1 -0
  59. package/dist/sync/media/companions.js +111 -0
  60. package/dist/sync/media/companions.js.map +1 -0
  61. package/dist/sync/media/index.d.ts +9 -0
  62. package/dist/sync/media/index.d.ts.map +1 -0
  63. package/dist/sync/media/index.js +9 -0
  64. package/dist/sync/media/index.js.map +1 -0
  65. package/dist/sync/media/mirror_item.d.ts +27 -0
  66. package/dist/sync/media/mirror_item.d.ts.map +1 -0
  67. package/dist/sync/media/mirror_item.js +207 -0
  68. package/dist/sync/media/mirror_item.js.map +1 -0
  69. package/dist/sync/media/naming_mirror.d.ts +36 -0
  70. package/dist/sync/media/naming_mirror.d.ts.map +1 -0
  71. package/dist/sync/media/naming_mirror.js +143 -0
  72. package/dist/sync/media/naming_mirror.js.map +1 -0
  73. package/dist/sync/media/pull_article.d.ts +37 -0
  74. package/dist/sync/media/pull_article.d.ts.map +1 -0
  75. package/dist/sync/media/pull_article.js +273 -0
  76. package/dist/sync/media/pull_article.js.map +1 -0
  77. package/dist/sync/media/pull_library.d.ts +12 -0
  78. package/dist/sync/media/pull_library.d.ts.map +1 -0
  79. package/dist/sync/media/pull_library.js +207 -0
  80. package/dist/sync/media/pull_library.js.map +1 -0
  81. package/dist/sync/media/remote_index.d.ts +18 -0
  82. package/dist/sync/media/remote_index.d.ts.map +1 -0
  83. package/dist/sync/media/remote_index.js +94 -0
  84. package/dist/sync/media/remote_index.js.map +1 -0
  85. package/dist/sync/media/types.d.ts +191 -0
  86. package/dist/sync/media/types.d.ts.map +1 -0
  87. package/dist/sync/media/types.js +11 -0
  88. package/dist/sync/media/types.js.map +1 -0
  89. package/dist/sync/media.d.ts +1 -74
  90. package/dist/sync/media.d.ts.map +1 -1
  91. package/dist/sync/media.js +1 -592
  92. package/dist/sync/media.js.map +1 -1
  93. package/dist/sync/orchestrator.d.ts +23 -0
  94. package/dist/sync/orchestrator.d.ts.map +1 -1
  95. package/dist/sync/orchestrator.js +85 -1
  96. package/dist/sync/orchestrator.js.map +1 -1
  97. package/dist/sync/preflight.d.ts +1 -1
  98. package/dist/sync/preflight.d.ts.map +1 -1
  99. package/dist/sync/preflight.js +127 -15
  100. package/dist/sync/preflight.js.map +1 -1
  101. package/dist/sync/pull.d.ts +19 -0
  102. package/dist/sync/pull.d.ts.map +1 -1
  103. package/dist/sync/pull.js +169 -15
  104. package/dist/sync/pull.js.map +1 -1
  105. package/dist/sync/push.d.ts +7 -0
  106. package/dist/sync/push.d.ts.map +1 -1
  107. package/dist/sync/push.js +37 -10
  108. package/dist/sync/push.js.map +1 -1
  109. package/dist/sync/status.d.ts +4 -0
  110. package/dist/sync/status.d.ts.map +1 -1
  111. package/dist/sync/status.js +68 -4
  112. package/dist/sync/status.js.map +1 -1
  113. package/dist/utils/config.d.ts +25 -0
  114. package/dist/utils/config.d.ts.map +1 -1
  115. package/dist/utils/config.js +28 -2
  116. package/dist/utils/config.js.map +1 -1
  117. package/dist/utils/frontmatter.d.ts +1 -1
  118. package/dist/utils/frontmatter.d.ts.map +1 -1
  119. package/dist/utils/frontmatter.js +15 -4
  120. package/dist/utils/frontmatter.js.map +1 -1
  121. package/dist/utils/media-body-refs.d.ts +81 -0
  122. package/dist/utils/media-body-refs.d.ts.map +1 -0
  123. package/dist/utils/media-body-refs.js +240 -0
  124. package/dist/utils/media-body-refs.js.map +1 -0
  125. package/dist/utils/media-refs.d.ts +44 -0
  126. package/dist/utils/media-refs.d.ts.map +1 -0
  127. package/dist/utils/media-refs.js +101 -0
  128. package/dist/utils/media-refs.js.map +1 -0
  129. package/dist/utils/naming.d.ts +31 -0
  130. package/dist/utils/naming.d.ts.map +1 -1
  131. package/dist/utils/naming.js +58 -0
  132. package/dist/utils/naming.js.map +1 -1
  133. 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)
@@ -80,20 +81,23 @@ EmDash is a modern serverless headless CMS built on Cloudflare Workers and D1. W
80
81
 
81
82
  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
83
 
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.
84
+ - **What Emit IS (Core Goal)**:
85
+ - An **active authoring and editing workbench (Staging Desk)** where human authors and AI coding agents collaborate seamlessly.
86
+ - 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.
87
+ - **What Emit is NOT (Non-Goals)**:
88
+ - **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.
89
+ - **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
90
 
90
91
  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
92
 
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.
93
+ 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
94
 
94
95
  > [!NOTE]
95
96
  > **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.
97
+ >
98
+ > **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.
99
+ >
100
+ > **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
101
 
98
102
  ### 2. The Sliding Window "Staging Desk" Model
99
103
 
@@ -101,17 +105,17 @@ Traditional synchronization tools attempt to download the entire history of a si
101
105
 
102
106
  Emit adopts a **bounded sliding window**:
103
107
 
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>`.
108
+ - 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).
109
+ - 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.
110
+ - Any pruned article can be pulled back into the workspace at any time with `emit pull <id>`.
107
111
 
108
112
  ### 3. Unpushed Edits Safeguard
109
113
 
110
114
  What happens if an article with unpushed local modifications is pushed out of the sliding window?
111
115
  Emit guarantees **zero data loss**:
112
116
 
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.
117
+ - If an article has local modifications not yet pushed to the remote server, Emit's pruning engine **aborts the pull immediately** with a `SyncPruneConflictError`.
118
+ - It lists the exact unpushed file and informs the user to either push the changes (`emit push <file>`) or discard them before proceeding.
115
119
 
116
120
  ### 4. Pure TypeScript & Zero Native Builds
117
121
 
@@ -122,36 +126,36 @@ By leveraging Node.js 22's built-in `node:sqlite` module (`DatabaseSync`), Emit
122
126
 
123
127
  To prevent concurrent race conditions when multiple authors or AI agents collaborate:
124
128
 
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.
129
+ - 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.
130
+ - If concurrent edits occur, Emit utilizes a **Hybrid 3-Way Merge** engine (`node-diff3`), merging YAML frontmatter fields and Markdown body text independently.
127
131
 
128
132
  ### 6. Division of Labor with the Official EmDash MCP
129
133
 
130
134
  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
135
 
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 |
136
+ | Operation | Right Tool |
137
+ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
138
+ | Drafting, reviewing, diffing, publishing articles with safety guards | **Emit** (`emit pull` / `emit push`, MCP `sync_*` tools) |
139
+ | On-demand retrieval of any single historical article by ULID | **Emit** (`emit pull <id>`) |
140
+ | Bulk media library management (mass upload, mass delete, full-library listing) | **Official EmDash MCP** (e.g. `media_upload`) / Admin Dashboard |
141
+ | Site-wide content queries, arbitrary collection operations outside the authoring loop | **Official EmDash MCP** raw content tools |
138
142
 
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.
143
+ 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
144
 
141
145
  ---
142
146
 
143
147
  ## 🌟 Key Features
144
148
 
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`.
149
+ - **AI-First Authoring Workflow**: Built specifically for autonomous agents and humans to draft, inspect, and sync local Markdown files safely.
150
+ - **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.
151
+ - **Unpushed Edits Safeguard**: Detects unpushed local changes outside the sync window and aborts sync safely before any files can be deleted.
152
+ - **Git-Style Workspace Encapsulation**: Houses local SQLite cache (`node:sqlite`) and workspace configuration neatly inside a `.emit/` hidden folder.
153
+ - **Multi-Site Profile Management**: Manage multiple EmDash sites and credentials in `~/.config/emit/profiles.yaml` with automatic workspace directory detection.
154
+ - **Hybrid 3-Way Merge Engine**: Resolves concurrent changes between remote and local versions like Git, independently merging YAML frontmatter and Markdown body.
155
+ - **Distributed Mutual Exclusion Lock**: Acquires 7-minute leases via `POST /lock` with `try/finally` release semantics to eliminate race conditions during publishing.
156
+ - **Opaque Block Preservation**: Preserves EmDash custom blocks (e.g., `core:gallery`) losslessly across AST roundtrips via `<!--ec:block ... -->` fences.
157
+ - **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).
158
+ - **Zero Native Dependencies**: Completely eliminates native C++ builds (`node-gyp`) by leveraging Node.js 22+ built-in `node:sqlite`.
155
159
 
156
160
  ---
157
161
 
@@ -205,9 +209,9 @@ emit profile add my-blog \
205
209
  --workspace /path/to/local/blog-repo
206
210
  ```
207
211
 
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.
212
+ - `--origin`: The base URL of your EmDash Cloudflare Workers instance.
213
+ - `--token`: Your Personal Access Token (PAT) generated from the EmDash dashboard.
214
+ - `--workspace`: The local directory where articles and assets will be synchronized.
211
215
 
212
216
  > [!NOTE]
213
217
  > 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 +248,7 @@ emit profile list
244
248
  ```
245
249
 
246
250
  **How Automatic Profile Resolution Works:**
251
+
247
252
  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
253
  2. **Explicit Profile Flag**: You can explicitly specify `--profile <name>` (or pass `profile: "<name>"` in MCP) from any directory to override automatic detection.
249
254
  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 +270,7 @@ When credentials include a `refresh_token` (such as OAuth sessions in `~/.config
265
270
  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
271
 
267
272
  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
273
+ 2. **`EMDASH_ORIGIN`** — overrides an _implicitly_ resolved profile so CI/staging overrides actually take effect
269
274
  3. **Auto-resolved profile** — current-directory match, unique origin match, or the single registered profile
270
275
  4. **Resolved auth origin** — carried by `~/.config/emdash/auth.json` / `~/.config/emit/credentials.json`
271
276
  5. **Workspace `.emit/config.json`** → then the `https://example.com` sentinel default
@@ -282,20 +287,21 @@ Running `emit init` generates `.emit/config.json` with the following keys (`src/
282
287
  "origin": "https://your-blog.example.com",
283
288
  "defaultCollection": "posts",
284
289
  "defaultLocale": "ja",
285
- "postsSyncLimit": 100,
290
+ "postsSyncLimit": 30,
286
291
  "mediaSyncLimit": 300,
287
292
  "concurrency": 8
288
293
  }
289
294
  ```
290
295
 
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 |
296
+ | Key | Default | Description |
297
+ | ------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
298
+ | `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 |
299
+ | `defaultCollection` | `"posts"` | Default content collection when `--collection` is omitted |
300
+ | `defaultLocale` | `"ja"` | Default locale used when scaffolding new drafts via `emit new` / `sync_new` |
301
+ | `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) |
302
+ | `mediaSyncLimit` | `300` | Default maximum items fetched by `emit pull-media` / `sync_pull_media` |
303
+ | `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 |
304
+ | `concurrency` | `8` | Parallel HTTP fetch concurrency during batch pulls |
299
305
 
300
306
  ### 6. Recommended `.gitignore`
301
307
 
@@ -325,10 +331,11 @@ tmp/
325
331
  ```
326
332
 
327
333
  > [!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.
334
+ >
335
+ > - `.emit/cache.db*` **must be excluded** from Git because it is a machine-local SQLite tracking database.
336
+ > - `.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`.
337
+ > - `media/*.yaml` metadata files can be committed to Git to track asset metadata (`storageKey`, `contentHash`, `status`, dimensions) alongside your Markdown articles.
338
+ > - **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
339
 
333
340
  ---
334
341
 
@@ -347,7 +354,7 @@ your-blog/
347
354
  ├── tags/ # Tag definition files (<id>-<label>.yaml)
348
355
  │ ├── 01M6TAG1-Typescript.yaml
349
356
  │ └── 01M6TAG2-Ai.yaml
350
- ├── media/ # Media files and companion metadata (<yyyyMMdd>_<id>-<name>.<ext>)
357
+ ├── media/ # Media files and companion metadata (`<markdownRef>.<ext>` + `.yaml`; markdownRef is derived from the storageKey)
351
358
  │ ├── drafts/ # Staging folder for offline draft images (auto-uploaded on push)
352
359
  │ │ └── sunset.png
353
360
  │ ├── 20261101_01M6ABCDEF-header.jpg
@@ -366,13 +373,26 @@ your-blog/
366
373
 
367
374
  Emit maintains a local SQLite database via Node.js built-in `node:sqlite` (`src/db/cache.ts`) with 5 tables:
368
375
 
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 |
376
+ | Table | Purpose |
377
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
378
+ | `entries` | Caches the latest remote JSON envelope and revision (`rev`) for each synced article |
379
+ | `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) |
380
+ | `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>")`) |
381
+ | `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 |
382
+ | `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). |
383
+
384
+ ### Two Media ID Spaces: `id` vs `storageKey`
385
+
386
+ 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.
387
+
388
+ Consequences worth internalizing before debugging media:
389
+
390
+ - 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.
391
+ - 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).
392
+ - 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).
393
+ - 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.
394
+
395
+ 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
396
 
377
397
  ---
378
398
 
@@ -384,40 +404,39 @@ Each article is stored as a Markdown file with a validated YAML frontmatter head
384
404
 
385
405
  ```yaml
386
406
  ---
387
- id: 01M6ABCDEF1234567890123456 # 26-char canonical ULID (auto-generated; DO NOT EDIT)
407
+ id: 01M6ABCDEF1234567890123456 # 26-char canonical ULID (auto-generated; DO NOT EDIT)
388
408
  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)
409
+ title: 'Building AI-Powered CMS Tools'
410
+ collection: posts # Target collection (defaults to "posts")
411
+ status: draft # "draft" or "published" (defaults to "draft")
412
+ publishedAt: null # ISO 8601 timestamp string (e.g. "2026-11-04T12:00:00.000Z") or null
413
+ locale: ja # Locale code (defaults to "ja")
414
+ featuredImage: '01M67P0MBMJQQXH2K95HHCYRYB' # Optional: 26-char media record ULID (uppercase-normalized) or null — NOT a storageKey
415
+ taxonomies: # Map of taxonomy key -> array of term slugs (or canonical term ULIDs)
396
416
  categories:
397
417
  - tech
398
418
  tags:
399
419
  - typescript
400
420
  - 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)
421
+ seo: # Optional SEO metadata block
422
+ title: null # Custom SEO title string or null
423
+ description: null # Meta description string or null
424
+ canonical: null # Absolute URL (e.g. "https://example.com/posts/...") or null
425
+ noIndex: false # Boolean (optional)
426
+ image: null # SEO image string or null
427
+ bylines: # Author byline slugs (resolved via cached bylines table)
408
428
  - aroooy
409
429
  ---
410
-
411
430
  Markdown body begins here...
412
-
413
431
  ```
414
432
 
415
433
  > [!IMPORTANT]
416
434
  > **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.
435
+ >
436
+ > - **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()))`).
437
+ > - **`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`.
438
+ > - **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.
439
+ > - **`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
440
 
422
441
  ### Critical Content Rules
423
442
 
@@ -434,7 +453,7 @@ Markdown body begins here...
434
453
  - Emit automatically updates the remote state and moves the file from `posts/draft/` to `posts/published/`.
435
454
  - **Never move or rename files manually using `mv`**.
436
455
  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.
456
+ - `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
457
  - To remove the hero image, set `featuredImage: null`. Omitting the field leaves the remote value untouched.
439
458
  - 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
459
  - 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 +466,18 @@ EmDash supports rich visual blocks (e.g., galleries, callouts, audio players). W
447
466
  ```html
448
467
  <!--ec:block type="core:gallery" id="01M6XYZ..." data="{\"columns\":3,\"images\":[...]}" -->
449
468
  <!--/ec:block-->
450
-
451
469
  ```
452
470
 
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.
471
+ - **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
472
 
455
473
  ### Media Management & Upload Workflow
456
474
 
457
475
  EmDash stores media files (images, documents) in Cloudflare R2 and uses **two distinct identifier spaces**:
458
476
 
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`) |
477
+ | Usage | Identifier Used | Format / Example |
478
+ | --------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
479
+ | Frontmatter `featuredImage` | **Media Record `id`** | 26-character ULID (`01M67P0MBMJQQXH2K95HHCYRYB`) or `null` |
480
+ | 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
481
 
464
482
  > [!IMPORTANT]
465
483
  > 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 +495,7 @@ You can author articles completely offline using local images and let `emit push
477
495
  - 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
496
  - Rewrites the Markdown line in place to the canonical root-relative `/_emdash/api/media/file/<storageKey>` URL.
479
497
  - 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.
498
+ - 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
499
  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
500
 
483
501
  #### Workflow B: Pre-Uploaded Media & Featured Hero Images
@@ -490,10 +508,11 @@ You can author articles completely offline using local images and let `emit push
490
508
 
491
509
  > [!WARNING]
492
510
  > **Local Image Guardrails (G5 & G11)**:
511
+ >
493
512
  > - 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
513
  > - 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
514
  > - 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.
515
+ > - 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
516
 
498
517
  ---
499
518
 
@@ -517,34 +536,40 @@ Whether you are a human developer or an autonomous AI agent (Antigravity, Claude
517
536
  1. **CHECK (`emit status`)**: Check your workspace status to see synced, modified, and untracked files.
518
537
  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
538
  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/`.
539
+
540
+ - For new posts: run `emit new <slug> --title "My Title"` to scaffold a canonical draft.
541
+ - Edit the Markdown body and YAML frontmatter in `posts/draft/` or `posts/published/`.
522
542
 
523
543
  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 |
544
+
545
+ - To push a draft: `emit push posts/draft/<filename>.md`
546
+ - To publish live: update `status: published` and run `emit push posts/draft/<filename>.md --allow-publish`.
547
+
548
+ ### Preflight Safety Guardrails (G1–G19)
549
+
550
+ 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:
551
+
552
+ | Guard | Code Name | Description | Severity |
553
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
554
+ | **G1** | `SINGLE_TARGET_GUARD` | Enforces explicit target (`filePath`, `id`, or `--all`) for push/pull; forbids blind bulk push without inspection | Error |
555
+ | **G2** | `ID_MISMATCH_ERROR` | Verifies that frontmatter `id` exactly matches the filename ULID; forbids manual `mv` renames | Error |
556
+ | **G3** | `UNINTENDED_PUBLISH_GUARD` | Rejects promoting `draft` to `published` unless `--allow-publish` (`allowPublish: true`) is explicitly given | Error |
557
+ | **G4** | `INVALID_TAXONOMY_ERROR` | Validates taxonomy terms against local definitions (transparently resolves valid term ULIDs) to stop AI taxonomy hallucination | Error |
558
+ | **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 |
559
+ | **G6** | `UNRESOLVED_CONFLICT_MARKERS` | Scans for Git 3-way merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) and blocks push | Error |
560
+ | **G7** | `TITLE_DUPLICATION_AUTOTRIM` | Detects redundant `# Title` headings at the top of the body and automatically trims them safely | Warning |
561
+ | **G8** | `UNSUPPORTED_SYNTAX_WARNING` | Warns if unsupported block HTML, tables, or tilde code fences are detected in Markdown (`detectUnsupportedMarkdownSyntax`) | Warning |
562
+ | **G9** | `MODIFIED_OPAQUE_BLOCK` | Verifies that `<!--ec:block ... -->` opaque fences have not been tampered with or corrupted | Error |
563
+ | **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
564
  | **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 |
565
+ | **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 |
566
+ | **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 |
567
+ | **G14** | `QUARANTINED_PATH_REQUIRED` | Restricts conflict resolution targets strictly within `<collection>/conflicts/`, preventing accidental overwrites or unlinks outside the quarantine directory | Error |
568
+ | **G15** | `DELETE_PENDING_ARTICLE` | Blocks pushing articles marked delete-pending when remote deletion was unconfirmed; requires `--revive` or `--force` | Error |
569
+ | **G16** | `TAXONOMY_CONFLICT_PENDING` | Blocks re-pushing articles with unresolved taxonomy conflicts from a previous sync; requires `--resolve-taxonomy` or `--force` | Error |
570
+ | **G17** | `TAXONOMY_APPLY_PENDING` | Blocks pushing when previous taxonomy assignment failed remotely; requires `--allow-unapplied-taxonomies` or `--force` | Error |
571
+ | **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 |
572
+ | **G19** | `DESTRUCTIVE_TAXONOMY_WIPE` | Blocks pushing empty taxonomy arrays that would wipe existing remote assignments; requires `--allow-wipe-taxonomies` or `--force` | Error |
548
573
 
549
574
  ---
550
575
 
@@ -559,7 +584,7 @@ emit init [--origin <url>]
559
584
 
560
585
  ```
561
586
 
562
- * `--origin <url>`: Optional EmDash origin URL. If omitted, uses the active profile.
587
+ - `--origin <url>`: Optional EmDash origin URL. If omitted, uses the active profile.
563
588
 
564
589
  **Example Output:**
565
590
 
@@ -604,12 +629,12 @@ emit new <slug> [-t, --title <title>] [-c, --collection <collection>] [-l, --loc
604
629
 
605
630
  ```
606
631
 
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.
632
+ - `<slug>`: URL slug for the article (e.g., `modern-web-architecture`).
633
+ - `-t, --title <title>`: Human-readable article title.
634
+ - `-c, --collection <name>`: Collection name (defaults to `posts`).
635
+ - `-l, --locale <locale>`: Locale code (defaults to `ja` or config).
636
+ - `-b, --byline <slug>`: Author byline slug (defaults to the first cached non-guest author ordered by `slug ASC`).
637
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
613
638
 
614
639
  **Example:**
615
640
 
@@ -647,19 +672,27 @@ emit pull [id] [options]
647
672
 
648
673
  ```
649
674
 
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.
675
+ - `[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.
676
+ - `-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).
677
+ - `-c, --collection <collection>`: Collection name (default: `posts`).
678
+ - `--no-prune`: Do not prune local articles that have fallen outside the sliding window (escape hatch; not intended for routine use).
679
+ - `--force`: Force overwrite local files and bypass differential hash checking.
680
+ - `--concurrency <n>`: Parallel processing task limit (default: `8`).
681
+ - `--posts-only`: Skip automatic remote taxonomy sync. Pulls articles only (local taxonomy candidates hint is still displayed from cached definitions).
682
+ - `--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.
683
+ - `--metadata-only`: With `--media`: download only metadata YAML, skip binary file downloads.
684
+ - `--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.
685
+ - `--no-local-media-refs`: Clear a previously remembered preference and store canonical EmDash URLs on disk again.
659
686
 
660
687
  > [!NOTE]
661
688
  > **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
689
 
690
+ > [!NOTE]
691
+ > **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.
692
+
693
+ > [!NOTE]
694
+ > **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.
695
+
663
696
  > [!NOTE]
664
697
  > **Why `--no-prune` is rarely needed:**
665
698
  > 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 +733,11 @@ Available Categories:
700
733
  Available Tags:
701
734
  - ai, cloudflare, emit, typescript
702
735
 
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
736
+ Fetching articles from remote (posts, status: all, window: 30/status, concurrency: 8)...
737
+ [1/50] ✅ Pulled hello-world -> posts/published/20261101_01M5...-Hello-World.md
738
+ [2/50] ✅ Pulled draft-post -> posts/draft/20261104_01M6...-Draft-Post.md
706
739
 
707
- 🎉 Articles: 169 total [100 published, 69 draft] (12 updated, 157 skipped/up-to-date)
740
+ 🎉 Articles: 50 total [30 published, 20 draft] (12 updated, 38 skipped/up-to-date)
708
741
 
709
742
  ```
710
743
 
@@ -719,16 +752,17 @@ emit push [file] [options]
719
752
 
720
753
  ```
721
754
 
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.
755
+ - `[file]`: Relative or absolute path to the Markdown file.
756
+ - `--allow-publish`: Required when promoting an article to `status: published`.
757
+ - `--all`: Pushes all modified files in the workspace.
758
+ - `--no-taxonomy-audit`: Skips pre-PUT remote taxonomy divergence audit.
759
+ - `--allow-unapplied-taxonomies`: Pushes article body even if taxonomy term assignment fails (bypasses Guard G17).
760
+ - `--allow-wipe-taxonomies`: Confirms intentional deletion of all assigned categories or tags.
761
+ - `--resolve-taxonomy <strategy>`: Resolves taxonomy conflict explicitly (`union`, `local`, or `remote`).
762
+ - `--revive`: Pushes an article that was previously marked delete_pending.
763
+ - `-f, --force`: Bypasses non-critical safety guardrails.
764
+ - `--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`.
765
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
732
766
 
733
767
  **Example:**
734
768
 
@@ -751,11 +785,11 @@ Safely deletes a local Markdown article and its remote counterpart on EmDash.
751
785
  emit delete <filePathOrId> [--allow-delete-published] [--dry-run] [-y, --yes] [-p, --profile <name>]
752
786
  ```
753
787
 
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.
788
+ - `<filePathOrId>`: Relative/absolute path to the Markdown file, or 26-character article ULID.
789
+ - `--allow-delete-published`: Required when deleting a published article (safety guardrail).
790
+ - `--dry-run`: Previews the deletion, inspecting referenced media and lines without modifying disk or remote.
791
+ - `-y, --yes`: Skips interactive confirmation prompt.
792
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
759
793
 
760
794
  > [!NOTE]
761
795
  > **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 +812,10 @@ emit conflicts resolve <filePath> --accept-local [-f, --force] [-c, --collection
778
812
  emit conflicts resolve <filePath> --accept-canonical [-c, --collection <collection>]
779
813
  ```
780
814
 
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.
815
+ - `list`: Scans `<collection>/conflicts/`, classifying items as `⚠️ SYNC CONFLICT` or `🗑️ DELETED RESCUE`, showing sizes and dates.
816
+ - `diff`: Displays a unified diff against the current canonical article.
817
+ - `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.
818
+ - `resolve --accept-canonical`: Safely discards the quarantined copy, retaining the canonical version.
785
819
 
786
820
  ---
787
821
 
@@ -790,10 +824,11 @@ emit conflicts resolve <filePath> --accept-canonical [-c, --collection <collecti
790
824
  Runs an automated diagnostic health check across the local workspace and cache database.
791
825
 
792
826
  ```bash
793
- emit doctor [-c, --collection <collection>] [-p, --profile <name>]
827
+ emit doctor [-c, --collection <collection>] [--strict-media] [--check-media-urls] [--check-media-library] [-p, --profile <name>]
794
828
  ```
795
829
 
796
830
  Validates 6 critical health vectors:
831
+
797
832
  1. **Version Compatibility**: Checks tool version against `createdWithVersion` / `lastSyncedVersion` in config.
798
833
  2. **Origin Consistency**: Verifies `.emit/config.json`, profile origin, and environment variables.
799
834
  3. **Frontmatter Integrity**: Ensures all local Markdown files have valid parseable frontmatter.
@@ -801,6 +836,11 @@ Validates 6 critical health vectors:
801
836
  5. **Sync Status**: Verifies local baseline hash tracking.
802
837
  6. **Referenced Media Existence**: Validates that all local media referenced in article Markdown bodies exist on disk.
803
838
 
839
+ Media flags:
840
+
841
+ - `--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.
842
+ - `--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.
843
+
804
844
  ---
805
845
 
806
846
  ### `emit status`
@@ -841,9 +881,9 @@ emit diff [filePath] [-i, --id <id>] [-p, --profile <name>]
841
881
 
842
882
  ```
843
883
 
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.
884
+ - `[filePath]`: Relative path to the local Markdown file (e.g., `posts/draft/20261104_01M6...-my-article.md`).
885
+ - `-i, --id <id>`: Inspect by 26-character article ULID instead of file path.
886
+ - `-p, --profile <name>`: Explicitly target a specific connection profile.
847
887
 
848
888
  ---
849
889
 
@@ -881,11 +921,17 @@ Downloads media assets and their companion metadata YAML files into `media/`. Su
881
921
  emit pull-media [-a, --article <id-or-path>] [-l, --limit <n>] [--all] [--prune] [--metadata-only]
882
922
  ```
883
923
 
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.
924
+ - `-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`.
925
+ - `-l, --limit <n>`: Maximum number of media items to fetch (default: `300`).
926
+ - `--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.
927
+ - `--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.
928
+ - `--metadata-only`: Download metadata YAML files only, skipping large binary downloads.
929
+
930
+ > **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.
931
+
932
+ > **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.
933
+
934
+ > **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.
889
935
 
890
936
  ---
891
937
 
@@ -928,7 +974,6 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
928
974
  }
929
975
  }
930
976
  }
931
-
932
977
  ```
933
978
 
934
979
  #### 2. Cursor (`.cursor/mcp.json`)
@@ -942,7 +987,6 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
942
987
  }
943
988
  }
944
989
  }
945
-
946
990
  ```
947
991
 
948
992
  #### 3. Google Antigravity (`~/.gemini/antigravity/mcp_config.json`)
@@ -956,27 +1000,26 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
956
1000
  }
957
1001
  }
958
1002
  }
959
-
960
1003
  ```
961
1004
 
962
1005
  > **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
1006
 
964
1007
  ### Available MCP Tools
965
1008
 
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` |
1009
+ | Tool | Purpose | Key Parameters |
1010
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
1011
+ | `sync_status` | Inspect local sync state (summary-first structure with capped actionRequired, articles, and search filters) | `collection`, `status`, `search`, `profile` |
1012
+ | `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` |
1013
+ | `sync_new` | Scaffold new article with canonical ULID, frontmatter, default byline, duplicate slug guard, and taxonomy hints | `slug` (required), `title`, `collection`, `locale`, `byline`, `profile` |
1014
+ | `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` |
1015
+ | `sync_delete` | Safely delete article locally and remotely with rescue backup (`.deleted.md`) and unpushed edit protection | `filePath`, `id`, `allowDeletePublished`, `dryRun`, `profile` |
1016
+ | `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` |
1017
+ | `sync_doctor` | Run comprehensive workspace diagnostics (version, origin, frontmatter, conflicts, sync, referenced media) | `collection`, `profile` |
1018
+ | `sync_diff` | Inspect diff between working file and baseline snapshot | `filePath`, `id`, `profile` |
1019
+ | `sync_pull_taxonomies` | Pull remote category and tag definitions (standalone; returns taxonomy hints) | `profile` |
1020
+ | `sync_pull_media` | Pull remote media files and metadata into `media/` (supports batch or targeted article media) | `article`, `limit`, `all`, `downloadFiles`, `profile` |
1021
+ | `profile_list` | List all configured connection profiles | (none) |
1022
+ | `profile_switch` | Switch active profile for multi-site environments | `name` (required), `persist` |
980
1023
 
981
1024
  > [!NOTE]
982
1025
  > **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 +1028,9 @@ Emit features a first-class **Model Context Protocol (MCP)** server over stdio.
985
1028
 
986
1029
  With Emit connected via MCP, you can give high-level natural language instructions to your AI:
987
1030
 
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."*
1031
+ - _"Check the workspace status, pull the latest posts, and show me what was changed recently."_
1032
+ - _"Draft a new blog post titled 'Next-Gen Serverless Architectures' with slug `serverless-next`, add the `tech` category, and write a comprehensive outline."_
1033
+ - _"Review the draft in `posts/draft/...`, proofread the content, and push it to remote as a draft."_
991
1034
 
992
1035
  ---
993
1036
 
@@ -1024,17 +1067,17 @@ Push your changes with 'emit push <file>' or discard them before pulling.
1024
1067
 
1025
1068
  ```
1026
1069
 
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.
1070
+ **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
1071
 
1029
1072
  **Solution**:
1030
1073
 
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`.
1074
+ - If you want to keep the changes: Push them to remote using `emit push posts/draft/...`.
1075
+ - If you want to discard local edits: Delete or revert the file, then re-run `emit pull`.
1076
+ - If you want to temporarily skip pruning: Run `emit pull --no-prune`.
1034
1077
 
1035
1078
  ### Q: I want to write a comprehensive piece that references my entire back-catalog — is that supported?
1036
1079
 
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).
1080
+ **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
1081
 
1039
1082
  ### Q: `PushValidationError`: Cannot publish article live without `--allow-publish`
1040
1083
 
@@ -1048,6 +1091,12 @@ Push your changes with 'emit push <file>' or discard them before pulling.
1048
1091
 
1049
1092
  **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
1093
 
1094
+ ### Q: Images in an older article do not render in a local Markdown preview
1095
+
1096
+ **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.
1097
+
1098
+ **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).
1099
+
1051
1100
  ### Q: `[NETWORK_ERROR]`: Network request failed (`fetch failed`)
1052
1101
 
1053
1102
  **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 +1134,3 @@ npm run check
1085
1134
  This project is open-source software licensed under the [MIT License](LICENSE).
1086
1135
 
1087
1136
  Copyright (c) 2026 SPORT-SERVICE RS★R. All rights reserved.
1088
-