openwriter 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/index-JMMJM_G_.js +212 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/comments.js +256 -0
  21. package/dist/server/documents.js +293 -20
  22. package/dist/server/enrichment.js +114 -0
  23. package/dist/server/helpers.js +63 -8
  24. package/dist/server/index.js +94 -40
  25. package/dist/server/install-skill.js +15 -0
  26. package/dist/server/logger.js +246 -0
  27. package/dist/server/markdown-parse.js +71 -14
  28. package/dist/server/markdown-serialize.js +136 -41
  29. package/dist/server/mcp.js +538 -99
  30. package/dist/server/node-blocks.js +22 -4
  31. package/dist/server/node-fingerprint.js +347 -73
  32. package/dist/server/node-matcher.js +76 -49
  33. package/dist/server/pending-overlay.js +862 -0
  34. package/dist/server/state.js +1178 -98
  35. package/dist/server/versions.js +18 -0
  36. package/dist/server/workspaces.js +42 -5
  37. package/dist/server/ws.js +194 -37
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +51 -21
  40. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  41. package/skill/docs/enrichment.md +179 -0
  42. package/dist/client/assets/index-BxI3DazW.js +0 -212
  43. package/dist/client/assets/index-OV13QtgQ.css +0 -1
@@ -3,51 +3,85 @@
3
3
  * Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
4
4
  * Title lives in frontmatter metadata. Filenames are stable identifiers.
5
5
  */
6
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync, watch } from 'fs';
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
- import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
11
+ import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
13
  import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
14
14
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
15
  import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
17
+ import { anyLegacyRaw } from './node-fingerprint.js';
18
+ import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
19
+ import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
20
+ import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
17
21
  /** Read the persisted identity graph (nodes + graveyard) from a file's
18
- * frontmatter. This is the matcher's previousNodes baseline at save time —
19
- * the disk is the source of truth, not a parallel in-memory cache. Returns
20
- * empty arrays for a brand-new file or unreadable frontmatter. */
22
+ * frontmatter. The save-time matcher reads previousNodes + graveyard
23
+ * directly from disk every write — the disk is the source of truth, not
24
+ * a parallel in-memory cache.
25
+ *
26
+ * Slim disk entries are enriched against the freshly-parsed disk body so
27
+ * derived fields (position, neighbor types, etc.) flow into the rich
28
+ * Fingerprint the matcher expects. Legacy verbose-object entries are
29
+ * positionally re-fingerprinted via the same helper.
30
+ * adr: adr/node-identity-matcher.md */
21
31
  function readPersistedIdentity(filePath) {
22
32
  if (!filePath || !existsSync(filePath))
23
33
  return { previousNodes: [], graveyard: [] };
24
34
  try {
25
35
  const raw = readFileSync(filePath, 'utf-8');
26
- const { data } = matter(raw);
36
+ // Bypass gray-matter for identity reads. gray-matter caches its parsed
37
+ // `data` object by raw string within a process, so any upstream
38
+ // mutation (test wrappers, dev tools) leaks into the matcher's input
39
+ // on subsequent reads. Identity fields live in a JSON frontmatter
40
+ // block emitted by tiptapToMarkdown — parse it directly so we always
41
+ // see fresh data.
42
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
43
+ let rawNodes = [];
44
+ let rawGraveyard = [];
45
+ let body = raw;
46
+ if (fmMatch) {
47
+ try {
48
+ const fmObj = JSON.parse(fmMatch[1]);
49
+ if (Array.isArray(fmObj.nodes))
50
+ rawNodes = fmObj.nodes;
51
+ if (Array.isArray(fmObj.graveyard))
52
+ rawGraveyard = fmObj.graveyard;
53
+ }
54
+ catch { /* malformed JSON frontmatter — treat as no identity */ }
55
+ body = raw.slice(fmMatch[0].length).replace(/^[\r\n]+/, '');
56
+ }
57
+ // Slim entries derive position/parent/neighbors from the slim array
58
+ // itself — no body parse needed. Legacy entries need positional
59
+ // re-fingerprinting from the body, so we parse it lazily only then.
60
+ // This avoids a full markdown re-parse on every save for the common
61
+ // (ultra-lean) case, which was the dominant cost of switchDocument's
62
+ // pre-switch save() for large docs.
63
+ const needsBodyParse = (Array.isArray(rawNodes) && rawNodes.length > 0 && anyLegacyRaw(rawNodes));
64
+ let previousBlocks = [];
65
+ if (needsBodyParse) {
66
+ const previousDocContent = markdownToNodes(body);
67
+ previousBlocks = tiptapToBlocks({ content: previousDocContent });
68
+ }
27
69
  return {
28
- previousNodes: normalizeNodeEntries(data.nodes),
29
- graveyard: normalizeNodeEntries(data.graveyard),
70
+ previousNodes: resolvePreviousNodes(rawNodes, previousBlocks),
71
+ graveyard: resolveGraveyard(rawGraveyard),
30
72
  };
31
73
  }
32
74
  catch {
33
75
  return { previousNodes: [], graveyard: [] };
34
76
  }
35
77
  }
36
- /** Defensive parse of frontmatter node entries — drops any malformed rows.
37
- * Mirrors the same-named helper in markdown-parse.ts so save and load apply
38
- * identical validation. */
39
- function normalizeNodeEntries(raw) {
40
- if (!Array.isArray(raw))
41
- return [];
42
- return raw
43
- .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
44
- .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
45
- }
46
78
  const DEFAULT_DOC = {
47
79
  type: 'doc',
48
80
  content: [{ type: 'paragraph', content: [] }],
49
81
  };
