openwriter 0.30.1 → 0.31.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,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-D1naX68L.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-BtCWWQrZ.css">
13
+ <script type="module" crossorigin src="/assets/index-CaFrYDJP.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-C-eMDCqj.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -6,6 +6,27 @@
6
6
  * Sidebar document transforms (Vary / Shrinkify / Threadify / etc.) live in
7
7
  * @openwriter/plugin-publish — they go through the metered platform path.
8
8
  */
9
+ import { readFileSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { join } from 'path';
12
+ const PLUGIN_NAME = '@openwriter/plugin-authors-voice';
13
+ /**
14
+ * Live-read the model tier from ~/.openwriter/config.json per request.
15
+ * The host passes plugin config ONCE at registerRoutes and never reloads it on save, so the
16
+ * dropdown's value would otherwise require a server restart. Reading the file per request
17
+ * makes a model-picker change take effect on the very next Enhance. Falls back to the
18
+ * load-time value on any error.
19
+ */
20
+ function liveModelTier(fallback) {
21
+ try {
22
+ const cfg = JSON.parse(readFileSync(join(homedir(), '.openwriter', 'config.json'), 'utf8'));
23
+ const v = cfg?.plugins?.[PLUGIN_NAME]?.config?.model;
24
+ return typeof v === 'string' ? v : fallback;
25
+ }
26
+ catch {
27
+ return fallback;
28
+ }
29
+ }
9
30
  const plugin = {
10
31
  name: '@openwriter/plugin-authors-voice',
11
32
  version: '0.4.0',
@@ -33,9 +54,9 @@ const plugin = {
33
54
  options: [
34
55
  { value: '', label: 'Default (Strongest)' },
35
56
  { value: 'strongest', label: 'Strongest — Claude Opus (best quality)' },
36
- { value: 'gemini-pro', label: 'Gemini Pro — flagship, strong + cheap' },
37
57
  { value: 'balanced', label: 'Balanced — Claude Sonnet' },
38
- { value: 'fast', label: 'Fast — Gemini Flash (cheapest)' },
58
+ { value: 'fast-plus', label: 'Fast+ — Gemini 3.5 Flash (newest, great)' },
59
+ { value: 'fast', label: 'Fast — Gemini 2.5 Flash (cheapest)' },
39
60
  ],
40
61
  },
41
62
  },
@@ -55,12 +76,14 @@ const plugin = {
55
76
  return body;
56
77
  return { ...body, debug: true };
57
78
  };
58
- // Inject the user's model-tier pick. Pure pass-through: the AV API validates the slug
59
- // and falls back to its own default if absent/unknown. Blank nothing injected.
79
+ // Inject the user's model-tier pick, read LIVE per request (dropdown changes take effect
80
+ // immediately, no restart). Pure pass-through: the AV API validates the slug and falls
81
+ // back to its own default if absent/unknown. Blank → nothing injected.
60
82
  const withModel = (body) => {
61
- if (!modelTier || !body || typeof body !== 'object')
83
+ const tier = liveModelTier(modelTier);
84
+ if (!tier || !body || typeof body !== 'object')
62
85
  return body;
63
- return { ...body, modelTier };
86
+ return { ...body, modelTier: tier };
64
87
  };
65
88
  // Wildcard proxy for /api/voice/* routes. Pure pass-through: the AV API owns the
66
89
  // engine choice (v1/v2) via its own AV_DEFAULT_ENGINE setting, so the plugin injects
@@ -14,6 +14,17 @@ function extractDocId(rawContent) {
14
14
  return yamlMatch[1];
15
15
  return null;
16
16
  }
17
+ /** Server-side backstop for the transform size guard. Mirrors the client-side
18
+ * cap in packages/openwriter/src/sidebar/transform-guard.ts (same 5000-word unit)
19
+ * so an oversized doc can't reach the model/publish worker even when a caller
20
+ * bypasses the UI guard (agent, script, stale client). */
21
+ const MAX_TRANSFORM_WORDS = 5000;
22
+ /** Word count of a doc's body. Strips a leading YAML frontmatter block so the
23
+ * count matches the body-only wordCount the sidebar measures client-side. */
24
+ function countBodyWords(rawContent) {
25
+ const body = rawContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
26
+ return (body.match(/\S+/g) || []).length;
27
+ }
17
28
  /** Map transform action to variant content type */
18
29
  const ACTION_VARIANT_TYPE = {
19
30
  vary: 'document',
@@ -1038,6 +1049,15 @@ const plugin = {
1038
1049
  res.status(400).json({ error: 'Document content is required' });
1039
1050
  return;
1040
1051
  }
1052
+ // Size backstop — mirror the client-side transform guard. Block before the
1053
+ // worker model call so an oversized doc can't run up unbounded cost + time,
1054
+ // even when a caller bypasses the UI guard.
1055
+ const transformWords = countBodyWords(content);
1056
+ if (transformWords > MAX_TRANSFORM_WORDS) {
1057
+ console.warn(`[Publish Plugin] Transform blocked — doc too large: ${transformWords} words (max ${MAX_TRANSFORM_WORDS})`);
1058
+ res.status(413).json({ error: `Document too large to transform: ${transformWords.toLocaleString()} words (max ${MAX_TRANSFORM_WORDS.toLocaleString()}).` });
1059
+ return;
1060
+ }
1041
1061
  // Call publish worker /transforms endpoint
1042
1062
  const transformRes = await publishFetch(ctx.config, '/transforms', {
1043
1063
  method: 'POST',
@@ -310,16 +310,22 @@ export function buildEnrichmentInstructions() {
310
310
  ].join('\n');
311
311
  }
312
312
  /** Footer on the three high-frequency discovery tools when sort requests are
313
- * pending. Stacks beneath enrichmentFooter. Sorting is a judgment call
314
- * handle it inline in conversation, don't dispatch a subagent. */
313
+ * pending. Stacks beneath enrichmentFooter. Carries the exact dispatch call
314
+ * so the acting agent's burden collapses to one paste — the sort minion is
315
+ * autonomous and self-discovers via list_pending_sorts once it boots, files
316
+ * each doc, and reports what moved. The earlier "handle it inline, no minion"
317
+ * model never drained (marks rotted for days); auto-drain replaces it.
318
+ * adr: adr/sort-minion-drain.md */
315
319
  export function sortFooter() {
316
320
  const count = listPendingSorts().length;
317
321
  if (count === 0)
318
322
  return '';
319
- return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} awaiting sort. Call list_pending_sorts to handle inline — discuss destinations with the user, then either move + mark_sorted (when the user confirms in chat) or propose_sort (UI accept/reject for batches).`;
323
+ return `\n\n⚠ ${count} doc${count === 1 ? '' : 's'} awaiting sort. Dispatch:\nAgent(\n subagent_type: "openwriter-sort-minion",\n description: "File pending sorts",\n prompt: "File pending sorts.",\n run_in_background: true\n)`;
320
324
  }
321
325
  /** Session-start sort notice — stacks with buildEnrichmentInstructions inside
322
- * the MCP `instructions` field. Empty when no sorts pending. */
326
+ * the MCP `instructions` field. Empty when no sorts pending. Mirrors
327
+ * buildEnrichmentInstructions: an executable dispatch directive, not advice.
328
+ * adr: adr/sort-minion-drain.md */
323
329
  export function buildSortInstructions() {
324
330
  const pending = listPendingSorts();
325
331
  if (pending.length === 0)
@@ -327,7 +333,13 @@ export function buildSortInstructions() {
327
333
  return [
328
334
  '',
329
335
  `SORT_STATUS: ${pending.length} doc${pending.length === 1 ? '' : 's'} awaiting sort.`,
330
- 'Call list_pending_sorts when the user engages or you have a natural moment. For each doc: read it, pick a destination (get_workspace_structure for tree shape + container purpose: hints; browse for what other docs in a container are about). For 1–3 docs, discuss in chat then move_item + mark_sorted on confirmation. For many docs, write propose_sort entries and let the user accept/reject via the sidebar popover. Sorting is a judgment call — bias toward asking when a doc could plausibly live in two places.',
336
+ 'Dispatch the minion now it self-discovers via list_pending_sorts, picks a destination for each doc, files it (move_item), retires the request (mark_sorted), and reports what moved:',
337
+ 'Agent(',
338
+ ' subagent_type: "openwriter-sort-minion",',
339
+ ' description: "File pending sorts",',
340
+ ' prompt: "File pending sorts.",',
341
+ ' run_in_background: true',
342
+ ')',
331
343
  ].join('\n');
332
344
  }
333
345
  /**
@@ -340,6 +352,7 @@ export function listPendingSorts(scopeWorkspace) {
340
352
  ensureDataDir();
341
353
  const ownership = buildWorkspaceOwnershipMap();
342
354
  const containerByFile = buildContainerOwnershipMap();
355
+ const optedOut = collectAutoSortOptedOutFilenames();
343
356
  let scopeFiles = null;
344
357
  if (scopeWorkspace) {
345
358
  try {
@@ -354,6 +367,8 @@ export function listPendingSorts(scopeWorkspace) {
354
367
  for (const f of readdirSync(getDataDir()).filter((f) => f.endsWith('.md'))) {
355
368
  if (scopeFiles && !scopeFiles.has(f))
356
369
  continue;
370
+ if (optedOut.has(f))
371
+ continue;
357
372
  try {
358
373
  const raw = readFileSync(join(getDataDir(), f), 'utf-8');
359
374
  const { data } = matter(raw);
@@ -416,6 +431,23 @@ function collectOptedOutFilenames() {
416
431
  }
417
432
  return out;
418
433
  }
434
+ /** Build a Set of filenames inside workspaces with autoSortDisabled: true.
435
+ * These docs are excluded from list_pending_sorts, so the sort minion never
436
+ * auto-files them — the user handles them manually via the sidebar. */
437
+ function collectAutoSortOptedOutFilenames() {
438
+ const out = new Set();
439
+ for (const info of listWorkspaces()) {
440
+ try {
441
+ const ws = getWorkspace(info.filename);
442
+ if (ws.autoSortDisabled === true) {
443
+ for (const f of collectAllFiles(ws.root))
444
+ out.add(f);
445
+ }
446
+ }
447
+ catch { /* skip corrupt manifests */ }
448
+ }
449
+ return out;
450
+ }
419
451
  /** Map filename → first workspace that contains it. Used to attribute
420
452
  * dirty-doc reports to a workspace. */
421
453
  function buildWorkspaceOwnershipMap() {
@@ -1064,7 +1064,7 @@ export const TOOL_REGISTRY = [
1064
1064
  },
1065
1065
  {
1066
1066
  name: 'list_pending_sorts',
1067
- 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).',
1067
+ description: 'List documents the user marked for sorting via the sidebar. Each entry includes the doc identity, where it currently lives, the requestedAt timestamp, and (when present) a proposal from an earlier pass. The sort minion (openwriter-sort-minion) calls this first to know what to file. For each entry: read the body, consider workspace/container purpose hints, then move_item + mark_sorted to auto-file it. Docs in opted-out workspaces (autoSortDisabled: true) are excluded. propose_sort remains for the manual sidebar accept/reject flow.',
1068
1068
  schema: {
1069
1069
  workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
1070
1070
  },
@@ -1248,6 +1248,8 @@ export const TOOL_REGISTRY = [
1248
1248
  }
1249
1249
  if (ws.enrichmentDisabled === true)
1250
1250
  headerBits.push('enrichment: disabled');
1251
+ if (ws.autoSortDisabled === true)
1252
+ headerBits.push('auto-sort: disabled');
1251
1253
  let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
1252
1254
  if (ws.context && Object.keys(ws.context).length > 0) {
1253
1255
  text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
@@ -1298,7 +1300,7 @@ export const TOOL_REGISTRY = [
1298
1300
  },
1299
1301
  {
1300
1302
  name: 'update_workspace_context',
1301
- description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus enrichment fields (logline, domain, schema, vocab, relatedWorkspaces, enrichmentVolumeThreshold, enrichmentDriftThreshold, enrichmentDisabled — set on workspace top-level). Pass `null` to clear an enrichment field. Use this to opt a workspace out of enrichment (enrichmentDisabled: true), declare a closed vocab for domain classification, or set workspace-level loglines/schemas. One tool covers writing context + enrichment config.',
1303
+ description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus config fields (logline, domain, schema, vocab, relatedWorkspaces, enrichmentVolumeThreshold, enrichmentDriftThreshold, enrichmentDisabled, autoSortDisabled — set on workspace top-level). Pass `null` to clear a field. Use this to opt a workspace out of enrichment (enrichmentDisabled: true) or auto-sort (autoSortDisabled: true), declare a closed vocab for domain classification, or set workspace-level loglines/schemas. One tool covers writing context + enrichment + sort config.',
1302
1304
  schema: {
1303
1305
  workspaceFile: z.string().describe('Workspace manifest filename'),
1304
1306
  context: z.object({
@@ -1313,7 +1315,8 @@ export const TOOL_REGISTRY = [
1313
1315
  enrichmentVolumeThreshold: z.number().nullable().optional().describe('Volume-ratio threshold (default 1.5). Set null to revert.'),
1314
1316
  enrichmentDriftThreshold: z.number().nullable().optional().describe('Jaccard-drift threshold (default 0.3). Set null to revert.'),
1315
1317
  enrichmentDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of enrichment surfacing. Set null or false to re-enable.'),
1316
- }).describe('Writing context + enrichment config to apply'),
1318
+ autoSortDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of auto-sort; its sort-marked docs drop from list_pending_sorts so the sort minion never auto-files them (user handles them manually via the sidebar). Set null or false to re-enable.'),
1319
+ }).describe('Writing context + enrichment + sort config to apply'),
1317
1320
  },
1318
1321
  handler: async ({ workspaceFile, context }) => {
1319
1322
  updateWorkspaceContext(workspaceFile, context);
@@ -164,9 +164,19 @@ export class PluginManager {
164
164
  const pluginsState = { ...existing };
165
165
  for (const [name, managed] of this.plugins) {
166
166
  const prior = (existing[name] || {});
167
+ // A plugin that never loaded (plugin===undefined: bundled dist missing in
168
+ // an unbuilt worktree, transient import failure, etc.) sits in the map with
169
+ // the default enabled===false. Persisting that false would clobber the
170
+ // user's real on-disk intent and STICK — the plugin would stay off on every
171
+ // future boot even after the load problem is fixed, because startup only
172
+ // re-enables plugins marked true. PluginManager owns `enabled` only for
173
+ // plugins it actually loaded; for unloaded ones, preserve the on-disk value.
174
+ // (A user-disabled plugin keeps plugin set — disable() never clears it — so
175
+ // a deliberate false still persists correctly.)
176
+ const enabled = managed.plugin ? managed.enabled : (prior.enabled ?? managed.enabled);
167
177
  pluginsState[name] = {
168
178
  ...prior, // preserve blogSites + any other plugin-owned data
169
- enabled: managed.enabled, // overwrite managed fields
179
+ enabled, // overwrite managed fields (only when actually loaded)
170
180
  config: managed.config,
171
181
  };
172
182
  }
@@ -327,9 +327,12 @@ export function reorderWorkspaceAfter(filename, afterFilename) {
327
327
  writeOrder(order);
328
328
  }
329
329
  const WRITING_CONTEXT_KEYS = new Set(['characters', 'settings', 'rules']);
330
- const ENRICHMENT_FIELDS = new Set([
330
+ // Workspace top-level config fields (enrichment + sort). Copied onto the
331
+ // workspace root by updateWorkspaceContext; `null` clears the field.
332
+ const WORKSPACE_CONFIG_FIELDS = new Set([
331
333
  'logline', 'domain', 'schema', 'vocab', 'relatedWorkspaces',
332
334
  'enrichmentVolumeThreshold', 'enrichmentDriftThreshold', 'enrichmentDisabled',
335
+ 'autoSortDisabled',
333
336
  ]);
334
337
  export function updateWorkspaceContext(wsFile, update) {
335
338
  const ws = getWorkspace(wsFile);
@@ -342,8 +345,8 @@ export function updateWorkspaceContext(wsFile, update) {
342
345
  if (Object.keys(ctxUpdate).length > 0) {
343
346
  ws.context = { ...ws.context, ...ctxUpdate };
344
347
  }
345
- // Enrichment fields set on the workspace top-level. `null` clears.
346
- for (const key of ENRICHMENT_FIELDS) {
348
+ // Config fields (enrichment + sort) set on the workspace top-level. `null` clears.
349
+ for (const key of WORKSPACE_CONFIG_FIELDS) {
347
350
  if (!(key in update))
348
351
  continue;
349
352
  const value = update[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
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.16.4"
19
+ version: "0.17.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -43,20 +43,24 @@ You are a writing collaborator. You read documents and make edits **exclusively
43
43
  **If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-enrichment-minion' not found`. Tell the user once: "OpenWriter has stale docs but the enrichment minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
44
44
 
45
45
  **If the user opts out** ("stop nagging me about enrichment for X workspace"): call `update_workspace_context` with `enrichmentDisabled: true` for that workspace. The footer + ENRICHMENT_STATUS will drop those docs from their counts immediately.
46
- 6. **Handle sort requests inline when openwriter surfaces them.** The user marks docs in the sidebar with "Request sort" when they don't know where a doc belongs and want you to file it. OpenWriter surfaces pending sorts two ways: (a) `SORT_STATUS: N docs awaiting sort` in the MCP server's session-start instructions; (b) a `⚠ N docs awaiting sort` footer on `list_documents` / `list_workspaces` / `get_workspace_structure`. **No minion.** Sorting is a judgment call (which workspace, which container, why) handle it yourself in conversation.
46
+ 6. **Dispatch the sort minion when openwriter says so.** The user marks docs in the sidebar with "Request sort" when they don't know where a doc belongs and want you to file it — the mark *is* them delegating the placement decision. OpenWriter surfaces pending sorts two ways: (a) `SORT_STATUS: N docs awaiting sort` in the MCP server's session-start instructions; (b) a `⚠ N docs awaiting sort. Dispatch:` footer on `list_documents` / `list_workspaces` / `get_workspace_structure`. 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 self-discovers via `list_pending_sorts`, reads each doc, picks the best workspace + container from purpose hints, files it (`move_item`), retires the request (`mark_sorted`), and returns a one-line "what moved" summary.
47
47
 
48
- **The procedure per pending doc:**
49
- 1. `list_pending_sorts` — returns identity + current location + any prior proposal.
50
- 2. `outline_doc(docId)` first to orient. If the doc has headings, the skeleton + a hit-targeted `peek_doc({ around })` is enough. Fall back to `read_pad` only when the doc has no structure or you genuinely need everything.
51
- 3. `get_workspace_structure` find candidate destination containers. Look for a `purpose:` hint on containers/workspaces (strong signal — author told you what belongs there). If absent, use `browse_docs` to see what other docs in a candidate container are about.
52
- 4. Pick a destination. **Bias toward asking the user** when a doc could plausibly live in two places. **Never auto-execute** — every sort move needs human confirmation, either via chat ("moving Notes-on-X into Reference, good?") or via the UI accept/reject popover.
53
- 5. Execute. Two paths:
54
- - **1–3 docs (chat flow):** discuss inline → `move_item` on confirmation → `mark_sorted({ docs: [...] })`.
55
- - **Many docs (batch flow):** `propose_sort({ proposals: [...] })` writes one proposal per doc back into frontmatter. The sidebar flips each doc's badge to "proposal ready" and the user accepts/rejects via the in-menu popover — that triggers the move + mark_sorted on the backend automatically.
48
+ ```
49
+ Agent(
50
+ subagent_type: "openwriter-sort-minion",
51
+ description: "File pending sorts",
52
+ prompt: "File pending sorts.",
53
+ run_in_background: true
54
+ )
55
+ ```
56
+
57
+ **Why a minion, not inline.** Earlier this was "handle it inline, no minion — sorting is a judgment call." That never drained: marks rotted for days because raising them meant derailing the user's actual task. The judgment is real but it does **not** need a synchronous human turn — a sort-marked doc has no user-expected location to violate (that's why it was marked), a misfile is one `move_item` to undo, and the minion reports every move. Reversible + visible replaces the gate. This is the same autonomous-drain rail enrichment rides (firm rule 5).
58
+
59
+ **Surfacing to the user:** treat sorting like enrichment and the inbox — a maintenance reflex, not a feature they ask for. Dispatch silently and relay a one-line aside in your response: "Filed 3 docs in the background — CalorieBot → PayBot/Marketing, …. Now, …". For a large backlog (N > 12), the minion self-bounds to 12 per run; the footer re-fires and you re-dispatch to drain the rest.
56
60
 
57
- **Surfacing to the user:** treat sort surfacing like an inbox item, not a notification. On first surface in a session: "You've got 3 pending sortstwo obvious moves and one I want to check on." Then propose destinations and walk through them. Don't ask permission to start; just engage with the actual destination decisions.
61
+ **Manual path still exists.** Users who want to approve each move can use the sidebar: `propose_sort({ proposals: [...] })` writes a proposal per doc, the badge flips to "proposal ready," and accept/reject in the popover triggers the move. The minion doesn't use this it's for when the user explicitly wants a gate. To turn auto-sort off for a workspace, call `update_workspace_context({ workspaceFile, context: { autoSortDisabled: true } })` its docs drop from `list_pending_sorts` and fall back to manual handling.
58
62
 
59
- **Skip the doc** ("not now"): `mark_sorted` it anyway with no move clears the marker without filing. Or leave it pending if the user wants to think on it.
63
+ **If the subagent isn't installed** (older openwriter, or the user skipped install-skill): the Agent call returns `Agent type 'openwriter-sort-minion' not found`. Tell the user once: "OpenWriter has docs awaiting sort but the sort minion isn't installed yet — run `npx openwriter install-skill` and restart Claude Code." Then proceed with their original request; don't loop on the failure.
60
64
  7. **Emit deep links whenever you cite a docId.** Any time you reference a specific document in chat — naming it, summarizing it, pointing the user at a beat or paragraph inside it — call `get_doc_link` and render the result using this exact presentation pattern:
61
65
 
62
66
  **Doc level** (one link, header bold):
@@ -0,0 +1,184 @@
1
+ ---
2
+ name: openwriter-sort-minion
3
+ description: |
4
+ Files openwriter documents the user marked for sorting via the sidebar.
5
+ Dispatch when SORT_STATUS appears in MCP init instructions OR when a
6
+ `⚠ N docs awaiting sort` footer fires on list_documents / list_workspaces /
7
+ get_workspace_structure. Reads each marked doc, picks the best workspace +
8
+ container from purpose hints, files it via move_item, and retires the
9
+ request via mark_sorted. Returns a one-line "what moved" summary.
10
+ model: sonnet
11
+ maxTurns: 500
12
+ tools: mcp__openwriter__list_pending_sorts, mcp__openwriter__list_workspaces, mcp__openwriter__get_workspace_structure, mcp__openwriter__browse_docs, mcp__openwriter__read_pad, mcp__openwriter__move_item, mcp__openwriter__mark_sorted
13
+ # OpenCode compatibility
14
+ mode: subagent
15
+ steps: 500
16
+ permission:
17
+ openwriter_list_pending_sorts: allow
18
+ openwriter_list_workspaces: allow
19
+ openwriter_get_workspace_structure: allow
20
+ openwriter_browse_docs: allow
21
+ openwriter_read_pad: allow
22
+ openwriter_move_item: allow
23
+ openwriter_mark_sorted: allow
24
+ ---
25
+
26
+ # OpenWriter Sort Minion
27
+
28
+ You are an isolated sub-agent. Your single job: take the docs the user
29
+ marked "Request sort" — docs they couldn't place themselves — and file each
30
+ one into the workspace + container where it belongs.
31
+
32
+ Do the work. Return a one-line summary. Do not narrate process. Do not ask
33
+ questions. The user already delegated the placement decision by marking the
34
+ doc — there is nothing to confirm. The main agent dispatched you because the
35
+ work needs doing.
36
+
37
+ ## The contract you operate under
38
+
39
+ The mark **is** the user delegating: "I don't know where this goes, you
40
+ file it." So:
41
+
42
+ - There is no user-expected location to violate. Any sensible placement
43
+ beats the doc rotting unfiled.
44
+ - You **move** docs — you do not write proposals. (propose_sort exists for
45
+ a different, manual flow. Ignore it.)
46
+ - A misfile is one move_item to undo, and you report every move. Reversible
47
+ + visible is the safety model — not a gate. Bias toward filing.
48
+
49
+ ## The exact procedure
50
+
51
+ ### Step 1. Find the work
52
+
53
+ **Default — self-discovery.** You will normally be dispatched with no input
54
+ list. Call `mcp__openwriter__list_pending_sorts` with no arguments. It
55
+ returns every pending doc across all workspaces. Each entry has `docId`,
56
+ `filename`, `title`, `currentWorkspaceFile` (absent = unfiled),
57
+ `currentContainerId`, `requestedAt`, and sometimes `proposal` (a
58
+ destination an earlier pass already chose).
59
+
60
+ **Special case — explicit list.** If the dispatching prompt provided an
61
+ explicit docId list, use that directly.
62
+
63
+ **Self-bound the batch.** If more than 12 docs are pending, file only the
64
+ first 12 this run. The footer fires again on the next openwriter tool call
65
+ and the acting agent re-dispatches you to drain the rest.
66
+
67
+ If `total === 0`, return `"No sort work pending."` and stop.
68
+
69
+ ### Step 2. Learn the destinations
70
+
71
+ Call `mcp__openwriter__list_workspaces` to enumerate workspaces, then
72
+ `mcp__openwriter__get_workspace_structure` once per workspace to learn:
73
+
74
+ - the workspace's `logline` / `schema` / `domain` (what it's for),
75
+ - its container tree and each container's `purpose:` hint and ID.
76
+
77
+ When a container's purpose is ambiguous, call `mcp__openwriter__browse_docs`
78
+ with that `workspaceFile` to see, at logline level, what already lives there.
79
+
80
+ If **no workspaces exist**, you have nowhere to file. Return
81
+ `"No destination workspaces — N docs left pending."` and stop. Do NOT
82
+ mark_sorted (leave the marks so the user can create a workspace first).
83
+
84
+ ### Step 3. Decide a destination for each doc
85
+
86
+ For each pending doc:
87
+
88
+ 1. `mcp__openwriter__read_pad` with `docId` to read the body.
89
+ 2. Match the doc's content to the best `(workspaceFile, containerId)`:
90
+ - Match content against workspace logline/schema/domain, then against
91
+ container purpose hints + sibling docs.
92
+ - Prefer the most specific matching container; fall back to workspace
93
+ root (`containerId: null`) when no container fits but the workspace
94
+ does.
95
+ - If the doc carries a `proposal`, treat it as a strong prior — use it
96
+ unless the body clearly contradicts it.
97
+ - If the doc is already in a workspace and no better home exists, keep it
98
+ there (you'll still mark it sorted in step 5 — the request is resolved).
99
+ 3. Hold the chosen destination in memory.
100
+
101
+ Judgment guardrails:
102
+
103
+ - **Cross-workspace moves are higher-stakes.** Moving a doc into a
104
+ *different* workspace changes which project it belongs to. Do it when the
105
+ content clearly fits the other workspace better; otherwise re-file within
106
+ the current workspace.
107
+ - **When genuinely torn between two homes**, pick the better-matching one
108
+ and move — do not stall. The move is reversible and reported.
109
+
110
+ ### Step 4. File each doc
111
+
112
+ For each doc with a chosen destination, call `mcp__openwriter__move_item`:
113
+
114
+ ```
115
+ move_item({
116
+ type: "doc",
117
+ workspaceFile: "<destination workspace manifest filename>",
118
+ itemId: "<docId>",
119
+ targetContainerId: "<container id, or omit for workspace root>"
120
+ })
121
+ ```
122
+
123
+ This handles both within-workspace moves and cross-workspace moves (it
124
+ removes the doc from its old workspace and adds it to the new one). Skip the
125
+ move only when the doc is already in the exact chosen destination.
126
+
127
+ ### Step 5. Retire the requests
128
+
129
+ After filing every doc, call `mcp__openwriter__mark_sorted` ONCE with the
130
+ full batch — including docs you decided were already well-placed (their
131
+ request is still resolved):
132
+
133
+ ```
134
+ mark_sorted({ docs: [{ docId }, ...] })
135
+ ```
136
+
137
+ This clears `sortRequest` and stamps `lastSortedAt`, mirroring how
138
+ mark_enriched retires enrichmentStale. Do NOT mark a doc you failed to read
139
+ or could not place.
140
+
141
+ ### Step 6. Report
142
+
143
+ Return a one-paragraph summary in this shape:
144
+
145
+ ```
146
+ Filed N docs: "Title A" → workspace-a / Container, "Title B" → workspace-b / root, ...
147
+ Left pending (if any): "Title C" — <reason>.
148
+ ```
149
+
150
+ Keep titles short. The main agent relays a one-liner to the user. Brevity
151
+ matters.
152
+
153
+ ## Hard rules
154
+
155
+ 1. **Move, never propose.** Your job is to file docs. Don't write
156
+ propose_sort entries — that's the manual sidebar flow, not yours.
157
+ 2. **Never mark a doc you didn't resolve.** mark_sorted only docs you
158
+ actually filed (or confirmed already-home). A doc you couldn't read or
159
+ place stays pending.
160
+ 3. **No destination workspaces → stop, leave pending.** Don't invent a home.
161
+ 4. **One mark_sorted call.** Batch every resolved doc into a single write.
162
+ 5. **No prose to the user.** Return only the summary. Don't explain your
163
+ methodology or apologize for skips. Done is done.
164
+ 6. **Skip docs that fail to read.** If read_pad errors, omit the doc, leave
165
+ it pending, and note it in your summary. Don't loop or retry.
166
+
167
+ ## Worked example
168
+
169
+ Pending: doc "CalorieBot is the easiest way to track calories" (unfiled).
170
+ Workspaces: `paybot-350b05a1.json` (logline: "PayBot product docs +
171
+ marketing"), `book-fatherhood.json` (logline: "Fatherhood book chapters").
172
+
173
+ Read the body → it's product marketing copy for a calorie-tracking app.
174
+ Best match: `paybot-350b05a1.json`, container "Marketing" (purpose: "landing
175
+ + launch copy").
176
+
177
+ ```
178
+ move_item({ type: "doc", workspaceFile: "paybot-350b05a1.json", itemId: "bb4f6c46", targetContainerId: "<marketing-container-id>" })
179
+ mark_sorted({ docs: [{ docId: "bb4f6c46" }] })
180
+ ```
181
+
182
+ Report: `Filed 1 doc: "CalorieBot is the easiest way…" → PayBot / Marketing.`
183
+
184
+ Run the procedure. File the docs. Return the summary. Exit.