openwriter 0.23.0 → 0.24.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.
@@ -10,10 +10,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
12
  import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
13
- import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, } from './state.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
+ import { outline, peek, searchInDoc } from './peek-outline.js';
15
16
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
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 { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions } from './documents.js';
17
18
  import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
18
19
  import { logger, generateRequestId, withRequestId } from './logger.js';
19
20
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
@@ -348,9 +349,46 @@ export const TOOL_REGISTRY = [
348
349
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
349
350
  },
350
351
  },
352
+ {
353
+ name: 'outline_doc',
354
+ description: 'Get the structural skeleton of a doc — heading tree by default, optionally drill into one section. The right tool to orient on a long doc before reading anything. Heading tree only is ~5 tokens per heading vs read_pad\'s ~50–100 per block. For 1000-node docs the entire outline is typically <500 tokens. Drill into a section with `underHeading: nodeId` to see one section\'s block previews (~15 tokens/block). Falls back to top-level block previews when the doc has no headings. Use after `search_docs` finds the doc but before `read_pad` or `peek_doc`.',
355
+ schema: {
356
+ docId: z.string().describe('Target document by docId (8-char hex).'),
357
+ underHeading: z.string().optional().describe('Heading nodeId — drill into that section and return all blocks beneath it (until the next same-or-shallower heading).'),
358
+ depth: z.number().optional().describe('Cap on heading levels shown (1 = h1 only, 2 = h1+h2, etc.). Default 3. Ignored when underHeading is set.'),
359
+ offset: z.number().optional().describe('Pagination start index (default 0). Useful for very long unstructured docs.'),
360
+ limit: z.number().optional().describe('Pagination max lines (default 200).'),
361
+ },
362
+ handler: async ({ docId, underHeading, depth, offset, limit }) => {
363
+ const target = resolveDocTarget(docId);
364
+ const text = outline(target.document, { underHeading, depth, offset, limit });
365
+ return { content: [{ type: 'text', text }] };
366
+ },
367
+ },
368
+ {
369
+ name: 'peek_doc',
370
+ description: 'Read a windowed slice of nodes from a doc — once you have a nodeId from search_docs or outline_doc. Six target shapes: { node } single block, { nodes } array of explicit IDs, { around, before, after } window around an anchor, { from, to } range between two anchors, { first } top N blocks, { last } bottom N blocks, { position, span } positional read (position 0.0–1.0). All return compact tagged-line format. Use this instead of read_pad whenever you only need part of a doc.',
371
+ schema: {
372
+ docId: z.string().describe('Target document by docId (8-char hex).'),
373
+ target: z.union([
374
+ z.object({ node: z.string() }).strict(),
375
+ z.object({ nodes: z.array(z.string()) }).strict(),
376
+ z.object({ around: z.string(), before: z.number().optional(), after: z.number().optional() }).strict(),
377
+ z.object({ from: z.string(), to: z.string() }).strict(),
378
+ z.object({ first: z.number() }).strict(),
379
+ z.object({ last: z.number() }).strict(),
380
+ z.object({ position: z.number(), span: z.number().optional() }).strict(),
381
+ ]).describe('Target spec — exactly one shape. See description for the six options.'),
382
+ },
383
+ handler: async ({ docId, target }) => {
384
+ const docTarget = resolveDocTarget(docId);
385
+ const nodes = peek(docTarget.document, target);
386
+ return { content: [{ type: 'text', text: compactNodes(nodes) }] };
387
+ },
388
+ },
351
389
  {
352
390
  name: 'get_nodes',
353
- description: 'Get specific nodes by ID. Returns compact tagged-line format per node.',
391
+ description: 'DEPRECATED superseded by peek_doc. Use peek_doc with target { nodes: [ids] } for the same behavior, or one of the other target shapes for richer windowed reads. This alias may be removed in a future release.',
354
392
  schema: {
355
393
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
356
394
  nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
@@ -384,7 +422,7 @@ export const TOOL_REGISTRY = [
384
422
  return `${main}\n → ${d.logline}`;
385
423
  return main;
386
424
  });
387
- const footer = enrichmentFooter();
425
+ const footer = `${enrichmentFooter()}${sortFooter()}`;
388
426
  return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
389
427
  },
