openwriter 0.19.0 → 0.20.1

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.
@@ -14,7 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
16
16
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
17
- import { extractForwardLinks } from './backlinks.js';
17
+ import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
18
18
  import { logger, generateRequestId, withRequestId } from './logger.js';
19
19
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
20
20
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
@@ -567,6 +567,28 @@ export const TOOL_REGISTRY = [
567
567
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
568
568
  markAllNodesAsPending(doc, 'insert');
569
569
  }
570
+ // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
571
+ // updateDocument(doc) overwrites state.canonical wholesale — without this
572
+ // merge, the create_document → populate_document sequence loses the stub's
573
+ // auto-generated trailing paragraph from canonical. When the browser later
574
+ // accepts the inserts and sends a doc-update with its TipTap-rendered tree
575
+ // (which also has a trailing empty paragraph, but a different ID), the
576
+ // save-time matcher classifies the stub's original trailing as deleted →
577
+ // graveyard, while the freshly added inserts have no previousNodes match.
578
+ // Cascading state corruption observed in live test 2026-05-22.
579
+ const existingCanonical = getCanonical();
580
+ if (existingCanonical?.content?.length) {
581
+ const incomingIds = new Set(doc.content
582
+ .map((n) => n?.attrs?.id)
583
+ .filter((id) => typeof id === 'string'));
584
+ const preserved = existingCanonical.content.filter((n) => {
585
+ const id = n?.attrs?.id;
586
+ return id && !incomingIds.has(id);
587
+ });
588
+ if (preserved.length > 0) {
589
+ doc.content = [...doc.content, ...preserved];
590
+ }
591
+ }
570
592
  updateDocument(doc);
571
593
  updatePendingCacheForActiveDoc();
572
594
  save();
@@ -1687,115 +1709,69 @@ export const TOOL_REGISTRY = [
1687
1709
  },
1688
1710
  {
1689
1711
  name: 'link_to',
1690
- description: 'Wrap anchor text in a source doc with a doc: link pointing at another doc. Operates in place on the block containing the anchor text never creates a duplicate paragraph. Optionally target a specific paragraph (target_node_id) for paragraph-level navigation, with an optional quote for scroll-anchor fallback. The on-save backlinks pipeline then auto-updates the target doc\'s frontmatter `backlinks` field so this single tool call creates both the forward link and the backlink. Use after writing prose to cross-reference concepts: agent writes about "territorial imperative" then calls link_to to point that phrase at the canonical concept doc.',
1712
+ description: 'Declare a structural doc-to-doc connection. Writes target_doc_id into the source doc\'s `references:` frontmatter array. Body markdown is NEVER mutatedthis is metadata, not prose. Idempotent: calling twice with the same source/target is a no-op. The inbound list on the target is computed live from the inverse of every doc\'s referencesno stored derived field. v0.20.0 breaking change: dropped `text`, `target_node_id`, and `quote` parameters; connections are structural, not anchored to prose. Legacy prose `doc:` links continue to render and auto-populate `references` on save (backward compat).',
1691
1713
  schema: {
1692
- text: z.string().describe('Anchor text in the source doc to wrap with the link. Exact substring match. First UNLINKED occurrence wins — calling link_to N times with the same anchor wraps N distinct occurrences, skipping ones already linked to the same target.'),
1693
- source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc containing the anchor text. NOT the active doc — must be explicit so user-driven navigation in the browser can\'t silently change the target.'),
1694
- target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
1695
- target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
1696
- quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
1714
+ source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc declaring the connection.'),
1715
+ target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the connection points AT.'),
1697
1716
  },