50
82
  let state = {
83
+ canonical: DEFAULT_DOC,
84
+ overlay: new Map(),
51
85
  document: DEFAULT_DOC,
52
86
  title: 'Untitled',
53
87
  metadata: { title: 'Untitled' },
@@ -56,37 +90,393 @@ let state = {
56
90
  lastModified: new Date(),
57
91
  docId: '',
58
92
  originalFrontmatter: null,
93
+ loadedMtime: 0,
59
94
  };
95
+ // ============================================================================
96
+ // PRIMARY-STATE WRITE HELPERS
97
+ // ============================================================================
98
+ // All mutations to canonical or overlay go through these helpers. Each updates
99
+ // the underlying state AND recomputes state.document so external readers via
100
+ // getDocument() see the new merged view. Direct assignment to state.canonical,
101
+ // state.overlay, or state.document is forbidden outside this module.
102
+ // adr: adr/pending-overlay-model.md
103
+ /** Recompute the merged view from primary state. Idempotent — running twice
104
+ * produces the same result. */
105
+ function recomputeMerged() {
106
+ state.document = applyOverlayPure(state.canonical, Array.from(state.overlay.values()));
107
+ }
108
+ /** Replace canonical wholesale. Used by load paths and accept-all flows. */
109
+ function setCanonical(doc) {
110
+ state.canonical = doc;
111
+ recomputeMerged();
112
+ }
113
+ /** Replace overlay wholesale from an entry array. Dedupes by nodeId
114
+ * (first occurrence wins; preserves original anchors). Preserves
115
+ * addedAtVersion from any pre-existing state.overlay entry with the
116
+ * same nodeId; stamps current docVersion on entries that are new. */
117
+ function setOverlayFromEntries(entries) {
118
+ const currentVersion = getDocVersion();
119
+ const newOverlay = new Map();
120
+ for (const e of entries) {
121
+ if (newOverlay.has(e.nodeId))
122
+ continue;
123
+ const existing = state.overlay.get(e.nodeId);
124
+ const entry = { ...e };
125
+ if (existing?.addedAtVersion !== undefined) {
126
+ entry.addedAtVersion = existing.addedAtVersion;
127
+ }
128
+ else if (entry.addedAtVersion === undefined) {
129
+ entry.addedAtVersion = currentVersion;
130
+ }
131
+ newOverlay.set(e.nodeId, entry);
132
+ }
133
+ state.overlay = newOverlay;
134
+ recomputeMerged();
135
+ }
136
+ /** Replace primary state from a merged-shape doc. Splits via splitMergedDoc.
137
+ * Used by paths that receive a merged doc (browser doc-updates, legacy
138
+ * on-disk migration). */
139
+ function setPrimaryFromMerged(merged) {
140
+ const { canonical, overlayEntries } = splitMergedDoc(merged);
141
+ state.canonical = canonical;
142
+ setOverlayFromEntries(overlayEntries);
143
+ }
144
+ /**
145
+ * Sync routing for a stale-version browser doc-update. The browser's
146
+ * submission was captured at server version `browserVersion`; the server
147
+ * has since advanced to a higher version because the agent wrote concurrently.
148
+ *
149
+ * Behavior: the browser's view of canonical is accepted as authoritative.
150
+ * The overlay merges browser's view with server's recent additions: any
151
+ * server overlay entry with addedAtVersion > browserVersion is an agent
152
+ * addition the browser hadn't seen, so it survives. Conflicting nodeIds
153
+ * (both browser and server have entries) → server wins, on the principle
154
+ * that an explicit agent proposal outranks a browser save that didn't see
155
+ * it. The user can reject the server's entry through the normal review UI
156
+ * if they disagree.
157
+ *
158
+ * Replaces the older "BLOCK stale doc-update" behavior — that pattern lost
159
+ * the user's typing without warning. The merge approach preserves both
160
+ * sides' work in the common case (disjoint touches) and surfaces conflicts
161
+ * (same-paragraph touches) via the existing pending-review UI.
162
+ *
163
+ * Returns the count of server overlay entries that were preserved.
164
+ * adr: adr/pending-overlay-model.md
165
+ */
166
+ export function syncBrowserDocUpdate(browserDoc, browserVersion) {
167
+ const { canonical: browserCanonical, overlayEntries: browserOverlay } = splitMergedDoc(browserDoc);
168
+ // Identify server overlay entries to preserve: those added after browser's baseline.
169
+ const preserved = [];
170
+ for (const [, entry] of state.overlay) {
171
+ const added = entry.addedAtVersion ?? 0;
172
+ if (added > browserVersion)
173
+ preserved.push(entry);
174
+ }
175
+ // Build the merged overlay. Browser's view first; server-preserved entries
176
+ // overwrite (server wins on conflict).
177
+ const merged = new Map();
178
+ for (const e of browserOverlay) {
179
+ if (!merged.has(e.nodeId))
180
+ merged.set(e.nodeId, e);
181
+ }
182
+ for (const e of preserved) {
183
+ merged.set(e.nodeId, e);
184
+ }
185
+ // Apply: browser's canonical view + merged overlay.
186
+ state.canonical = browserCanonical;
187
+ setOverlayFromEntries(Array.from(merged.values()));
188
+ return { preservedServerEntries: preserved.length };
189
+ }
60
190
  const listeners = new Set();
191
+ const idRewriteListeners = new Set();
192
+ const externalWriteConflictListeners = new Set();
193
+ export function onExternalWriteConflict(listener) {
194
+ externalWriteConflictListeners.add(listener);
195
+ return () => externalWriteConflictListeners.delete(listener);
196
+ }
197
+ function notifyExternalWriteConflict(filePath, diskMtime, loadedMtime) {
198
+ for (const listener of externalWriteConflictListeners) {
199
+ try {
200
+ listener({ filePath, diskMtime, loadedMtime });
201
+ }
202
+ catch (err) {
203
+ console.error('[State] external-write listener threw:', err);
204
+ }
205
+ }
206
+ }
207
+ /**
208
+ * Check whether the active doc's on-disk mtime is newer than what we
209
+ * loaded/saved. Returns null when the doc has no file path, the file is
210
+ * gone, or there's no drift. Exposed for the get_pad_status MCP tool so
211
+ * agents can detect "you need to reload_from_disk before your next save"
212
+ * without waiting for the save itself to fail.
213
+ */
214
+ export function getExternalMtimeDrift() {
215
+ if (!state.filePath || state.loadedMtime === 0)
216
+ return null;
217
+ try {
218
+ const diskMtime = statSync(state.filePath).mtimeMs;
219
+ if (diskMtime !== state.loadedMtime) {
220
+ return { diskMtime, loadedMtime: state.loadedMtime };
221
+ }
222
+ }
223
+ catch { /* file missing */ }
224
+ return null;
225
+ }
226
+ /** Force-refresh the active doc's loadedMtime snapshot to current disk mtime.
227
+ * Used by reload_from_disk after re-reading the file, so the freshly
228
+ * adopted content's mtime becomes our new baseline. */
229
+ export function refreshLoadedMtime() {
230
+ if (!state.filePath)
231
+ return;
232
+ try {
233
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
234
+ }
235
+ catch { /* best-effort */ }
236
+ }
237
+ // ============================================================================
238
+ // ACTIVE DOC FILE WATCHER
239
+ // ============================================================================
240
+ //
241
+ // Watches the currently-active doc's file for external writes. When any
242
+ // other process (Edit tool, VSCode, a script) mutates the file, fs.watch
243
+ // fires; we debounce burst events, verify the disk mtime actually advanced
244
+ // past our last-stamped `loadedMtime`, and route through the unified
245
+ // reloadActiveDocFromDisk pathway — which re-parses disk, re-attaches the
246
+ // pending overlay by nodeId, runs the matcher, and lets subscribers
247
+ // broadcast a document-reloaded message to clients.
248
+ //
249
+ // Why push (fs.watch) instead of pull (mtime poll on writes only):
250
+ // - The browser autosave race was: external editor writes → server's
251
+ // in-memory state is now stale → browser sends an autosave from BEFORE
252
+ // the external write → server's version counter hasn't advanced (no
253
+ // MCP write occurred) → version check passes → stale content clobbers
254
+ // the external write.
255
+ // - The fix is to advance docVersion the instant the file changes on
256
+ // disk, not the instant we try to save. fs.watch makes that immediate.
257
+ //
258
+ // Single watcher at a time: we only care about the active doc. Switching
259
+ // docs tears down the previous watcher and opens a new one.
260
+ //
261
+ // Cross-platform: Node's fs.watch is inotify on Linux, FSEvents on macOS,
262
+ // ReadDirectoryChangesW on Windows. Single-file watching is reliable on
263
+ // all three; chokidar would add value for recursive directory trees, not
264
+ // here.
265
+ //
266
+ // adr: adr/active-doc-watcher.md
267
+ let activeWatcher = null;
268
+ let activeWatcherPath = '';
269
+ let watcherDebounceTimer = null;
270
+ const documentReloadedListeners = new Set();
271
+ export function onDocumentReloaded(listener) {
272
+ documentReloadedListeners.add(listener);
273
+ return () => documentReloadedListeners.delete(listener);
274
+ }
275
+ function notifyDocumentReloaded(event) {
276
+ for (const listener of documentReloadedListeners) {
277
+ try {
278
+ listener(event);
279
+ }
280
+ catch (err) {
281
+ console.error('[State] document-reloaded listener threw:', err);
282
+ }
283
+ }
284
+ }
285
+ /** Tear down the active-doc watcher (if any) and clear bookkeeping. */
286
+ function stopActiveDocWatcher() {
287
+ if (watcherDebounceTimer) {
288
+ clearTimeout(watcherDebounceTimer);
289
+ watcherDebounceTimer = null;
290
+ }
291
+ if (activeWatcher) {
292
+ try {
293
+ activeWatcher.close();
294
+ }
295
+ catch { /* best-effort */ }
296
+ activeWatcher = null;
297
+ }
298
+ activeWatcherPath = '';
299
+ }
300
+ /** Handle a watcher event after debounce — reload if mtime actually advanced. */
301
+ function handleWatcherEvent() {
302
+ // Only act if the watched path is still the active doc. If the user
303
+ // switched away during the debounce window, drop this event — the new
304
+ // active doc has its own watcher already running.
305
+ if (!state.filePath || state.filePath !== activeWatcherPath)
306
+ return;
307
+ // Skip if the file is gone (e.g., delete during watch). The next save
308
+ // will re-create it from in-memory state.
309
+ if (!existsSync(state.filePath))
310
+ return;
311
+ let diskMtime;
312
+ try {
313
+ diskMtime = statSync(state.filePath).mtimeMs;
314
+ }
315
+ catch {
316
+ return;
317
+ }
318
+ // Filter out events from our own writes. writeToDisk re-stamps
319
+ // state.loadedMtime to the post-write disk mtime, so by the time this
320
+ // handler fires for our own atomicWriteFileSync, mtimes match and we
321
+ // skip. Only genuine external writes have diskMtime > loadedMtime.
322
+ if (diskMtime === state.loadedMtime)
323
+ return;
324
+ const reloaded = reloadActiveDocFromDisk();
325
+ if (!reloaded)
326
+ return;
327
+ // (docVersion already bumped inside reloadActiveDocFromDisk — single
328
+ // source of truth for the reload-version-bump.)
329
+ notifyDocumentReloaded({
330
+ filePath: state.filePath,
331
+ filename: reloaded.filename,
332
+ document: reloaded.document,
333
+ title: reloaded.title,
334
+ docId: state.docId,
335
+ metadata: state.metadata,
336
+ orphans: reloaded.orphans,
337
+ staleBaseline: reloaded.staleBaseline,
338
+ });
339
+ const orphanCount = reloaded.orphans.length;
340
+ const staleCount = reloaded.staleBaseline.length;
341
+ const suffix = orphanCount || staleCount
342
+ ? ` (${orphanCount} orphan, ${staleCount} stale-baseline pending entries)`
343
+ : '';
344
+ console.log(`[State] reload active doc from external write: ${reloaded.filename}${suffix}`);
345
+ }
346
+ /** Start (or restart) the watcher on the active doc's filePath. Called
347
+ * whenever the active doc changes, or whenever load() lands a file. */
348
+ export function startActiveDocWatcher() {
349
+ stopActiveDocWatcher();
350
+ if (!state.filePath || !existsSync(state.filePath))
351
+ return;
352
+ // Some test environments (Windows CI, ephemeral filesystems) error from
353
+ // fs.watch on transient files. Swallow the failure — we'll simply not
354
+ // have external-write detection for this doc, which is degraded but
355
+ // not broken. writeToDisk still has the loadedMtime guard as a backstop.
356
+ try {
357
+ activeWatcherPath = state.filePath;
358
+ activeWatcher = watch(state.filePath, { persistent: false }, () => {
359
+ // Burst-debounce: editors often write through a temp file + rename,
360
+ // which fires multiple events in rapid succession. 80ms is short
361
+ // enough that human-perceptible latency is unchanged and long
362
+ // enough that one logical save coalesces.
363
+ if (watcherDebounceTimer)
364
+ clearTimeout(watcherDebounceTimer);
365
+ watcherDebounceTimer = setTimeout(handleWatcherEvent, 80);
366
+ });
367
+ activeWatcher.on('error', (err) => {
368
+ console.error(`[State] active doc watcher error on ${activeWatcherPath}:`, err.message);
369
+ stopActiveDocWatcher();
370
+ });
371
+ }
372
+ catch (err) {
373
+ console.error(`[State] failed to watch ${state.filePath}:`, err?.message || err);
374
+ stopActiveDocWatcher();
375
+ }
376
+ }
61
377
  // ============================================================================
62
378
  // EXTERNAL DOCUMENT REGISTRY
63
379
  // ============================================================================
64
380
  function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
381
+ /** External docs registered with openwriter — canonicalized paths only.
382
+ * Adding via `registerExternalDoc` runs paths through `canonicalizePath`
383
+ * before insertion, so the same physical file via any spelling
384
+ * (forward/back slash, mixed case drive letter, symlink) collapses to a
385
+ * single entry.
386
+ * adr: adr/path-canonicalization.md */
65
387
  const externalDocs = new Set();
388
+ // ============================================================================
389
+ // AGENT-STUB REGISTRY (in-memory only — never persisted)
390
+ // ============================================================================
391
+ //
392
+ // A doc is an "agent stub" between create_document (which mints an empty
393
+ // shell) and populate_document + accept (which fills it with content). If
394
+ // the user rejects the populated content, the stub is deleted — the agent
395
+ // proposed a doc, the user declined, nothing should remain.
396
+ //
397
+ // HISTORICAL MISTAKE: stub status was stored as `agentCreated: true` in
398
+ // frontmatter on disk. That made it sticky across sessions, server
399
+ // restarts, and arbitrary file lifetimes. Any reject-all on a doc whose
400
+ // stub status had been forgotten in a previous flow would destructively
401
+ // delete a file with hours of accepted work. The fix is not "guard the
402
+ // destruction more carefully"; the fix is "don't persist transient
403
+ // session state to disk in the first place."
404
+ //
405
+ // Stub status is now an in-memory Set<filename> with process lifetime.
406
+ // - markAsAgentStub(filename) — called on create_document
407
+ // - unmarkAgentStub(filename) — called on any save that contains
408
+ // non-pending content (graduation), on accept-all, on rename
409
+ // - isAgentStub(filename) — the only thing reject-all-deletes consults
410
+ //
411
+ // A stub that survives a server restart is by definition no longer fresh.
412
+ // It loads from disk like any other doc and reject-all will not delete it.
413
+ // This is intentional: graduating-by-lifetime is the safest fallback.
414
+ //
415
+ // adr: adr/agent-stub-model.md
416
+ const agentStubFilenames = new Set();
417
+ export function markAsAgentStub(filename) {
418
+ if (filename)
419
+ agentStubFilenames.add(filename);
420
+ }
421
+ export function unmarkAgentStub(filename) {
422
+ if (filename)
423
+ agentStubFilenames.delete(filename);
424
+ }
425
+ export function isAgentStub(filename) {
426
+ return !!filename && agentStubFilenames.has(filename);
427
+ }
428
+ /** Legacy migration. If a file's frontmatter has `agentCreated: true`, that
429
+ * field came from the pre-architectural-fix code path. Strip it on load so
430
+ * we don't keep round-tripping a dead field. No in-memory stub registration
431
+ * happens — by definition, if the flag survived to disk this long, the doc
432
+ * is not a fresh stub anymore. */
433
+ function stripLegacyAgentCreated(metadata) {
434
+ if (metadata && 'agentCreated' in metadata)
435
+ delete metadata.agentCreated;
436
+ }
66
437
  function persistExternalDocs() {
67
438
  try {
68
439
  atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
69
440
  }
70
441
  catch { /* best-effort */ }
71
442
  }
443
+ /** Load external-doc registry from disk and canonicalize every entry on
444
+ * the way in. The Set's branded type collapses any pre-existing
445
+ * duplicates (forward-slash vs backslash entries for the same file)
446
+ * to one — that's the one-time migration. If canonicalization changed
447
+ * the on-disk representation, we re-persist so the file matches
448
+ * in-memory state.
449
+ * adr: adr/path-canonicalization.md */
72
450
  function loadExternalDocs() {
73
451
  try {
74
- if (existsSync(getExternalDocsFile())) {
75
- const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
76
- for (const p of paths) {
77
- if (existsSync(p))
78
- externalDocs.add(p);
452
+ if (!existsSync(getExternalDocsFile()))
453
+ return;
454
+ const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
455
+ let needsRewrite = false;
456
+ for (const p of paths) {
457
+ if (!existsSync(p)) {
458
+ needsRewrite = true;
459
+ continue;
79
460
  }
461
+ const canon = canonicalizePath(p);
462
+ if (canon !== p)
463
+ needsRewrite = true;
464
+ externalDocs.add(canon);
465
+ }
466
+ // If the on-disk file had duplicates or non-canonical entries, the
467
+ // collapsed Set is now smaller and/or different — persist it back.
468
+ if (needsRewrite || externalDocs.size !== paths.length) {
469
+ persistExternalDocs();
80
470
  }
81
471
  }
82
472
  catch { /* corrupt file — start fresh */ }
83
473
  }
84
474
  export function registerExternalDoc(fullPath) {
85
- externalDocs.add(fullPath);
475
+ externalDocs.add(canonicalizePath(fullPath));
86
476
  persistExternalDocs();
87
477
  }
88
478
  export function unregisterExternalDoc(fullPath) {
89
- externalDocs.delete(fullPath);
479
+ externalDocs.delete(canonicalizePath(fullPath));
90
480
  persistExternalDocs();
91
481
  }
92
482
  export function getExternalDocs() {
@@ -110,6 +500,20 @@ function isDocEmpty(doc) {
110
500
  export function getDocument() {
111
501
  return state.document;
112
502
  }
503
+ /** The clean canonical document — what disk holds, what the matcher pairs
504
+ * against, what snapshots capture. Primary state; not derived. */
505
+ export function getCanonical() {
506
+ return state.canonical;
507
+ }
508
+ /** Snapshot of the structured pending overlay. Primary state. */
509
+ export function getOverlayEntries() {
510
+ return Array.from(state.overlay.values());
511
+ }
512
+ /** Read-only view of the overlay Map. Use when you need keyed lookup
513
+ * rather than iteration. */
514
+ export function getOverlay() {
515
+ return state.overlay;
516
+ }
113
517
  export function getTitle() {
114
518
  return state.title;
115
519
  }
@@ -409,8 +813,19 @@ export function updateDocument(doc) {
409
813
  if (serverHadPending) {
410
814
  transferPendingAttrs(state.document, doc);
411
815
  }
412
- state.document = doc;
816
+ // Route the incoming merged-shape doc through the primary-state setter
817
+ // so canonical + overlay get refreshed and state.document is the
818
+ // recomputed merged view. Direct assignment is forbidden.
819
+ setPrimaryFromMerged(doc);
413
820
  state.lastModified = new Date();
821
+ // Bump docVersion so the writeToDisk no-op gate (which compares
822
+ // docVersion to lastSavedDocVersion) sees this mutation. Browser
823
+ // doc-updates flow through here, and without the bump the subsequent
824
+ // debouncedSave would short-circuit and the user's edits would never
825
+ // hit disk. The canonical contract: any path that mutates
826
+ // state.document MUST bump docVersion. applyChanges does the same.
827
+ // adr: adr/pending-overlay-model.md
828
+ bumpDocVersion();
414
829
  // Validate: if server had pending changes, verify they survived the transfer
415
830
  if (serverHadPending && !hasPendingChanges()) {
416
831
  console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
@@ -479,18 +894,69 @@ function transferPendingAttrs(source, target) {
479
894
  // ============================================================================
480
895
  // AGENT WRITE LOCK
481
896
  // ============================================================================
897
+ // adr: adr/agent-lock-per-doc.md
482
898
  const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
483
- let lastAgentWriteTime = 0;
484
- /** Set the agent write lock (called after agent changes). */
485
- export function setAgentLock() {
486
- lastAgentWriteTime = Date.now();
487
- }
488
- /** Check if the agent write lock is active. */
489
- export function isAgentLocked() {
490
- return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
899
+ const lockExpiry = new Map();
900
+ let globalLockExpiry = 0;
901
+ /** Derive the identifier used for locking the active doc. Mirrors
902
+ * documents.ts:getActiveFilename without importing it (circular dep). */
903
+ function activeDocLockKey() {
904
+ const fp = state.filePath;
905
+ if (!fp)
906
+ return '';
907
+ if (isExternalDoc(fp))
908
+ return canonicalizeIdentifier(fp);
909
+ return fp.split(/[/\\]/).pop() || '';
910
+ }
911
+ /** Set the agent write lock for a specific document. */
912
+ export function setAgentLock(filename) {
913
+ if (!filename) {
914
+ // Defensive: empty filename means we don't know what to lock — lock global.
915
+ setAgentLockGlobal();
916
+ return;
917
+ }
918
+ const key = canonicalizeIdentifier(filename);
919
+ const wasActive = (lockExpiry.get(key) ?? 0) > Date.now();
920
+ lockExpiry.set(key, Date.now() + AGENT_LOCK_MS);
921
+ diagLog(`[Lock] SET filename=${key} ttl=${AGENT_LOCK_MS}ms${wasActive ? ' (extends active lock)' : ''}`);
922
+ }
923
+ /** Lock the currently active document. Convenience for callers that mutate
924
+ * via state.filePath rather than an explicit filename. */
925
+ export function setAgentLockActive() {
926
+ setAgentLock(activeDocLockKey());
927
+ }
928
+ /** Lock every document briefly. Used at server init so reconnecting browsers
929
+ * can't push stale state from before the restart. */
930
+ export function setAgentLockGlobal() {
931
+ globalLockExpiry = Date.now() + AGENT_LOCK_MS;
932
+ diagLog(`[Lock] SET global ttl=${AGENT_LOCK_MS}ms`);
933
+ }
934
+ /** Check if the agent write lock is active for a given document. */
935
+ export function isAgentLocked(filename) {
936
+ const now = Date.now();
937
+ if (globalLockExpiry > now)
938
+ return true;
939
+ if (!filename)
940
+ return false;
941
+ const key = canonicalizeIdentifier(filename);
942
+ const expiry = lockExpiry.get(key);
943
+ if (expiry === undefined)
944
+ return false;
945
+ if (expiry > now)
946
+ return true;
947
+ lockExpiry.delete(key);
948
+ return false;
491
949
  }
492
950
  // ---- Document version counter: prevents stale browser doc-updates ----
493
951
  let docVersion = 0;
952
+ // Counterpart to docVersion: the docVersion at which we last confirmed
953
+ // in-memory state matches disk. save()/writeToDisk() is a strict no-op when
954
+ // these are equal (and a file exists on disk). This is the server-side
955
+ // counterpart to the client diff-gate in App.tsx — it makes doc-switches
956
+ // between unchanged docs free (no serialize, no sidecar write, no snapshot,
957
+ // no mtime bump that would invalidate the doc cache).
958
+ // adr: adr/pending-overlay-model.md
959
+ let lastSavedDocVersion = 0;
494
960
  /** Increment version after agent writes. Returns the new version. */
495
961
  export function bumpDocVersion() {
496
962
  return ++docVersion;
@@ -503,14 +969,25 @@ export function getDocVersion() {
503
969
  export function isVersionCurrent(browserVersion) {
504
970
  return browserVersion >= docVersion;
505
971
  }
506
- /** Reset version on document switch (new document = new version lineage). */
972
+ /** Reset version on document switch (new document = new version lineage).
973
+ * Both counters move together: the new doc was just loaded from disk (or
974
+ * cache, which mtime-validates against disk), so in-memory matches disk
975
+ * by definition. */
507
976
  export function resetDocVersion() {
508
977
  docVersion = 0;
978
+ lastSavedDocVersion = 0;
509
979
  }
510
980
  // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
981
+ //
982
+ // Single timer for the entire process. Both state.ts (MCP write paths, applyChanges,
983
+ // updateDocument) and ws.ts (browser doc-update, pending-resolved) call into this.
984
+ // Previously each module had its own timer (state.ts 500ms, ws.ts 2s) which meant
985
+ // a save could be armed by one path, reset by another, and fire on a delay that
986
+ // matched neither documented value. One timer, one TTL — predictable.
987
+ // adr: adr/pending-overlay-model.md
511
988
  let saveTimer = null;
512
989
  const SAVE_DEBOUNCE_MS = 500;
513
- function debouncedSave() {
990
+ export function debouncedSave() {
514
991
  if (saveTimer)
515
992
  clearTimeout(saveTimer);
516
993
  saveTimer = setTimeout(() => {
@@ -530,7 +1007,7 @@ export function applyChanges(changes) {
530
1007
  const processed = applyChangesToDocument(changes);
531
1008
  // Bump version + lock browser doc-updates to prevent stale state overwrite
532
1009
  const version = bumpDocVersion();
533
- setAgentLock();
1010
+ setAgentLockActive();
534
1011
  // Broadcast processed changes (with server-assigned IDs + version) to browser clients
535
1012
  for (const listener of listeners) {
536
1013
  listener(processed, version);
@@ -554,6 +1031,10 @@ export function applyChanges(changes) {
554
1031
  }
555
1032
  return { count: processed.length, lastNodeId };
556
1033
  }
1034
+ export function onIdRewrites(listener) {
1035
+ idRewriteListeners.add(listener);
1036
+ return () => idRewriteListeners.delete(listener);
1037
+ }
557
1038
  export function onChanges(listener) {
558
1039
  listeners.add(listener);
559
1040
  return () => listeners.delete(listener);
@@ -562,6 +1043,24 @@ export function onChanges(listener) {
562
1043
  // SERVER-SIDE DOCUMENT MUTATIONS
563
1044
  // ============================================================================
564
1045
  // generateNodeId imported from helpers.ts
1046
+ /**
1047
+ * Containers whose internals are NOT addressable via MCP. `findNode` must not
1048
+ * descend into these — their child IDs are ephemeral (regenerated every
1049
+ * `markdownToTiptap` parse, untracked by the matcher) and never exposed via
1050
+ * `compactNodes`, so any agent-provided ID that happens to match a node inside
1051
+ * one is a collision, not a legitimate target.
1052
+ *
1053
+ * Before this guard existed, a `write_to_pad` rewrite could silently corrupt a
1054
+ * table cell: an agent ID collision with a freshly-minted table-internal node
1055
+ * routed the splice into the table instead of the intended top-level
1056
+ * paragraph. Reported as success, observable as stray paragraphs / mangled
1057
+ * rows in the saved markdown.
1058
+ *
1059
+ * Mirrors `node-blocks.ts`'s walker, which already treats tables as opaque.
1060
+ *
1061
+ * adr: adr/node-identity-matcher.md
1062
+ */
1063
+ const OPAQUE_CONTAINER_TYPES = new Set(['table', 'tableRow', 'tableCell', 'tableHeader']);
565
1064
  /**
566
1065
  * Find a node by ID in any document tree.
567
1066
  * topLevel is used to resolve the "end" sentinel.
@@ -577,6 +1076,11 @@ function findNode(nodes, id, topLevel) {
577
1076
  if (nodes[i].attrs?.id === id) {
578
1077
  return { parent: nodes, index: i };
579
1078
  }
1079
+ // Don't descend into table internals (table, tableRow, tableCell, tableHeader).
1080
+ // Their IDs aren't addressable via MCP and they regenerate on every parse,
1081
+ // so any match inside is a collision that would silently corrupt the table.
1082
+ if (OPAQUE_CONTAINER_TYPES.has(nodes[i].type))
1083
+ continue;
580
1084
  if (nodes[i].content && Array.isArray(nodes[i].content)) {
581
1085
  const result = findNode(nodes[i].content, id, topLevel);
582
1086
  if (result)
@@ -845,12 +1349,23 @@ export function isAutoAcceptActive(filename, metadata) {
845
1349
  return false;
846
1350
  return isAutoAcceptInheritedForDoc(filename);
847
1351
  }
848
- /** Apply changes to the active document singleton. */
1352
+ /** Apply changes to the active document singleton.
1353
+ *
1354
+ * The applyChangesToDoc engine mutates the merged view (state.document) —
1355
+ * it's where the pending-status stamping logic lives. After the mutation,
1356
+ * re-split state.document back into primary state (canonical + overlay) so
1357
+ * the cache + matcher + save paths see consistent primary state. The
1358
+ * re-split is the bridge between the legacy "mutate merged in place" engine
1359
+ * and the new "primary state is canonical + overlay" model. */
849
1360
  function applyChangesToDocument(changes) {
850
1361
  const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
851
1362
  const processed = applyChangesToDoc(state.document, changes, autoAccept);
852
1363
  if (processed.length > 0) {
853
1364
  state.lastModified = new Date();
1365
+ // Re-sync primary state from the now-mutated merged view. Idempotent:
1366
+ // splitMergedDoc + applyOverlayPure round-trip leaves state.document
1367
+ // structurally equivalent to itself.
1368
+ setPrimaryFromMerged(state.document);
854
1369
  }
855
1370
  return processed;
856
1371
  }
@@ -864,8 +1379,16 @@ export function applyTextEdits(nodeId, edits) {
864
1379
  return { success: false, error: `Node ${nodeId} not found` };
865
1380
  const originalNode = found.parent[found.index];
866
1381
  const result = applyTextEditsToNode(originalNode, edits);
867
- if (!result)
868
- return { success: false, error: 'No edits matched' };
1382
+ if (!result) {
1383
+ // Surface a slice of the actual node text alongside the searched `find`
1384
+ // strings so the agent can diff for unicode/whitespace mismatches
1385
+ // (em-dash vs hyphen-minus, NBSP vs space, smart quotes, etc.). Without
1386
+ // this the failure is opaque and the agent has to guess.
1387
+ const nodeText = extractText(originalNode.content || []);
1388
+ const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
1389
+ const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
1390
+ return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
1391
+ }
869
1392
  // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
870
1393
  if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
871
1394
  result.node.attrs = {
@@ -888,14 +1411,42 @@ export function applyTextEdits(nodeId, edits) {
888
1411
  * (Option B in adr/node-identity-matcher.md). Markdown is the source of
889
1412
  * truth; memory is an ephemeral working copy. */
890
1413
  export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
891
- state.document = doc;
1414
+ // Route the incoming doc through the primary-state setter — splits into
1415
+ // canonical + overlay (handles legacy in-frontmatter pending if present)
1416
+ // and recomputes the merged view.
1417
+ setPrimaryFromMerged(doc);
892
1418
  state.title = title;
893
1419
  state.metadata = metadata || { title };
894
- state.filePath = filePath;
1420
+ // Legacy: strip any pre-architectural-fix `agentCreated` field that
1421
+ // arrived in metadata (e.g. from a re-parse of an old on-disk file).
1422
+ // The in-memory agentStubFilenames Set is the only authority for stub
1423
+ // status — disk frontmatter must not carry stub state.
1424
+ stripLegacyAgentCreated(state.metadata);
1425
+ // Canonicalize at the identity boundary: same physical file via any
1426
+ // spelling (forward/back slash, drive-letter case, symlink) lands the
1427
+ // same string in state.filePath, which is the cache key for the doc
1428
+ // cache and the subscription path for the fs watcher.
1429
+ // adr: adr/path-canonicalization.md
1430
+ state.filePath = filePath ? canonicalizePath(filePath) : '';
895
1431
  state.isTemp = isTemp;
896
1432
  state.lastModified = lastModified || new Date();
897
1433
  state.docId = ensureDocId(state.metadata);
898
1434
  state.originalFrontmatter = originalFrontmatter ?? null;
1435
+ // Snapshot the on-disk mtime so writeToDisk can detect external writes
1436
+ // that land while this doc is active. 0 = no file on disk yet (new doc).
1437
+ try {
1438
+ state.loadedMtime = filePath && existsSync(filePath) ? statSync(filePath).mtimeMs : 0;
1439
+ }
1440
+ catch {
1441
+ state.loadedMtime = 0;
1442
+ }
1443
+ // Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
1444
+ // (sidecar present, legacy migration, no pending).
1445
+ mergeOverlayOnLoad();
1446
+ // Subscribe the fs watcher to this doc so external writes (Edit tool,
1447
+ // VSCode, scripts) trigger a unified reload + version bump + broadcast.
1448
+ // adr: adr/active-doc-watcher.md
1449
+ startActiveDocWatcher();
899
1450
  }
900
1451
  // ============================================================================
901
1452
  // PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
@@ -965,7 +1516,12 @@ function populatePendingCache() {
965
1516
  catch { /* skip unreadable files */ }
966
1517
  }
967
1518
  }
968
- const docCache = new Map(); // key = filePath
1519
+ /** Keyed by canonical path. Two spellings of the same physical file
1520
+ * (forward/back slash, drive-letter case) collapse to one cache entry —
1521
+ * preventing parallel state where the same disk file lives in two
1522
+ * cache slots.
1523
+ * adr: adr/path-canonicalization.md */
1524
+ const docCache = new Map();
969
1525
  /** Cache the active document's full state, keyed by filePath. Call after save().
970
1526
  *
971
1527
  * Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
@@ -979,8 +1535,14 @@ export function cacheActiveDocument() {
979
1535
  fileMtime = statSync(state.filePath).mtimeMs;
980
1536
  }
981
1537
  catch { /* file may not exist yet */ }
982
- docCache.set(state.filePath, {
983
- document: structuredClone(state.document),
1538
+ // Split the live merged document into canonical + overlay AT cache time —
1539
+ // this is what protects against the duplicate-insert bug. Storing merged
1540
+ // and re-applying overlay later was the broken pattern.
1541
+ const split = splitMergedDoc(state.document);
1542
+ docCache.set(canonicalizePath(state.filePath), {
1543
+ canonical: split.canonical,
1544
+ overlayEntries: split.overlayEntries,
1545
+ document: applyOverlayPure(split.canonical, split.overlayEntries),
984
1546
  metadata: structuredClone(state.metadata),
985
1547
  title: state.title,
986
1548
  isTemp: state.isTemp,
@@ -992,27 +1554,28 @@ export function cacheActiveDocument() {
992
1554
  }
993
1555
  /** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
994
1556
  export function getCachedDocument(filePath) {
995
- const cached = docCache.get(filePath);
1557
+ const key = canonicalizePath(filePath);
1558
+ const cached = docCache.get(key);
996
1559
  if (!cached)
997
1560
  return null;
998
1561
  try {
999
1562
  const currentMtime = statSync(filePath).mtimeMs;
1000
1563
  if (currentMtime !== cached.fileMtime) {
1001
1564
  // File changed on disk — invalidate cache
1002
- docCache.delete(filePath);
1565
+ docCache.delete(key);
1003
1566
  return null;
1004
1567
  }
1005
1568
  }
1006
1569
  catch {
1007
1570
  // File doesn't exist or can't be read — invalidate
1008
- docCache.delete(filePath);
1571
+ docCache.delete(key);
1009
1572
  return null;
1010
1573
  }
1011
1574
  return cached;
1012
1575
  }
1013
1576
  /** Remove a specific file from the document cache. */
1014
1577
  export function invalidateDocCache(filePath) {
1015
- docCache.delete(filePath);
1578
+ docCache.delete(canonicalizePath(filePath));
1016
1579
  }
1017
1580
  /** Update the cache entry for a file after writing changes (without cloning the active state). */
1018
1581
  export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
@@ -1021,10 +1584,18 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
1021
1584
  fileMtime = statSync(filePath).mtimeMs;
1022
1585
  }
1023
1586
  catch { /* best-effort */ }
1587
+ const key = canonicalizePath(filePath);
1024
1588
  // Preserve originalFrontmatter from existing cache entry (if any)
1025
- const existing = docCache.get(filePath);
1026
- docCache.set(filePath, {
1027
- document: structuredClone(doc),
1589
+ const existing = docCache.get(key);
1590
+ // Split the incoming doc into canonical + overlay before caching so the
1591
+ // cache stores canonical-only and the duplicate-insert chain is broken.
1592
+ const split = splitMergedDoc(doc);
1593
+ const overlayEntries = split.overlayEntries;
1594
+ const canonicalClone = split.canonical;
1595
+ docCache.set(key, {
1596
+ canonical: canonicalClone,
1597
+ overlayEntries,
1598
+ document: applyOverlayPure(canonicalClone, overlayEntries),
1028
1599
  metadata: structuredClone(metadata),
1029
1600
  title,
1030
1601
  isTemp,
@@ -1036,10 +1607,15 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
1036
1607
  }
1037
1608
  /** Reset all in-memory caches. Called on profile switch. */
1038
1609
  export function clearAllCaches() {
1610
+ // Tear down the active-doc watcher before swapping state — leaving it
1611
+ // alive would point at a path the new profile doesn't own.
1612
+ stopActiveDocWatcher();
1039
1613
  docCache.clear();
1040
1614
  pendingDocCache.clear();
1041
1615
  externalDocs.clear();
1042
1616
  state = {
1617
+ canonical: DEFAULT_DOC,
1618
+ overlay: new Map(),
1043
1619
  document: DEFAULT_DOC,
1044
1620
  title: 'Untitled',
1045
1621
  metadata: { title: 'Untitled' },
@@ -1048,6 +1624,7 @@ export function clearAllCaches() {
1048
1624
  lastModified: new Date(),
1049
1625
  docId: '',
1050
1626
  originalFrontmatter: null,
1627
+ loadedMtime: 0,
1051
1628
  };
1052
1629
  }
1053
1630
  // ============================================================================
@@ -1069,29 +1646,252 @@ export function hasPendingChanges(doc) {
1069
1646
  }
1070
1647
  return scan(target.content);
1071
1648
  }
1072
- /** Strip all pending attrs from the current document (after browser resolves all changes). */
1649
+ /** Strip all pending attrs from the current document (after browser resolves all changes).
1650
+ * In the new model this is implemented by clearing the overlay — the merged
1651
+ * view recomputes to canonical (which already has no pending markers). */
1073
1652
  export function stripPendingAttrs() {
1074
- function strip(nodes) {
1075
- if (!nodes)
1653
+ state.overlay = new Map();
1654
+ recomputeMerged();
1655
+ removePendingCacheEntry(activeDocFilename());
1656
+ }
1657
+ /**
1658
+ * Return a deep clone of `doc` with all pending changes reverted, as if the
1659
+ * user had rejected every pending decoration:
1660
+ * - pendingStatus=insert → drop the node
1661
+ * - pendingStatus=rewrite → replace node with `pendingOriginalContent` (or drop if absent)
1662
+ * - pendingStatus=delete → keep node, clear pending attrs (the rejection is "no, don't delete")
1663
+ * - no pendingStatus → keep node, strip any stray pending attrs
1664
+ *
1665
+ * Used by `restore_version` to write a canonical-only safety checkpoint so
1666
+ * the snapshot represents a clean recovery point rather than a flattened
1667
+ * pending+canonical hybrid that never actually existed in the user's view.
1668
+ *
1669
+ * Does NOT mutate the input doc.
1670
+ */
1671
+ /**
1672
+ * Reload the active doc from disk. Re-reads the canonical .md file, applies
1673
+ * the pending overlay from the sidecar (with orphan + stale-baseline
1674
+ * classification), and updates state.document, state.metadata, state.title,
1675
+ * and state.loadedMtime in place.
1676
+ *
1677
+ * Called by:
1678
+ * - chokidar watcher when an external write modifies the active file
1679
+ * - mcp.restore_version after writing the snapshot's content to disk
1680
+ * - mcp.reload_from_disk tool (explicit user-driven reload)
1681
+ * - writeToDisk's mtime-CAS backstop (recover-and-retry path)
1682
+ *
1683
+ * Returns the reload result so callers can broadcast or react to the
1684
+ * orphan / staleBaseline classifications. Returns null if there's no
1685
+ * active file path to reload (no-op).
1686
+ *
1687
+ * adr: adr/pending-overlay-model.md
1688
+ */
1689
+ export function reloadActiveDocFromDisk() {
1690
+ if (!state.filePath || !existsSync(state.filePath))
1691
+ return null;
1692
+ const raw = readFileSync(state.filePath, 'utf-8');
1693
+ const parsed = markdownToTiptap(raw);
1694
+ // Split parsed doc: canonical (clean) + any legacy in-frontmatter pending.
1695
+ const { canonical, overlayEntries: legacyOverlay } = splitMergedDoc(parsed.document);
1696
+ state.title = parsed.title;
1697
+ state.metadata = parsed.metadata;
1698
+ stripLegacyAgentCreated(state.metadata);
1699
+ state.docId = ensureDocId(state.metadata);
1700
+ state.lastModified = new Date(statSync(state.filePath).mtimeMs);
1701
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
1702
+ // Sidecar is authoritative if present. Otherwise legacy in-doc pending
1703
+ // migrates to sidecar (one-time).
1704
+ const sidecar = loadOverlay(state.docId);
1705
+ let entries;
1706
+ if (sidecar.length > 0) {
1707
+ entries = sidecar;
1708
+ }
1709
+ else if (legacyOverlay.length > 0) {
1710
+ entries = legacyOverlay;
1711
+ saveOverlay(state.docId, legacyOverlay);
1712
+ }
1713
+ else {
1714
+ entries = [];
1715
+ }
1716
+ // Set primary state directly — canonical from disk parse, overlay from
1717
+ // sidecar (or legacy migration). Recompute merged via the helper.
1718
+ state.canonical = canonical;
1719
+ setOverlayFromEntries(entries);
1720
+ // External writes change the body but leave disk frontmatter pointing at
1721
+ // the previous save's fingerprints. If the user cuts/deletes a block before
1722
+ // the next browser-driven save, the matcher graveyards with that stale
1723
+ // fingerprint and a later paste-back can't match by exact fingerprint —
1724
+ // graveyard-restore silently misses. Resync disk frontmatter with the
1725
+ // reloaded body now: the matcher's edit rule pins IDs and emits fresh
1726
+ // per-block fingerprints. fs.watch self-suppression via state.loadedMtime
1727
+ // (handleWatcherEvent) prevents a reload→save→reload loop.
1728
+ //
1729
+ // Bump docVersion BEFORE writeToDisk: serves two purposes at once. (1)
1730
+ // Rejects any in-flight stale browser autosaves (the WS handler checks
1731
+ // version currency). (2) Forces writeToDisk's no-op gate to see "dirty"
1732
+ // state and actually persist the refreshed frontmatter — without the bump,
1733
+ // the gate would short-circuit and the stale-fingerprint bug returns.
1734
+ // adr: adr/node-identity-matcher.md
1735
+ bumpDocVersion();
1736
+ try {
1737
+ writeToDisk();
1738
+ }
1739
+ catch { /* best-effort — reload still useful even if save fails */ }
1740
+ return {
1741
+ document: state.document,
1742
+ title: state.title,
1743
+ filename: state.filePath.split(/[/\\]/).pop() || '',
1744
+ orphans: [],
1745
+ staleBaseline: [],
1746
+ };
1747
+ }
1748
+ /**
1749
+ * Pending overlay rehydration. Runs after a doc loads from disk (load() or
1750
+ * setActiveDocument). Three cases:
1751
+ *
1752
+ * 1. Sidecar has entries: state.document is canonical (parser found no
1753
+ * `meta.pending`). Apply sidecar overlay to layer pending decorations
1754
+ * back onto the canonical tree.
1755
+ *
1756
+ * 2. Sidecar empty, parser stamped pending attrs from legacy frontmatter:
1757
+ * one-time migration. Extract overlay from state.document, save to
1758
+ * sidecar. State.document already has pending attrs from parse, so
1759
+ * no apply step needed.
1760
+ *
1761
+ * 3. Sidecar empty, parser stamped nothing: no pending state. No-op.
1762
+ *
1763
+ * Returns the merged overlay entries for callers that need them.
1764
+ * adr: adr/pending-overlay-model.md
1765
+ */
1766
+ function mergeOverlayOnLoad() {
1767
+ if (!state.docId)
1768
+ return [];
1769
+ // setActiveDocument just populated primary state via setPrimaryFromMerged.
1770
+ // If a sidecar exists for this docId, it overrides any legacy in-doc pending
1771
+ // that the splitter extracted from the input. If no sidecar exists and the
1772
+ // legacy split found entries, persist them as a one-time migration.
1773
+ const sidecar = loadOverlay(state.docId);
1774
+ const legacy = Array.from(state.overlay.values());
1775
+ if (sidecar.length > 0) {
1776
+ setOverlayFromEntries(sidecar);
1777
+ return sidecar;
1778
+ }
1779
+ else if (legacy.length > 0) {
1780
+ saveOverlay(state.docId, legacy);
1781
+ // overlay already has these entries from setPrimaryFromMerged; no-op
1782
+ return legacy;
1783
+ }
1784
+ return [];
1785
+ }
1786
+ /**
1787
+ * Apply an oldId → newId translation map to a TipTap doc tree in place.
1788
+ * Used by writeToDisk to bring state.document's nodeIds in sync with the
1789
+ * matcher's post-pass canonical IDs.
1790
+ */
1791
+ function applyIdTranslationToDoc(doc, translation) {
1792
+ if (translation.size === 0)
1793
+ return;
1794
+ function walk(nodes) {
1795
+ if (!Array.isArray(nodes))
1076
1796
  return;
1077
1797
  for (const node of nodes) {
1078
- if (node.attrs?.pendingStatus) {
1079
- delete node.attrs.pendingStatus;
1080
- delete node.attrs.pendingOriginalContent;
1081
- delete node.attrs.pendingTextEdits;
1798
+ const oldId = node?.attrs?.id;
1799
+ if (oldId && translation.has(oldId)) {
1800
+ node.attrs.id = translation.get(oldId);
1082
1801
  }
1083
- if (node.content)
1084
- strip(node.content);
1802
+ if (node?.content)
1803
+ walk(node.content);
1085
1804
  }
1086
1805
  }
1087
- strip(state.document.content);
1088
- removePendingCacheEntry(activeDocFilename());
1806
+ walk(doc.content || []);
1807
+ }
1808
+ export function cloneWithPendingReverted(doc) {
1809
+ const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
1810
+ function clean(node) {
1811
+ const clone = JSON.parse(JSON.stringify(node));
1812
+ if (clone.attrs) {
1813
+ for (const k of PENDING_KEYS)
1814
+ delete clone.attrs[k];
1815
+ }
1816
+ if (clone.content)
1817
+ clone.content = walk(clone.content);
1818
+ return clone;
1819
+ }
1820
+ function walk(nodes) {
1821
+ const result = [];
1822
+ for (const node of nodes) {
1823
+ const status = node.attrs?.pendingStatus;
1824
+ if (status === 'insert')
1825
+ continue; // drop fresh agent inserts
1826
+ if (status === 'rewrite') {
1827
+ const original = node.attrs?.pendingOriginalContent;
1828
+ if (original)
1829
+ result.push(clean(original));
1830
+ // If no original stashed, drop the node — we have nothing to revert to.
1831
+ continue;
1832
+ }
1833
+ // 'delete' status or no status: keep the node, strip any pending attrs.
1834
+ result.push(clean(node));
1835
+ }
1836
+ return result;
1837
+ }
1838
+ return { type: 'doc', content: walk(doc.content || []) };
1839
+ }
1840
+ /**
1841
+ * Does the document have any "accepted" content — i.e. blocks that wouldn't
1842
+ * vanish under reject-all? Used to clear the `agentCreated` flag once a stub
1843
+ * has graduated into a real document, so a later reject-all on stale pending
1844
+ * decorations doesn't accidentally trigger the delete-on-reject cascade.
1845
+ *
1846
+ * A node counts as accepted if it has no pendingStatus, or has
1847
+ * pendingStatus=delete (reject keeps the node), or pendingStatus=rewrite with
1848
+ * `pendingOriginalContent` present (reject restores prior content).
1849
+ */
1850
+ export function hasAcceptedContent(doc) {
1851
+ function extractTextLocal(nodes) {
1852
+ let out = '';
1853
+ for (const n of nodes) {
1854
+ if (typeof n.text === 'string')
1855
+ out += n.text;
1856
+ if (n.content)
1857
+ out += extractTextLocal(n.content);
1858
+ }
1859
+ return out;
1860
+ }
1861
+ function walk(nodes) {
1862
+ if (!nodes)
1863
+ return false;
1864
+ for (const node of nodes) {
1865
+ const status = node.attrs?.pendingStatus;
1866
+ // Nodes that would NOT survive reject-all: skip entirely, including their
1867
+ // children. Otherwise an insert-pending paragraph's text children would be
1868
+ // misread as accepted content.
1869
+ if (status === 'insert')
1870
+ continue;
1871
+ if (status === 'rewrite' && !node.attrs?.pendingOriginalContent)
1872
+ continue;
1873
+ // This node survives reject-all (no status, or delete, or rewrite-with-original).
1874
+ const surfaceText = node.text || extractTextLocal(node.content || []);
1875
+ if (surfaceText && surfaceText.trim().length > 0)
1876
+ return true;
1877
+ // Non-text leaf types also count as accepted content
1878
+ if (node.type === 'image' || node.type === 'horizontalRule' || node.type === 'table')
1879
+ return true;
1880
+ // Recurse into container nodes (lists, blockquotes) only when the
1881
+ // container itself isn't a dropped-on-reject node.
1882
+ if (node.content && walk(node.content))
1883
+ return true;
1884
+ }
1885
+ return false;
1886
+ }
1887
+ return walk(doc.content || []);
1089
1888
  }
1090
1889
  /**
1091
1890
  * Mark leaf block nodes as pending within a node array.
1092
1891
  * Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
1093
1892
  * NOT container nodes (bulletList, orderedList, listItem, blockquote).
1094
- * This ensures collectPendingState captures them correctly on save.
1893
+ * Used by `applyChangesToDoc` for write_to_pad inserts where containers
1894
+ * are handled by the explicit firstNode top-level mark.
1095
1895
  */
1096
1896
  function markLeafBlocksAsPending(nodes, status) {
1097
1897
  if (!nodes)
@@ -1108,8 +1908,45 @@ function markLeafBlocksAsPending(nodes, status) {
1108
1908
  }
1109
1909
  }
1110
1910
  }
1911
+ /**
1912
+ * Block-level container types. Tagged as pending alongside leaves on the
1913
+ * populate path so a fresh doc with nested content (lists, blockquotes)
1914
+ * records the wrappers as overlay entries, not just the inner paragraphs.
1915
+ * Without this, on reload the wrappers are gone (empty containers have no
1916
+ * markdown representation) and inner-paragraph entries with parentNodeId
1917
+ * pointing at the missing wrapper get classified as orphans.
1918
+ *
1919
+ * adr: adr/pending-overlay-model.md
1920
+ */
1921
+ const CONTAINER_BLOCK_TYPES = new Set([
1922
+ 'bulletList', 'orderedList', 'listItem',
1923
+ 'taskList', 'taskItem',
1924
+ 'blockquote',
1925
+ ]);
1926
+ /**
1927
+ * Mark every block node (leaves + containers) as pending. Used by the
1928
+ * populate path where the entire doc tree is the agent's proposal — every
1929
+ * structural node must become an overlay entry so on reload the leaves'
1930
+ * parentNodeId references resolve through entries placed earlier in the
1931
+ * same batch.
1932
+ */
1933
+ function markAllBlockNodesAsPending(nodes, status) {
1934
+ if (!nodes)
1935
+ return;
1936
+ for (const node of nodes) {
1937
+ if (node.type && (LEAF_BLOCK_TYPES.has(node.type) || CONTAINER_BLOCK_TYPES.has(node.type))) {
1938
+ node.attrs = { ...node.attrs, pendingStatus: status };
1939
+ if (!node.attrs.id) {
1940
+ node.attrs.id = generateNodeId();
1941
+ }
1942
+ }
1943
+ if (node.content && !LEAF_BLOCK_TYPES.has(node.type)) {
1944
+ markAllBlockNodesAsPending(node.content, status);
1945
+ }
1946
+ }
1947
+ }
1111
1948
  export function markAllNodesAsPending(doc, status) {
1112
- markLeafBlocksAsPending(doc.content, status);
1949
+ markAllBlockNodesAsPending(doc.content, status);
1113
1950
  }
1114
1951
  /** Read pending doc info from in-memory cache (O(1) instead of disk scan). */
1115
1952
  export function getPendingDocInfo() {
@@ -1135,6 +1972,16 @@ export function getPendingDocInfo() {
1135
1972
  // PERSISTENCE
1136
1973
  // ============================================================================
1137
1974
  function writeToDisk() {
1975
+ // No-op gate: when the in-memory document hasn't been mutated since the
1976
+ // last successful write (or byte-equality skip), bail before any work.
1977
+ // Skips the full serialize + matcher pipeline (~50ms on medium docs), the
1978
+ // sidecar overlay write, the snapshot read+write, and the mtime bump that
1979
+ // would invalidate the doc cache. The existsSync check ensures first-save
1980
+ // of a new file still runs even when version state looks clean.
1981
+ // adr: adr/pending-overlay-model.md
1982
+ if (state.filePath && existsSync(state.filePath) && docVersion === lastSavedDocVersion) {
1983
+ return;
1984
+ }
1138
1985
  ensureDataDir();
1139
1986
  // Capture old forward links BEFORE we overwrite the file — needed by the
1140
1987
  // backlinks engine to know which target docs to refresh when source changes.
@@ -1146,54 +1993,181 @@ function writeToDisk() {
1146
1993
  }
1147
1994
  catch { /* best-effort */ }
1148
1995
  }
1996
+ // Stub graduation: once the doc contains accepted content, it's no longer
1997
+ // a fresh stub. Remove it from the in-memory stub registry so reject-all
1998
+ // can never trigger the cleanup-delete on it.
1999
+ // adr: adr/agent-stub-model.md
2000
+ if (hasAcceptedContent(state.document)) {
2001
+ unmarkAgentStub(activeDocFilename());
2002
+ }
2003
+ // Defensive: never serialize `agentCreated` to disk. The field is dead;
2004
+ // any code reading it would be the bug, not the field's presence.
2005
+ if (state.metadata)
2006
+ stripLegacyAgentCreated(state.metadata);
1149
2007
  let markdown;
1150
2008
  if (isExternalDoc(state.filePath)) {
1151
- // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
2009
+ // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
2010
+ // External docs don't participate in the pending overlay system (the external editor
2011
+ // is the source of truth for the file's structure).
1152
2012
  const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
1153
2013
  markdown = state.originalFrontmatter
1154
2014
  ? `---\n${state.originalFrontmatter}\n---\n\n${body}`
1155
2015
  : body;
1156
2016
  }
1157
2017
  else {
1158
- // Save-time matcher pass (Option B: disk is the source of truth).
2018
+ // Save-time matcher pass + pending overlay split.
1159
2019
  //
1160
- // Read the existing file's frontmatter to recover previousNodes +
1161
- // graveyard, run the matcher against the current TipTap tree, and apply
1162
- // pinned IDs back onto the tree. Without this, type-change and
1163
- // graveyard-restore never fire within a session the editor mints fresh
1164
- // IDs at insert time and the load-time matcher only sees the post-edit
1165
- // state. Memory holds no identity cache; identity always re-derives from
1166
- // disk at the save boundary.
2020
+ // Architectural model: disk is canonical only. Pending state lives in a
2021
+ // sidecar at `_pending/{docId}.json`. We split state.document into:
2022
+ // - canonical: a clone with all pending reverted (matcher operates on this)
2023
+ // - overlay: the extracted pending entries (saved to sidecar)
1167
2024
  //
1168
- // adr: adr/node-identity-matcher.md
2025
+ // The matcher runs on canonical so the on-disk `nodes:` fingerprints match
2026
+ // the on-disk body. Pre-matcher canonical IDs are translated to post-matcher
2027
+ // IDs and the same translation is applied to (a) state.document, so the
2028
+ // in-memory tree stays consistent with disk, and (b) the overlay entries,
2029
+ // so they re-anchor correctly on reload.
2030
+ //
2031
+ // adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
2032
+ const canonical = cloneWithPendingReverted(state.document);
1169
2033
  const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
2034
+ // previousNodes and graveyard are already in rich Fingerprint form —
2035
+ // readPersistedIdentity handles slim-tuple enrichment and legacy
2036
+ // re-fingerprinting before returning. Matcher gets a uniform input
2037
+ // regardless of what's on disk.
2038
+ // adr: adr/node-identity-matcher.md
2039
+ //
2040
+ // newBlocks is computed once and reused by:
2041
+ // (a) the matcher branch below (when there are previous nodes to match)
2042
+ // (b) the enrichment staleness check (always — even on first save)
2043
+ // Hoisted outside the matcher conditional so first-save staleness still
2044
+ // gets the current sentence-hash signal.
2045
+ const newBlocks = tiptapToBlocks(canonical);
1170
2046
  let nextGraveyard = graveyard;
2047
+ const idTranslation = new Map();
1171
2048
  if (previousNodes.length > 0) {
1172
- const newBlocks = tiptapToBlocks(state.document);
1173
- const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
2049
+ const beforeIds = newBlocks.map((b) => b.id);
2050
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard: nextGraveyard });
1174
2051
  const pinnedByPosition = new Map();
1175
2052
  for (const p of matchResult.pinned)
1176
2053
  pinnedByPosition.set(p.position, p.id);
1177
- applyIdsToTiptap(state.document, pinnedByPosition);
2054
+ applyIdsToTiptap(canonical, pinnedByPosition);
1178
2055
  nextGraveyard = matchResult.nextGraveyard;
2056
+ // Build pre→post id translation (canonical's IDs match state.document's
2057
+ // IDs at non-insert positions, since cloneWithPendingReverted preserves
2058
+ // IDs on rewrite/delete/passthrough nodes).
2059
+ for (let i = 0; i < beforeIds.length; i++) {
2060
+ const oldId = beforeIds[i];
2061
+ const newId = pinnedByPosition.get(i);
2062
+ if (oldId && newId && oldId !== newId) {
2063
+ idTranslation.set(oldId, newId);
2064
+ }
2065
+ }
2066
+ // Apply translation to primary state. The matcher renamed IDs on
2067
+ // canonical (above); we need state.canonical, state.overlay's entry
2068
+ // keys (the nodeId fields), and state.document to all see the new IDs.
2069
+ if (idTranslation.size > 0) {
2070
+ applyIdTranslationToDoc(state.canonical, idTranslation);
2071
+ // Translate overlay entry nodeIds (rewrite/delete entries point at
2072
+ // canonical IDs that may have shifted; insert entries have unique
2073
+ // IDs not in the translation map and pass through).
2074
+ const newOverlay = new Map();
2075
+ for (const [nodeId, entry] of state.overlay) {
2076
+ const newNodeId = idTranslation.get(nodeId) ?? nodeId;
2077
+ newOverlay.set(newNodeId, { ...entry, nodeId: newNodeId });
2078
+ }
2079
+ state.overlay = newOverlay;
2080
+ recomputeMerged();
2081
+ }
2082
+ // Broadcast id-rewrites so browser clients converge their TipTap state.
2083
+ if (idRewriteListeners.size > 0 && idTranslation.size > 0) {
2084
+ const rewrites = Array.from(idTranslation, ([oldId, newId]) => ({ oldId, newId }));
2085
+ for (const listener of idRewriteListeners)
2086
+ listener(rewrites);
2087
+ }
2088
+ }
2089
+ // Persist the structured overlay to sidecar. state.overlay IS the overlay
2090
+ // in the new model — no extraction needed.
2091
+ if (state.docId) {
2092
+ saveOverlay(state.docId, Array.from(state.overlay.values()));
2093
+ }
2094
+ // ENRICHMENT STALENESS — reuses the matcher's sentence-hash machinery.
2095
+ // After the matcher pass, harvest current sentence hashes + char count
2096
+ // from the same blocks the matcher just operated on; compare against the
2097
+ // at-enrichment baseline in frontmatter. Flip enrichmentStale=true when
2098
+ // volume or drift thresholds trip. OpenWriter never clears the flag —
2099
+ // that's the agent's job via mark_enriched (Phase 4).
2100
+ //
2101
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2102
+ try {
2103
+ const currentSentences = harvestSentenceHashes(newBlocks);
2104
+ const currentChars = harvestCharCount(newBlocks);
2105
+ const stale = isEnrichmentStale(currentSentences, currentChars, state.metadata);
2106
+ if (stale && state.metadata.enrichmentStale !== true) {
2107
+ state.metadata.enrichmentStale = true;
2108
+ diagLog(`[Enrichment] stale: ${state.filePath}`);
2109
+ }
2110
+ }
2111
+ catch (err) {
2112
+ // Staleness detection is observational, never load-bearing for the save.
2113
+ console.error('[Enrichment] staleness check failed:', err);
1179
2114
  }
1180
2115
  // Pass graveyard through metadata so the serializer can emit it in frontmatter.
1181
2116
  const metaWithGraveyard = nextGraveyard.length > 0
1182
2117
  ? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
1183
2118
  : state.metadata;
1184
- // Checked serializer — verifies the TipTap markdown → TipTap round-trip
1185
- // preserves block shape. Logs to console on drift; never blocks the save.
1186
- const result = tiptapToMarkdownChecked(state.document, state.title, metaWithGraveyard);
2119
+ // Checked serializer — operates on canonical (already pending-reverted).
2120
+ // The serializer no longer emits `meta.pending` (overlay handles that).
2121
+ const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
1187
2122
  markdown = result.markdown;
1188
2123
  }
1189
2124
  if (existsSync(state.filePath)) {
1190
2125
  // Skip write if content is identical (prevents phantom git changes on doc switch)
1191
2126
  try {
1192
2127
  const existing = readFileSync(state.filePath, 'utf-8');
1193
- if (existing === markdown)
2128
+ if (existing === markdown) {
2129
+ // Even on a no-op write, refresh our mtime snapshot so we don't
2130
+ // misread a stale `loadedMtime` as evidence of an external write.
2131
+ try {
2132
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2133
+ }
2134
+ catch { /* best-effort */ }
2135
+ // Mark in-sync at this docVersion so the next save bails at the
2136
+ // top-level gate before re-running serialize. Without this, the
2137
+ // gate would only kick in after a real disk write.
2138
+ lastSavedDocVersion = docVersion;
1194
2139
  return;
2140
+ }
1195
2141
  }
1196
2142
  catch { /* read failed, proceed with write */ }
2143
+ // EXTERNAL-WRITE GUARD: if disk mtime is newer than the mtime we stamped
2144
+ // at load (or our last successful save), an external writer modified the
2145
+ // file out from under us. Blindly writing our in-memory state would
2146
+ // clobber their content silently. Block the write, log, and surface via
2147
+ // sync-status so the agent/user can resolve via reload_from_disk.
2148
+ //
2149
+ // Edge cases handled:
2150
+ // - First save of a new doc: loadedMtime=0, so the guard never fires
2151
+ // (every real file's mtime will be > 0).
2152
+ // - Atomic-write race: writeFileSync momentarily mtime-bumps. We re-
2153
+ // stamp loadedMtime AFTER every successful own write so subsequent
2154
+ // guard checks compare against our own write, not a phantom delta.
2155
+ // - Clock drift: we compare exact ms equality (not >); any change at
2156
+ // all is treated as external. Filesystems guarantee monotonic mtime
2157
+ // per file on the same host so this is safe.
2158
+ if (state.loadedMtime > 0) {
2159
+ try {
2160
+ const diskMtime = statSync(state.filePath).mtimeMs;
2161
+ if (diskMtime !== state.loadedMtime) {
2162
+ console.error(`[State] BLOCKED save: external write detected on ${state.filePath} ` +
2163
+ `(disk mtime ${new Date(diskMtime).toISOString()} != loaded mtime ${new Date(state.loadedMtime).toISOString()}). ` +
2164
+ `Call reload_from_disk to adopt external content, or write_to_pad to re-apply changes on top.`);
2165
+ notifyExternalWriteConflict(state.filePath, diskMtime, state.loadedMtime);
2166
+ return;
2167
+ }
2168
+ }
2169
+ catch { /* stat failed, proceed with save */ }
2170
+ }
1197
2171
  // Safety: don't overwrite a file with substantial content using near-empty content.
1198
2172
  // Prevents save cascades where empty editor state destroys chapter files.
1199
2173
  // Exception: docs with pending changes may legitimately be smaller (agent replaced content).
@@ -1209,6 +2183,15 @@ function writeToDisk() {
1209
2183
  }
1210
2184
  }
1211
2185
  atomicWriteFileSync(state.filePath, markdown);
2186
+ // Re-stamp loadedMtime so the next save's guard compares against our own
2187
+ // most-recent write, not the prior load's mtime.
2188
+ try {
2189
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2190
+ }
2191
+ catch { /* best-effort */ }
2192
+ // Record that disk now matches in-memory at this docVersion. Subsequent
2193
+ // save() calls without further mutations will bail at the top-level gate.
2194
+ lastSavedDocVersion = docVersion;
1212
2195
  // Best-effort version snapshot — never blocks saves
1213
2196
  try {
1214
2197
  snapshotIfNeeded(state.docId, state.filePath);
@@ -1228,14 +2211,17 @@ function writeToDisk() {
1228
2211
  }
1229
2212
  export function save() {
1230
2213
  if (!state.filePath) {
1231
- // First save — assign a file path
2214
+ // First save — assign a file path. Canonicalize at this identity
2215
+ // boundary so cache lookups and watcher subscriptions key on the
2216
+ // same string regardless of how this path was produced.
2217
+ // adr: adr/path-canonicalization.md
1232
2218
  ensureDataDir();
1233
2219
  if (state.title === 'Untitled') {
1234
- state.filePath = tempFilePath();
2220
+ state.filePath = canonicalizePath(tempFilePath());
1235
2221
  state.isTemp = true;
1236
2222
  }
1237
2223
  else {
1238
- state.filePath = filePathForTitle(state.title);
2224
+ state.filePath = canonicalizePath(filePathForTitle(state.title));
1239
2225
  state.isTemp = false;
1240
2226
  }
1241
2227
  }
@@ -1243,6 +2229,9 @@ export function save() {
1243
2229
  }
1244
2230
  export function load() {
1245
2231
  ensureDataDir();
2232
+ // One-time sidecar repair MUST run before any doc loads so the file-walk
2233
+ // loop reads deduped sidecars, not corrupted ones. adr: adr/pending-overlay-model.md
2234
+ repairOverlaysOnStartup();
1246
2235
  // Restore external document registry from disk
1247
2236
  loadExternalDocs();
1248
2237
  // Migrate any .sw.json files to .md
@@ -1268,19 +2257,38 @@ export function load() {
1268
2257
  // Skip empty temp files — prefer a real document
1269
2258
  if (isTemp && isDocEmpty(parsed.document))
1270
2259
  continue;
1271
- state.document = parsed.document;
2260
+ // Route the parsed doc through the primary-state setter — splits into
2261
+ // canonical + overlay (handles legacy in-frontmatter pending) and
2262
+ // recomputes the merged view via state.document.
2263
+ setPrimaryFromMerged(parsed.document);
1272
2264
  state.title = parsed.title;
1273
2265
  state.metadata = parsed.metadata;
2266
+ // Legacy: strip any pre-architectural-fix `agentCreated` field that
2267
+ // survived on disk. The in-memory stub registry is the only authority.
2268
+ stripLegacyAgentCreated(state.metadata);
1274
2269
  state.lastModified = new Date(statSync(file.path).mtimeMs);
1275
- state.filePath = file.path;
2270
+ state.filePath = canonicalizePath(file.path);
1276
2271
  state.isTemp = isTemp;
2272
+ state.loadedMtime = statSync(file.path).mtimeMs;
1277
2273
  // Lazy docId migration: assign if missing, save to persist
1278
2274
  const hadDocId = !!state.metadata.docId;
1279
2275
  state.docId = ensureDocId(state.metadata);
1280
2276
  if (!hadDocId) {
1281
2277
  const md = tiptapToMarkdown(state.document, state.title, state.metadata);
1282
2278
  atomicWriteFileSync(state.filePath, md);
2279
+ try {
2280
+ state.loadedMtime = statSync(state.filePath).mtimeMs;
2281
+ }
2282
+ catch { /* best-effort */ }
1283
2283
  }
2284
+ // Pending overlay merge: rehydrate pending decorations from the sidecar.
2285
+ // For legacy files (parser stamped pending attrs from old `meta.pending`),
2286
+ // capture those into the overlay format and write the sidecar as a one-
2287
+ // time migration. For migrated files (parser stamped nothing because
2288
+ // `meta.pending` is gone from frontmatter), the sidecar is the only
2289
+ // source.
2290
+ // adr: adr/pending-overlay-model.md
2291
+ mergeOverlayOnLoad();
1284
2292
  break;
1285
2293
  }
1286
2294
  catch {
@@ -1290,15 +2298,18 @@ export function load() {
1290
2298
  }
1291
2299
  // If nothing loaded (all files were empty temps or corrupt), start fresh
1292
2300
  if (!state.filePath) {
1293
- state.filePath = tempFilePath();
2301
+ state.filePath = canonicalizePath(tempFilePath());
1294
2302
  state.isTemp = true;
1295
2303
  }
1296
2304
  // Populate pending doc cache from disk (single scan on startup)
1297
2305
  populatePendingCache();
1298
2306
  // Overlay active doc's in-memory state (may have unsaved pending changes)
1299
2307
  updatePendingCacheForActiveDoc();
2308
+ // Subscribe the active-doc watcher so external writes route through the
2309
+ // unified reload pathway. adr: adr/active-doc-watcher.md
2310
+ startActiveDocWatcher();
1300
2311
  // Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
1301
- setAgentLock();
2312
+ setAgentLockGlobal();
1302
2313
  }
1303
2314
  /** Migrate legacy .sw.json files to .md format */
1304
2315
  function migrateSwJsonFiles() {
@@ -1388,9 +2399,13 @@ function cleanupEmptyTempFiles() {
1388
2399
  try {
1389
2400
  const raw = readFileSync(fullPath, 'utf-8');
1390
2401
  const parsed = markdownToTiptap(raw);
1391
- // Keep temp files that have meaningful metadata (templates, pending changes, tags)
2402
+ // Keep temp files that have meaningful metadata (templates, pending changes, tags).
2403
+ // Note: `agentCreated` used to be a disk-frontmatter signal here. Stub status is now
2404
+ // in-memory only — see agentStubFilenames. An empty temp file with no other meaningful
2405
+ // metadata that survived a server restart is by definition no longer a fresh stub and
2406
+ // can be cleaned up.
1392
2407
  const meta = parsed.metadata || {};
1393
- const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
2408
+ const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
1394
2409
  || (Array.isArray(meta.tags) && meta.tags.length > 0);
1395
2410
  if (isDocEmpty(parsed.document) && !hasMetadata) {
1396
2411
  unlinkSync(fullPath);
@@ -1540,6 +2555,13 @@ export function removeDocTag(filename, tag) {
1540
2555
  /**
1541
2556
  * Save a browser doc-update to a specific file on disk.
1542
2557
  * Used when the browser sends a doc-update for a non-active document (race condition guard).
2558
+ *
2559
+ * Disk gets canonical (pending stripped by serialization). Any pending attrs
2560
+ * carried on `doc` (transferred from disk) are persisted to the overlay
2561
+ * sidecar in the same pass. Without the symmetric overlay save, pending
2562
+ * content would vanish silently between the strip-during-serialize and the
2563
+ * disk write.
2564
+ * adr: adr/pending-overlay-model.md
1543
2565
  */
1544
2566
  export function saveDocToFile(filename, doc) {
1545
2567
  const targetPath = resolveDocPath(filename);
@@ -1563,6 +2585,11 @@ export function saveDocToFile(filename, doc) {
1563
2585
  markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
1564
2586
  }
1565
2587
  atomicWriteFileSync(targetPath, markdown);
2588
+ const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
2589
+ if (docId) {
2590
+ const overlay = extractOverlay(doc);
2591
+ saveOverlay(docId, overlay);
2592
+ }
1566
2593
  }
1567
2594
  catch { /* best-effort */ }
1568
2595
  }
@@ -1597,9 +2624,15 @@ export function setAutoAcceptOnFile(filename, enabled) {
1597
2624
  }
1598
2625
  /**
1599
2626
  * Strip pending attrs from a specific file on disk (not the active document).
1600
- * Optionally clears agentCreated metadata (on accept).
2627
+ *
2628
+ * The `_legacyClearAgentCreated` parameter is preserved for callsite-signature
2629
+ * stability but no longer does anything meaningful. Stub status is in-memory
2630
+ * only — there is no `agentCreated` field to clear on disk. The on-load
2631
+ * legacy-strip handles any residual occurrences from pre-architectural-fix
2632
+ * files.
2633
+ * adr: adr/agent-stub-model.md
1601
2634
  */
1602
- export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
2635
+ export function stripPendingAttrsFromFile(filename, _legacyClearAgentCreated) {
1603
2636
  const targetPath = resolveDocPath(filename);
1604
2637
  if (!existsSync(targetPath))
1605
2638
  return;
@@ -1621,9 +2654,9 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1621
2654
  }
1622
2655
  }
1623
2656
  strip(parsed.document.content);
1624
- if (clearAgentCreated && parsed.metadata.agentCreated) {
1625
- delete parsed.metadata.agentCreated;
1626
- }
2657
+ // Belt-and-suspenders: strip any legacy on-disk agentCreated (e.g. an
2658
+ // old file that hasn't been re-saved since the migration).
2659
+ stripLegacyAgentCreated(parsed.metadata);
1627
2660
  let markdown;
1628
2661
  if (isExternalDoc(targetPath)) {
1629
2662
  const body = tiptapToBody(parsed.document);
@@ -1635,6 +2668,12 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1635
2668
  markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1636
2669
  }
1637
2670
  atomicWriteFileSync(targetPath, markdown);
2671
+ // Pending was just cleared on disk; the sidecar overlay must go too,
2672
+ // otherwise the next load would re-apply stale pending entries.
2673
+ // adr: adr/pending-overlay-model.md
2674
+ const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
2675
+ if (docId)
2676
+ deleteOverlay(docId);
1638
2677
  removePendingCacheEntry(filename);
1639
2678
  }
1640
2679
  catch { /* best-effort */ }
@@ -1658,10 +2697,47 @@ export function countPending(nodes) {
1658
2697
  return count;
1659
2698
  }
1660
2699
  /** Write a mutated doc back to disk and update the pending cache. */
2700
+ /** Write a mutated doc back to disk and update the pending cache.
2701
+ *
2702
+ * Disk gets canonical (pending stripped by `tiptapToMarkdown`). The
2703
+ * overlay sidecar gets the extracted pending entries — without this,
2704
+ * any pending content on `doc` vanishes between strip and disk write.
2705
+ * This mirrors writeToDisk's active-doc path; the foundation commit
2706
+ * established the contract there but missed this non-active-doc
2707
+ * callsite. Without symmetric overlay save, populate_document /
2708
+ * write_to_pad on a non-active doc silently dropped pending content.
2709
+ * adr: adr/pending-overlay-model.md */
1661
2710
  function flushDocToFile(filename, doc, title, metadata) {
1662
2711
  const targetPath = resolveDocPath(filename);
2712
+ // Enrichment staleness — same signal as writeToDisk, but flushDocToFile
2713
+ // bypasses the matcher entirely so we harvest sentence hashes directly.
2714
+ // Measure the canonical (pending-reverted) view since that's what lands on
2715
+ // disk; pending overlay content rides in the sidecar and isn't part of the
2716
+ // doc's "published" content for enrichment purposes. External docs skip —
2717
+ // they don't participate in the enrichment graph.
2718
+ // adr: see brief 2026-05-18-frontmatter-enrichment-system
2719
+ if (!isExternalDoc(targetPath)) {
2720
+ try {
2721
+ const canonical = cloneWithPendingReverted(doc);
2722
+ const blocks = tiptapToBlocks(canonical);
2723
+ const currentSentences = harvestSentenceHashes(blocks);
2724
+ const currentChars = harvestCharCount(blocks);
2725
+ const stale = isEnrichmentStale(currentSentences, currentChars, metadata);
2726
+ if (stale && metadata.enrichmentStale !== true) {
2727
+ metadata.enrichmentStale = true;
2728
+ }
2729
+ }
2730
+ catch (err) {
2731
+ console.error('[Enrichment] staleness check (flush) failed:', err);
2732
+ }
2733
+ }
1663
2734
  const markdown = tiptapToMarkdown(doc, title, metadata);
1664
2735
  atomicWriteFileSync(targetPath, markdown);
2736
+ const docId = (metadata && typeof metadata.docId === 'string') ? metadata.docId : '';
2737
+ if (docId) {
2738
+ const overlay = extractOverlay(doc);
2739
+ saveOverlay(docId, overlay);
2740
+ }
1665
2741
  setPendingCacheEntry(filename, countPending(doc.content));
1666
2742
  }
1667
2743
  export function populateDocumentFile(filename, doc) {
@@ -1762,8 +2838,12 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1762
2838
  return { success: false, error: `Node ${nodeId} not found` };
1763
2839
  const originalNode = found.parent[found.index];
1764
2840
  const result = applyTextEditsToNode(originalNode, edits);
1765
- if (!result)
1766
- return { success: false, error: 'No edits matched' };
2841
+ if (!result) {
2842
+ const nodeText = extractText(originalNode.content || []);
2843
+ const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
2844
+ const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
2845
+ return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
2846
+ }
1767
2847
  const autoAccept = isAutoAcceptActive(filename, metadata);
1768
2848
  // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1769
2849
  // since the change commits directly.