390
428
  },
@@ -501,7 +539,7 @@ export const TOOL_REGISTRY = [
501
539
  // this entry. Fires after the file exists, so documents-changed arrives with
502
540
  // the real entry that the sidebar filters behind the spinner until populate.
503
541
  spinnerKey = result.filename;
504
- broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
542
+ broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
505
543
  broadcastDocumentsChanged();
506
544
  return {
507
545
  content: [{
@@ -654,7 +692,7 @@ export const TOOL_REGISTRY = [
654
692
  const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
655
693
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
656
694
  }
657
- broadcastWritingStarted(w.title, wsTarget, result.filename);
695
+ broadcastWritingStarted(w.title, wsTarget, result.filename, result.filename, result.docId);
658
696
  broadcastedKeys.push(result.filename);
659
697
  results.push({ docId: result.docId, filename: result.filename, title: result.title });
660
698
  }
@@ -902,6 +940,7 @@ export const TOOL_REGISTRY = [
902
940
  kind: 'enrichment',
903
941
  headline: `Enrichment stamped ${target.title || target.filename}`,
904
942
  detail: item.logline,
943
+ docId: item.docId,
905
944
  filename: target.filename,
906
945
  });
907
946
  results.push({ docId: item.docId, ok: true });
@@ -932,12 +971,84 @@ export const TOOL_REGISTRY = [
932
971
  },
933
972
  },
934
973
  {
935
- name: 'crawl',
936
- description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive agents use this to scan the workspace shelf at concept level (~60 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay. v0.19.0 schema: status (canonical / draft) replaces docRole / domain / concepts filters those legacy filters were dropped because the fields they queried had no authority discipline. See brief 2026-05-21-simplify-enrichment-schema-three-fields.',
974
+ name: 'list_pending_sorts',
975
+ description: 'List documents that the user has marked for sorting via the sidebar. Each entry includes the doc identity, where it currently lives, the requestedAt timestamp, and (when present) a proposal already written by an earlier pass. Call this first to know what sort work is pending; for each entry, read the doc body, consider workspace/container purpose hints, and either discuss the destination with the user in chat (1–3 docs) or write a proposal via propose_sort (many docs batch UI accept/reject).',
976
+ schema: {
977
+ workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
978
+ },
979
+ handler: async ({ workspaceFile }) => {
980
+ const docs = listPendingSorts(workspaceFile);
981
+ return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
982
+ },
983
+ },
984
+ {
985
+ name: 'propose_sort',
986
+ description: 'Write a sort proposal back to a doc. Used in confirm-mode: the minion picks a destination (workspace + container) and a one-line reasoning, stamps it onto the doc\'s sortRequest, and the sidebar flips the doc\'s badge to "proposal ready" so the user can accept or reject in-browser. Does NOT move the doc — that happens on user accept (via the UI) or in auto-mode via mark_sorted+move_item. Accepts an array so a batch lands in one call.',
987
+ schema: {
988
+ proposals: z.array(z.object({
989
+ docId: z.string().describe('Target document by docId (8-char hex from list_pending_sorts).'),
990
+ wsFilename: z.string().describe('Destination workspace manifest filename.'),
991
+ containerId: z.string().nullable().describe('Destination container ID, or null for workspace root.'),
992
+ reasoning: z.string().max(200).describe('One-line rationale shown to the user beside the proposal. Under 200 chars.'),
993
+ }).strict()).describe('One or more proposals. Single-doc calls are a length-1 array.'),
994
+ },
995
+ handler: async ({ proposals }) => {
996
+ const results = [];
997
+ for (const p of proposals) {
998
+ try {
999
+ const filename = resolveDocId(p.docId);
1000
+ setSortProposalOnFile(filename, { wsFilename: p.wsFilename, containerId: p.containerId, reasoning: p.reasoning });
1001
+ results.push({ docId: p.docId, ok: true });
1002
+ }
1003
+ catch (err) {
1004
+ results.push({ docId: p.docId, ok: false, error: String(err?.message ?? err) });
1005
+ }
1006
+ }
1007
+ broadcastDocumentsChanged();
1008
+ const okCount = results.filter((r) => r.ok).length;
1009
+ const failCount = results.length - okCount;
1010
+ const summary = failCount === 0
1011
+ ? `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}`
1012
+ : `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}, ${failCount} failed`;
1013
+ return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ proposals: results })}` }] };
1014
+ },
1015
+ },
1016
+ {
1017
+ name: 'mark_sorted',
1018
+ description: 'Clear a doc\'s sortRequest and stamp lastSortedAt. Used in auto-mode after the minion has actually called move_item (or decided the doc is already in the right place), and in confirm-mode after the user has accepted/rejected via the UI. Accepts an array for batch fulfillment. Does NOT perform the move — call move_item first. The retire-on-fulfillment pattern mirrors mark_enriched / enrichmentStale.',
1019
+ schema: {
1020
+ docs: z.array(z.object({
1021
+ docId: z.string().describe('Target document by docId.'),
1022
+ }).strict()).describe('One or more docs to mark sorted.'),
1023
+ },
1024
+ handler: async ({ docs }) => {
1025
+ const results = [];
1026
+ for (const item of docs) {
1027
+ try {
1028
+ const filename = resolveDocId(item.docId);
1029
+ clearSortRequestOnFile(filename);
1030
+ results.push({ docId: item.docId, ok: true });
1031
+ }
1032
+ catch (err) {
1033
+ results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
1034
+ }
1035
+ }
1036
+ broadcastDocumentsChanged();
1037
+ const okCount = results.filter((r) => r.ok).length;
1038
+ const failCount = results.length - okCount;
1039
+ const summary = failCount === 0
1040
+ ? `Marked ${okCount} sorted`
1041
+ : `Marked ${okCount} sorted, ${failCount} failed`;
1042
+ return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
1043
+ },
1044
+ },
1045
+ {
1046
+ name: 'browse_docs',
1047
+ description: 'Browse the workspace shelf at concept level — bulk-read enriched frontmatter per doc, filtered by criteria. Returns identity + logline + status + tags + stale flag (~60 tokens/doc). No bodies, no nodes, no pending overlay, no container nesting. Filters compose with AND semantics; empty filter returns every non-archived doc. Use this between get_workspace_structure (tree shape) and read_pad (full body) — see which docs look relevant before paying to read one.',
937
1048
  schema: {
938
1049
  workspaceFile: z.string().optional().describe('Scope to one workspace.'),
939
1050
  tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
940
- status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common crawl is `status: canonical`.'),
1051
+ status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common browse is `status: canonical`.'),
941
1052
  hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
942
1053
  },
943
1054
  handler: async (filter) => {
@@ -945,6 +1056,20 @@ export const TOOL_REGISTRY = [
945
1056
  return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
946
1057
  },
947
1058
  },
1059
+ {
1060
+ name: 'crawl',
1061
+ description: 'DEPRECATED — renamed to browse_docs. Use browse_docs instead. This alias may be removed in a future release.',
1062
+ schema: {
1063
+ workspaceFile: z.string().optional(),
1064
+ tags: z.array(z.string()).optional(),
1065
+ status: z.enum(['canonical', 'draft']).optional(),
1066
+ hasLogline: z.boolean().optional(),
1067
+ },
1068
+ handler: async (filter) => {
1069
+ const docs = crawlDocs(filter);
1070
+ return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
1071
+ },
1072
+ },
948
1073
  {
949
1074
  name: 'list_workspaces',
950
1075
  description: 'List all workspaces. Returns filename, title, and doc count.',
@@ -952,7 +1077,7 @@ export const TOOL_REGISTRY = [
952
1077
  handler: async () => {
953
1078
  const workspaces = listWorkspaces();
954
1079
  const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
955
- const footer = enrichmentFooter();
1080
+ const footer = `${enrichmentFooter()}${sortFooter()}`;
956
1081
  return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
957
1082
  },
958
1083
  },
@@ -988,46 +1113,20 @@ export const TOOL_REGISTRY = [
988
1113
  },
989
1114
  {
990
1115
  name: 'get_workspace_structure',
991
- description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, status, tags, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies. v0.19.0: enrichment fields shown per-doc are logline (LLM-owned), status (agent-owned: canonical / draft), tags, and the STALE marker (system-owned).',
1116
+ description: 'Get the tree shape of a workspace: containers and docs with their IDs/filenames, plus workspace-level structural fields (schema, vocab, enrichment flag). NO per-doc loglines, status, tags, or stale flags those are concept-level and live in `browse`. Use this when you need to find a destination container (sort, move) or understand nesting. For "what is each doc about" call `browse` instead.',
992
1117
  schema: {
993
1118
  filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
994
1119
  },
995
1120
  handler: async ({ filename }) => {
996
1121
  const ws = getWorkspace(filename);
997
- // Build a one-pass map of filename → frontmatter so we don't re-read each
998
- // doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
999
- const enriched = crawlDocs({ workspaceFile: filename });
1000
- const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
1001
1122
  function renderTree(nodes, indent) {
1002
1123
  const lines = [];
1003
1124
  for (const node of nodes) {
1004
1125
  if (node.type === 'doc') {
1005
- const e = enrichByFile.get(node.file);
1006
- const tags = e?.tags ?? [];
1007
- const enrichBits = [];
1008
- // v0.19.0: status (agent-owned) replaces domain + docRole.
1009
- // Only "canonical" is worth surfacing — draft is the default
1010
- // and would add noise on every line.
1011
- if (e?.status === 'canonical')
1012
- enrichBits.push('canonical');
1013
- if (tags.length > 0)
1014
- enrichBits.push(`tags: ${tags.join(', ')}`);
1015
- if (e?.enrichmentStale === true)
1016
- enrichBits.push('STALE');
1017
- const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
1018
- const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
1019
- lines.push(docLine);
1020
- if (e?.logline)
1021
- lines.push(`${indent} → ${e.logline}`);
1126
+ lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
1022
1127
  }
1023
1128
  else {
1024
- const cBits = [];
1025
- if (node.role)
1026
- cBits.push(node.role);
1027
- const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
1028
- lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
1029
- if (node.logline)
1030
- lines.push(`${indent} → ${node.logline}`);
1129
+ lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
1031
1130
  lines.push(...renderTree(node.items, indent + ' '));
1032
1131
  }
1033
1132
  }
@@ -1035,25 +1134,18 @@ export const TOOL_REGISTRY = [
1035
1134
  }
1036
1135
  const treeLines = renderTree(ws.root, ' ');
1037
1136
  const headerBits = [`workspace: "${ws.title}"`];
1038
- if (ws.logline)
1039
- headerBits.push(`logline: ${ws.logline}`);
1040
- if (ws.domain)
1041
- headerBits.push(`domain: ${ws.domain}`);
1042
1137
  if (ws.schema)
1043
1138
  headerBits.push(`schema: ${ws.schema}`);
1044
1139
  if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
1045
1140
  headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
1046
1141
  }
1047
- if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
1048
- headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
1049
- }
1050
1142
  if (ws.enrichmentDisabled === true)
1051
1143
  headerBits.push('enrichment: disabled');
1052
1144
  let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
1053
1145
  if (ws.context && Object.keys(ws.context).length > 0) {
1054
1146
  text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
1055
1147
  }
1056
- const footer = enrichmentFooter();
1148
+ const footer = `${enrichmentFooter()}${sortFooter()}`;
1057
1149
  return { content: [{ type: 'text', text: `${text}${footer}` }] };
1058
1150
  },
1059
1151
  },
@@ -1785,6 +1877,7 @@ export const TOOL_REGISTRY = [
1785
1877
  broadcastActivityEvent({
1786
1878
  kind: 'backlinks-added',
1787
1879
  headline: `Linked ${sourceTitle} → ${targetTitle}`,
1880
+ docId: source_doc_id,
1788
1881
  filename: sourceFilename,
1789
1882
  });
1790
1883
  }
@@ -1823,18 +1916,26 @@ export const TOOL_REGISTRY = [
1823
1916
  },
1824
1917
  {
1825
1918
  name: 'search_docs',
1826
- description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
1919
+ description: 'Full-text search. Default (no docId): ranked candidates across every workspace doc, returning docId + title + match type + snippet. Scoped (docId set): search inside one doc and return matching NODES with nodeId + type + snippet — the content-to-node bridge that lets the agent jump from "I want the beat about X" to a known nodeId without ever reading the full doc. Pairs with peek_doc for the actual node read. Use the workspace search before link_to. Use the doc-scoped search before peek_doc when researching inside a long doc.',
1827
1920
  schema: {
1828
- query: z.string().describe('Search query (case-insensitive substring match against title, tags, then content).'),
1921
+ query: z.string().describe('Search query (case-insensitive substring match).'),
1922
+ docId: z.string().optional().describe('When set, scope the search to one doc and return matching nodes (with nodeId) instead of doc snippets. The content-orientation entry point for in-doc research.'),
1829
1923
  limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
1830
1924
  },
1831
- handler: async ({ query, limit = 10 }) => {
1925
+ handler: async ({ query, docId, limit = 10 }) => {
1832
1926
  const cap = Math.min(Math.max(limit, 1), 50);
1927
+ // Doc-scoped path: walk the doc's blocks and return matching nodes.
1928
+ if (docId) {
1929
+ const target = resolveDocTarget(docId);
1930
+ const matches = searchInDoc(target.document, query, cap);
1931
+ return { content: [{ type: 'text', text: JSON.stringify({ docId, total: matches.length, matches }) }] };
1932
+ }
1933
+ // Workspace-wide path: existing behavior.
1833
1934
  const raw = searchDocuments(query);
1834
1935
  // Enrich with docId + enrichment fields from frontmatter so the agent
1835
1936
  // can rank/pick candidates without a follow-up body read.
1836
1937
  const enriched = raw.slice(0, cap).map((r) => {
1837
- let docId = null;
1938
+ let resolvedDocId = null;
1838
1939
  let logline;
1839
1940
  let status;
1840
1941
  let tags;
@@ -1842,7 +1943,7 @@ export const TOOL_REGISTRY = [
1842
1943
  const filePath = resolveDocPath(r.filename);
1843
1944
  const fileRaw = readFileSync(filePath, 'utf-8');
1844
1945
  const fm = matter(fileRaw);
1845
- docId = fm.data?.docId || null;
1946
+ resolvedDocId = fm.data?.docId || null;
1846
1947
  // v0.19.0 three-field schema: logline (LLM), status (agent), tags.
1847
1948
  if (typeof fm.data?.logline === 'string')
1848
1949
  logline = fm.data.logline;
@@ -1853,7 +1954,7 @@ export const TOOL_REGISTRY = [
1853
1954
  }
1854
1955
  catch { /* fields stay undefined */ }
1855
1956
  return {
1856
- docId,
1957
+ docId: resolvedDocId,
1857
1958
  title: r.title,
1858
1959
  filename: r.filename,
1859
1960
  matchType: r.matchType,
@@ -1984,10 +2085,15 @@ export async function startMcpServer() {
1984
2085
  // agent's system context. Empty string when no enrichment work is pending.
1985
2086
  // See brief 2026-05-18-frontmatter-enrichment-system.
1986
2087
  const enrichmentNotice = buildEnrichmentInstructions();
2088
+ const sortNotice = buildSortInstructions();
2089
+ // Stack notices in the MCP instructions field. Both notices stand on their
2090
+ // own — agents can read and dispatch them independently. Empty when neither
2091
+ // system has pending work.
2092
+ const combinedNotice = [enrichmentNotice, sortNotice].filter(Boolean).join('\n');
1987
2093
  const server = new McpServer({
1988
2094
  name: 'openwriter',
1989
2095
  version: '0.2.0',
1990
- }, enrichmentNotice ? { instructions: enrichmentNotice } : undefined);
2096
+ }, combinedNotice ? { instructions: combinedNotice } : undefined);
1991
2097
  // Wrap each tool handler in withRequestId so every event logged during
1992
2098
  // the tool's execution inherits the same request ID. Trace one MCP call
1993
2099
  // through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.