1698
- handler: async ({ text, source_doc_id, target_doc_id, target_node_id, quote }) => {
1717
+ handler: async ({ source_doc_id, target_doc_id }) => {
1699
1718
  const sourceFilename = resolveDocId(source_doc_id);
1700
1719
  if (!sourceFilename) {
1701
1720
  return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
1702
1721
  }
1703
- // Build the href in canonical doc:DOCID#NODEID?q=quote form so we can also
1704
- // detect "this text is already wrapped with THIS link" and skip it.
1705
- let href = `doc:${target_doc_id}`;
1706
- if (target_node_id)
1707
- href += `#${target_node_id}`;
1708
- if (quote)
1709
- href += `?q=${encodeURIComponent(quote)}`;
1710
- // Load the source doc from in-memory state if it's active, from disk
1711
- // otherwise. Explicit source dispatch prevents the active-doc race where
1712
- // a user click in the browser silently changes which doc gets edited.
1722
+ const targetFilename = resolveDocId(target_doc_id);
1723
+ if (!targetFilename) {
1724
+ return { content: [{ type: 'text', text: `target_doc_id "${target_doc_id}" not found. Use list_documents or search_docs to find the right docId.` }] };
1725
+ }
1726
+ if (source_doc_id === target_doc_id) {
1727
+ return { content: [{ type: 'text', text: `Cannot link a document to itself (source_doc_id and target_doc_id are both "${source_doc_id}").` }] };
1728
+ }
1729
+ // Load source's current references (active doc in-memory; otherwise disk).
1713
1730
  const sourceIsActive = sourceFilename === getActiveFilename();
1714
- let sourceDoc;
1731
+ let currentReferences;
1715
1732
  if (sourceIsActive) {
1716
- sourceDoc = getDocument();
1733
+ const meta = getMetadata();
1734
+ currentReferences = Array.isArray(meta?.references) ? [...meta.references] : [];
1717
1735
  }
1718
1736
  else {
1719
- const cached = getCachedDocument(resolveDocPath(sourceFilename));
1720
- if (cached) {
1721
- sourceDoc = cached.document;
1722
- }
1723
- else {
1724
- try {
1725
- const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
1726
- sourceDoc = markdownToTiptap(raw).document;
1727
- }
1728
- catch (err) {
1729
- return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
1730
- }
1731
- }
1737
+ const fm = readFrontmatter(sourceFilename);
1738
+ currentReferences = Array.isArray(fm?.data?.references) ? [...fm.data.references] : [];
1732
1739
  }
1733
- // Locate the first block containing the anchor text WHERE the text is
1734
- // not already entirely wrapped with a link to the same href. This makes
1735
- // link_to idempotent and lets repeat calls wrap successive occurrences.
1736
- let sourceNodeId = null;
1737
- let totalOccurrences = 0;
1738
- let alreadyLinkedOccurrences = 0;
1739
- function isTextAlreadyLinked(nodeContent) {
1740
- // Concatenate text from inline children that have a link mark matching href
1741
- let linkedText = '';
1742
- for (const child of nodeContent) {
1743
- if (child.type !== 'text' || !child.text)
1744
- continue;
1745
- const marks = child.marks || [];
1746
- const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
1747
- if (hasMatchingLink)
1748
- linkedText += child.text;
1749
- }
1750
- return linkedText.includes(text);
1740
+ // Idempotent: target already declared no-op.
1741
+ if (currentReferences.includes(target_doc_id)) {
1742
+ return { content: [{ type: 'text', text: JSON.stringify({
1743
+ success: true,
1744
+ sourceDocId: source_doc_id,
1745
+ targetDocId: target_doc_id,
1746
+ alreadyReferenced: true,
1747
+ references: currentReferences,
1748
+ }) }] };
1751
1749
  }
1752
- function walk(nodes) {
1753
- if (sourceNodeId)
1754
- return;
1755
- for (const node of nodes) {
1756
- if (sourceNodeId)
1757
- return;
1758
- if (Array.isArray(node.content)) {
1759
- const blockText = node.content.map((c) => c.text || '').join('');
1760
- if (node.attrs?.id && blockText.includes(text)) {
1761
- totalOccurrences++;
1762
- if (isTextAlreadyLinked(node.content)) {
1763
- alreadyLinkedOccurrences++;
1764
- walk(node.content);
1765
- continue;
1766
- }
1767
- sourceNodeId = node.attrs.id;
1768
- return;
1769
- }
1770
- walk(node.content);
1771
- }
1772
- }
1750
+ // Append + dedup via Set round-trip.
1751
+ const newReferences = Array.from(new Set([...currentReferences, target_doc_id]));
1752
+ if (sourceIsActive) {
1753
+ // Active doc: mutate state.metadata and let save() persist the frontmatter.
1754
+ // save()'s writeToDisk path invalidates the backlinks cache.
1755
+ setMetadata({ references: newReferences });
1756
+ save();
1757
+ broadcastMetadataChanged(getMetadata());
1773
1758
  }
1774
- walk(sourceDoc.content);
1775
- if (!sourceNodeId) {
1776
- if (totalOccurrences > 0 && totalOccurrences === alreadyLinkedOccurrences) {
1777
- return { content: [{ type: 'text', text: `Anchor text "${text}" found in source doc but all ${totalOccurrences} occurrence(s) are already linked to ${href}. Nothing to do.` }] };
1759
+ else {
1760
+ // Non-active doc: write frontmatter directly, preserving body verbatim.
1761
+ const fm = readFrontmatter(sourceFilename);
1762
+ if (!fm) {
1763
+ return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}" frontmatter.` }] };
1778
1764
  }
1779
- return { content: [{ type: 'text', text: `Anchor text "${text}" not found in source doc "${source_doc_id}" (${sourceFilename}). Use search_docs or read_pad to verify.` }] };
1780
- }
1781
- // Apply the link mark in place. Dispatch by active vs non-active so the
1782
- // edit always lands in the right doc — never silently in whatever doc
1783
- // happens to be foregrounded in the browser.
1784
- const editResult = sourceIsActive
1785
- ? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
1786
- : applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
1787
- if (!editResult.success) {
1788
- return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
1765
+ const merged = { ...fm.data, references: newReferences };
1766
+ writeFrontmatter(sourceFilename, merged);
1767
+ invalidateDocCache(resolveDocPath(sourceFilename));
1768
+ invalidateBacklinksCache();
1789
1769
  }
1790
- if (sourceIsActive)
1791
- save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
1792
1770
  return { content: [{ type: 'text', text: JSON.stringify({
1793
1771
  success: true,
1794
1772
  sourceDocId: source_doc_id,
1795
- sourceFilename,
1796
- nodeId: sourceNodeId,
1797
- href,
1798
- ...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
1773
+ targetDocId: target_doc_id,
1774
+ references: newReferences,
1799
1775
  }) }] };
1800
1776
  },
1801
1777
  },
@@ -1847,7 +1823,7 @@ export const TOOL_REGISTRY = [
1847
1823
  },
1848
1824
  {
1849
1825
  name: 'get_graph',
1850
- description: 'Return forward links + backlinks for a doc — the crawl primitive for cross-doc context retrieval. Forward links extracted from the doc body, backlinks read from the doc\'s frontmatter (maintained by the on-save backlinks pipeline). Optional depth walks neighbors recursively (cap 3).',
1826
+ description: 'Return forward + inbound connections for a doc — the crawl primitive for cross-doc context retrieval. Forward connections read from the doc\'s `references:` frontmatter (structural data, v0.20). Inbound computed live by scanning every doc\'s references for entries pointing at this one (no stored derived field). Optional depth walks neighbors recursively (cap 3). v0.20.0 breaking change: edges are doc-to-doc; per-paragraph granularity (from_node/to_node/anchor text) was dropped — that was an artifact of the prose-link model.',
1851
1827
  schema: {
1852
1828
  docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
1853
1829
  depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
@@ -1867,28 +1843,18 @@ export const TOOL_REGISTRY = [
1867
1843
  catch {
1868
1844
  return;
1869
1845
  }
1870
- const forward = extractForwardLinks(target.document, id);
1871
- const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
1846
+ const forward = Array.isArray(target.metadata.references) ? target.metadata.references : [];
1847
+ const backlinks = computeBacklinksFor(id);
1872
1848
  nodes.push({
1873
1849
  docId: id,
1874
1850
  title: target.title,
1875
- forward: forward.map((l) => ({
1876
- text: l.text,
1877
- from_node: l.from_node,
1878
- to_doc: l.to_doc,
1879
- ...(l.to_node ? { to_node: l.to_node } : {}),
1880
- })),
1881
- backlinks: backlinks.map((b) => ({
1882
- text: b.text,
1883
- from_doc: b.from_doc,
1884
- from_node: b.from_node,
1885
- ...(b.to_node ? { to_node: b.to_node } : {}),
1886
- })),
1851
+ forward: forward.map((to_doc) => ({ to_doc })),
1852
+ backlinks: backlinks.map((b) => ({ from_doc: b.from_doc })),
1887
1853
  });
1888
1854
  if (hopsLeft > 0) {
1889
1855
  const neighbors = new Set();
1890
- for (const l of forward)
1891
- neighbors.add(l.to_doc);
1856
+ for (const to_doc of forward)
1857
+ neighbors.add(to_doc);
1892
1858
  for (const b of backlinks)
1893
1859
  neighbors.add(b.from_doc);
1894
1860
  for (const n of neighbors) {
@@ -10,7 +10,7 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
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
- import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
13
+ import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } 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';
@@ -434,6 +434,15 @@ function stripLegacyAgentCreated(metadata) {
434
434
  if (metadata && 'agentCreated' in metadata)
435
435
  delete metadata.agentCreated;
436
436
  }
437
+ /** Strip the legacy `backlinks` field. v0.19 stored derived inbound edges in
438
+ * frontmatter; v0.20 computes them live from the inverse of `references`
439
+ * across the workspace. Any save that visits a doc with the stale field drops
440
+ * it — lazy migration. No data loss because the new `references` field on
441
+ * source docs is the authoritative inbound source; we can always recompute. */
442
+ function stripLegacyBacklinks(metadata) {
443
+ if (metadata && 'backlinks' in metadata)
444
+ delete metadata.backlinks;
445
+ }
437
446
  function persistExternalDocs() {
438
447
  try {
439
448
  atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
@@ -1990,16 +1999,6 @@ function writeToDisk() {
1990
1999
  return;
1991
2000
  }
1992
2001
  ensureDataDir();
1993
- // Capture old forward links BEFORE we overwrite the file — needed by the
1994
- // backlinks engine to know which target docs to refresh when source changes.
1995
- // Skip for external docs (they don't participate in the doc graph).
1996
- let oldForwardLinks = [];
1997
- if (!isExternalDoc(state.filePath) && state.docId) {
1998
- try {
1999
- oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
2000
- }
2001
- catch { /* best-effort */ }
2002
- }
2003
2002
  // Stub graduation: once the doc contains accepted content, it's no longer
2004
2003
  // a fresh stub. Remove it from the in-memory stub registry so reject-all
2005
2004
  // can never trigger the cleanup-delete on it.
@@ -2009,8 +2008,10 @@ function writeToDisk() {
2009
2008
  }
2010
2009
  // Defensive: never serialize `agentCreated` to disk. The field is dead;
2011
2010
  // any code reading it would be the bug, not the field's presence.
2012
- if (state.metadata)
2011
+ if (state.metadata) {
2013
2012
  stripLegacyAgentCreated(state.metadata);
2013
+ stripLegacyBacklinks(state.metadata);
2014
+ }
2014
2015
  let markdown;
2015
2016
  if (isExternalDoc(state.filePath)) {
2016
2017
  // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
@@ -2204,16 +2205,27 @@ function writeToDisk() {
2204
2205
  snapshotIfNeeded(state.docId, state.filePath);
2205
2206
  }
2206
2207
  catch { /* ignore */ }
2207
- // Backlinks update: refresh target docs' backlinks frontmatter if source's
2208
- // forward links changed. Best-effort never blocks the save it follows.
2208
+ // Auto-sync references from prose: legacy `doc:` prose links still render
2209
+ // (PadLink extension), but the graph/crawl/backlinks-panel read the
2210
+ // structural `references:` field. After every save, scan the body for
2211
+ // prose links and merge their targets into references — backward compat
2212
+ // without forcing rewrites. Then invalidate the live-backlinks cache so
2213
+ // the next /api/backlinks/:docId call sees the fresh inverse.
2214
+ // Best-effort — never blocks the save it follows.
2209
2215
  if (!isExternalDoc(state.filePath) && state.docId) {
2210
2216
  try {
2211
- const newForwardLinks = extractForwardLinks(state.document, state.docId);
2212
- updateBacklinksForSource(state.docId, newForwardLinks, oldForwardLinks);
2217
+ const sync = syncReferencesFromProse(state.docId, state.document, state.metadata || {});
2218
+ if (sync && state.metadata) {
2219
+ state.metadata.references = sync.newReferences;
2220
+ // Second tiny write: re-persist frontmatter only (body already on disk).
2221
+ const filename = state.filePath.split(/[/\\]/).pop() || '';
2222
+ writeFrontmatter(filename, state.metadata);
2223
+ }
2213
2224
  }
2214
2225
  catch (err) {
2215
- console.error('[State] backlinks update failed:', err);
2226
+ console.error('[State] references auto-sync failed:', err);
2216
2227
  }
2228
+ invalidateBacklinksCache();
2217
2229
  }
2218
2230
  }
2219
2231
  export function save() {
@@ -2756,6 +2768,28 @@ export function populateDocumentFile(filename, doc) {
2756
2768
  if (!isAutoAcceptActive(filename, parsed.metadata)) {
2757
2769
  markAllNodesAsPending(doc, 'insert');
2758
2770
  }
2771
+ // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
2772
+ // flushDocToFile writes `doc` directly — it does NOT merge with the existing
2773
+ // parsed.document on disk. Without this merge step, the stub's auto-generated
2774
+ // trailing paragraph falls out of canonical, the matcher's `previousNodes`
2775
+ // for any subsequent save no longer references it, and a follow-up Accept All
2776
+ // doc-update can find itself with no matching previousNodes to anchor against.
2777
+ // Cascading: the matcher classifies the newly accepted inserts as deletions
2778
+ // (orphaned from the empty previousNodes set), they go to graveyard, the disk
2779
+ // body ends up empty.
2780
+ // Mirrors the active-doc fix in mcp.ts:populate_document.
2781
+ if (parsed.document?.content?.length) {
2782
+ const incomingIds = new Set(doc.content
2783
+ .map((n) => n?.attrs?.id)
2784
+ .filter((id) => typeof id === 'string'));
2785
+ const preserved = parsed.document.content.filter((n) => {
2786
+ const id = n?.attrs?.id;
2787
+ return id && !incomingIds.has(id);
2788
+ });
2789
+ if (preserved.length > 0) {
2790
+ doc.content = [...doc.content, ...preserved];
2791
+ }
2792
+ }
2759
2793
  flushDocToFile(filename, doc, parsed.title, parsed.metadata);
2760
2794
  const pendingCount = countPending(doc.content);
2761
2795
  const text = extractText(doc.content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.7.6"
19
+ version: "0.10.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -31,12 +31,12 @@ You are a writing collaborator. You read documents and make edits **exclusively
31
31
  2. **The terminal is for discussion only.** Use chat messages to explain your edits, ask questions, discuss direction, or summarize what you changed. Never use it as the writing surface.
32
32
  3. **Name every document.** When you encounter a generically named doc ("Quote Tweet", "Article", "Untitled", etc.), rename it based on its content before proceeding. Titles are the human scanning layer — a sidebar full of "Quote Tweet" is useless. Use `rename_item` with the docId. Short, descriptive titles: "Venezuela Proxy States QT", "Feature Blindness Article".
33
33
  4. **Metadata first.** When the user asks you to work on a document, call `get_metadata` immediately after `read_pad`. Tweet docs store the parent URL in `tweetContext.url`, article docs store context in `articleContext`. **Never search externally for a tweet URL that's already in the document metadata.** This prevents wasting paid API calls on information you already have.
34
- 5. **Dispatch the enrichment minion when openwriter says so.** OpenWriter surfaces stale enrichment two ways: (a) `ENRICHMENT_STATUS: N docs need enrichment ...` in the MCP server's session-start instructions; (b) a `⚠ N docs need enrichment spawn the enrichment minion` footer on `list_documents`, `list_workspaces`, and `get_workspace_structure` responses. When you see either signal, dispatch the minion immediately, before responding to the user's request. Use the Agent tool with `subagent_type: "openwriter-enrichment-minion"` that subagent is installed by `npx openwriter install-skill` at `~/.claude/agents/openwriter-enrichment-minion.md` and ships with its tool allowlist restricted to the 4 MCP tools it needs (no full registry overhead). Pass a one-line prompt like `"Enrich all currently stale openwriter docs."` the subagent has its full procedure encoded already. It runs autonomously, calls `list_dirty_docs` `read_pad` per doc bulk `mark_enriched`, and returns a short summary.
34
+ 5. **Dispatch the enrichment minion when openwriter says so.** OpenWriter surfaces stale enrichment two ways: (a) `ENRICHMENT_STATUS: ...` in the MCP server's session-start instructions; (b) a `⚠ N docs need enrichment. Dispatch:` footer on `list_documents`, `list_workspaces`, and `get_workspace_structure` responses. Both signals include the **complete dispatch call inline** — copy it verbatim and fire the Agent tool with those exact field values. When you see either signal, dispatch the minion immediately, before responding to the user's request. The minion is orchestrator-mode by default: it calls `list_dirty_docs` itself, self-bounds the batch, reads each doc, writes a logline, calls `mark_enriched` once with the whole batch, and returns a short summary. **v0.19.0 schema:** the minion writes ONE field `logline`. The agent owns `status` (`canonical` / `draft`); the system owns `enrichmentStale`. The legacy fields `domain`, `concepts`, and `docRole` were dropped. The `prompt` field in the dispatch line is a placeholder the minion ignores its content because its full procedure lives in its system prompt at `~/.claude/agents/openwriter-enrichment-minion.md`.
35
35
 
36
36
  **Surfacing to the user:** treat enrichment like the inbox — a maintenance reflex, not a feature they have to ask for. Phrasing depends on context:
37
37
 
38
38
  - **First time in a session, small batch (N ≤ 5):** silent dispatch + one-line aside in your response: "Enriched 3 docs in the background. Now, ..."
39
- - **First time in a session, medium batch (5 < N ≤ 20):** brief explanation on first surface: "OpenWriter just refreshed loglines and concepts on 12 docs in the background. Now, ..." Sets expectations once; subsequent runs can stay silent.
39
+ - **First time in a session, medium batch (5 < N ≤ 20):** brief explanation on first surface: "OpenWriter just refreshed loglines on 12 docs in the background. Now, ..." Sets expectations once; subsequent runs can stay silent.
40
40
  - **First time in a session, large batch (N > 20):** give the user a heads-up BEFORE dispatching: "OpenWriter detected 47 docs that haven't been summarized yet — first-time setup. Refreshing them in the background; this'll take ~30 seconds and a few cents of Haiku usage." Then dispatch and report when done.
41
41
  - **Very large batch (N > 30):** one minion can't get through that many in reasonable wall time. Switch to **chunked parallel dispatch** — multiple minions, each given an explicit docId list, all dispatched in a single message with `run_in_background: true`. Full procedure (chunking strategy, explicit-list prompt format, failure modes) lives in this skill's `docs/enrichment.md`. Read that doc before dispatching anything over 30 docs.
42
42
 
@@ -150,8 +150,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
150
150
  | `list_workspaces` | List all workspaces with title and doc count |
151
151
  | `create_workspace` | Create a new workspace |
152
152
  | `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
153
- | `get_workspace_structure` | Get full workspace tree: containers, docs, enrichment (logline/domain/docRole per doc), workspace-level vocab/schema, plus context (characters, settings, rules) |
154
- | `get_item_context` | Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline, domain, concepts, docRole, status, enrichmentStale) |
153
+ | `get_workspace_structure` | Get full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), workspace-level vocab/schema, plus context (characters, settings, rules) |
154
+ | `get_item_context` | Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
155
155
  | `update_workspace_context` | Update workspace context (characters, settings, rules) |
156
156
 
157
157
  ### Workspace Organization
@@ -165,15 +165,29 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
165
165
  | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
166
166
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
167
167
 
168
- ### Enrichment (frontmatter classification + crawlability)
168
+ ### Enrichment (three-field schema v0.19.0)
169
169
 
170
- OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-hash Jaccard drift, character-count volume ratio) on every save and stamps `enrichmentStale: true`. The agent's job is to dispatch the enrichment minion (see firm rule 5 + `docs/enrichment.md` in this skill) to refresh the loglines, domain, concepts, docRole, and status fields.
170
+ OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-hash Jaccard drift, character-count volume ratio) on every save and stamps `enrichmentStale: true`. The agent's job is to dispatch the enrichment minion (see firm rule 5 + `docs/enrichment.md` in this skill) to refresh the logline.
171
+
172
+ **The three-field schema** — each field has exactly one owner:
173
+
174
+ | Field | Owner | Set how |
175
+ |-------|-------|---------|
176
+ | `logline` | LLM (minion) | `mark_enriched({ docs: [{ docId, logline }] })` |
177
+ | `status` (`canonical` / `draft`) | Agent | `create_document({ status })` on create; `set_metadata({ status })` on lifecycle change |
178
+ | `enrichmentStale` | System | OpenWriter sets on save; minion clears on `mark_enriched` |
179
+
180
+ **Lifecycle convention for `status`:**
181
+ - Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
182
+ - Flip to `canonical` when the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth).
183
+ - Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
184
+ - The common crawl pattern is `crawl({ status: "canonical" })` — that's the trusted-shelf query.
171
185
 
172
186
  | Tool | Key Params | Description |
173
187
  |------|-----------|-------------|
174
188
  | `list_dirty_docs` | `workspaceFile?` | List docs that need enrichment (never enriched OR explicitly flagged stale). Returns identity + reason only — no bodies. Optionally scoped to one workspace. Docs in opted-out workspaces (`enrichmentDisabled: true`) are excluded. |
175
- | `mark_enriched` | `docs: [{docId, logline?, domain?, concepts?, docRole?, status?}]` | Stamp one or more docs as freshly enriched. OpenWriter auto-computes baselines (`lastEnrichedAt`, `lastEnrichedCharCount`, `lastEnrichedSentences`) and clears `enrichmentStale`. The minion calls this once at the end of its run with the full batch. |
176
- | `crawl` | `workspaceFile?`, `domain?`, `tags?`, `concepts?`, `docRole?`, `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~150 tokens per doc, no bodies. Pick which bodies to actually read after crawling. |
189
+ | `mark_enriched` | `docs: [{docId, logline}]` | Stamp one or more docs as freshly enriched. **Strict schema** — passing `domain` / `concepts` / `docRole` / `status` fails validation. OpenWriter auto-computes baselines (`lastEnrichedAt`, `lastEnrichedCharCount`, `lastEnrichedSentences`), clears `enrichmentStale`, and retires legacy fields from frontmatter. The minion calls this once at the end of its run with the full batch. |
190
+ | `crawl` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies. v0.19.0 dropped `domain` / `concepts` / `docRole` filters (their fields had no authority discipline); `status` is the replacement axis for the common load-bearing-vs-working query. |
177
191
 
178
192
  ### Comments
179
193
 
@@ -275,9 +289,12 @@ create_document({
275
289
 
276
290
  - **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
277
291
  - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
278
- - Both are optional — omit for standalone docs outside any workspace.
292
+ - **`afterId`** (string, optional)docId (8-char hex) or containerId to place the new doc immediately after. Omit and the doc lands at the **bottom** of its parent (the default since 0.18.0, matching the ascending-order convention: oldest at top, newest at bottom). Use `afterId` when you need surgical placement — e.g. inserting a new chapter doc immediately after the chapter's Beats doc.
293
+ - All three are optional — omit `workspace` for standalone docs outside any workspace.
294
+
295
+ This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace. The default-bottom landing also eliminates the need for a follow-up `move_item` pass to fix sidebar order after every create — the doc lands in convention position the first time.
279
296
 
280
- This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
297
+ `create_container` accepts the same `afterId` parameter with identical semantics — new containers default to the bottom of their parent and can be precisely placed via `afterId`. The Drafts sub-container that goes under every chapter container, for example, can be created with `afterId` set to the chapter's Research Notes docId so it lands at the very bottom in one call.
281
298
 
282
299
  ### Batched Creation (multiple docs at once)
283
300
 
@@ -299,7 +316,7 @@ When creating **two or more documents together** — a tweet thread saved as sep
299
316
  **Rules:**
300
317
  - Each write in the batch gets its own sidebar spinner keyed to its filename — a spinner only clears when you `populate_document` that specific `docId`
301
318
  - Spinners persist across app refreshes (server-side registry)
302
- - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`
319
+ - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`/`afterId`
303
320
  - `reply` / `quote` types still require `url`
304
321
  - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
305
322