openwriter 0.36.2 → 0.37.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.
@@ -12,6 +12,7 @@ import { z } from 'zod';
12
12
  import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync, readConfig } from './helpers.js';
13
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, getIsTemp, 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 { readBlame, summarizeBlame } from './attribution.js';
15
16
  import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
16
17
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
17
18
  import { resolveTypeMeta } from './content-type-meta.js';
@@ -551,7 +552,7 @@ export const TOOL_REGISTRY = [
551
552
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
552
553
  }
553
554
  const newDocId = getDocId();
554
- save();
555
+ save('agent');
555
556
  broadcastDocumentsChanged();
556
557
  broadcastWorkspacesChanged();
557
558
  broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
@@ -692,7 +693,7 @@ export const TOOL_REGISTRY = [
692
693
  }
693
694
  updateDocument(doc);
694
695
  updatePendingCacheForActiveDoc();
695
- save();
696
+ save('agent');
696
697
  // Broadcast sidebar updates first (deferred from create_document) so the doc
697
698
  // entry and spinner removal arrive in the same render cycle
698
699
  broadcastDocumentsChanged();
@@ -880,6 +881,31 @@ export const TOOL_REGISTRY = [
880
881
  return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
881
882
  },
882
883
  },
884
+ {
885
+ name: 'get_attribution',
886
+ description: 'Get human-vs-agent author attribution for a document. Returns the char-weighted composition (% human / % agent / % unknown) plus per-node coarse origin (human | agent | mixed | unknown). Attribution is captured automatically at save time and anchored to sentence content, so it survives edits, splits, and paste-back. "unknown" = content authored before attribution tracking began. Use to report how much of a doc is genuinely author-written vs agent-scaffolded.',
887
+ schema: {
888
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
889
+ },
890
+ handler: async ({ docId }) => {
891
+ const target = resolveDocTarget(docId);
892
+ const blocks = tiptapToBlocks(target.document);
893
+ const blame = readBlame(docId);
894
+ const summary = summarizeBlame(blame, blocks);
895
+ const nodeCounts = { human: 0, agent: 0, mixed: 0, unknown: 0 };
896
+ for (const origin of Object.values(summary.nodes))
897
+ nodeCounts[origin] = (nodeCounts[origin] ?? 0) + 1;
898
+ return { content: [{ type: 'text', text: JSON.stringify({
899
+ docId,
900
+ percent: summary.percent,
901
+ chars: summary.chars,
902
+ nodeOrigins: summary.nodes,
903
+ nodeCounts,
904
+ tracked: blame !== null,
905
+ attributionSince: blame?.attributionSince ?? null,
906
+ }) }] };
907
+ },
908
+ },
883
909
  {
884
910
  name: 'set_metadata',
885
911
  description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately. Lifecycle convention (v0.19.0): use `set_metadata({ status: "canonical" })` when a doc commits to the workspace spine (Beats locks, Research Note becomes load-bearing); use `set_metadata({ status: "draft" })` when a doc is superseded or demoted. Status is the agent\'s field — the enrichment minion never writes it.',
@@ -923,7 +949,7 @@ export const TOOL_REGISTRY = [
923
949
  const meta = getMetadata();
924
950
  for (const key of removed)
925
951
  delete meta[key];
926
- save();
952
+ save('agent');
927
953
  broadcastMetadataChanged(getMetadata());
928
954
  if (cleaned.title) {
929
955
  // Reached only on temp-file creation titling — hot promote.
@@ -1015,7 +1041,7 @@ export const TOOL_REGISTRY = [
1015
1041
  for (const k of LEGACY_FIELDS_TO_RETIRE)
1016
1042
  delete liveMeta[k];
1017
1043
  bumpDocVersion();
1018
- save();
1044
+ save('agent');
1019
1045
  broadcastMetadataChanged(getMetadata());
1020
1046
  }
1021
1047
  else {
@@ -1647,7 +1673,7 @@ export const TOOL_REGISTRY = [
1647
1673
  doc.content.push(pendingImage);
1648
1674
  }
1649
1675
  updateDocument(doc);
1650
- save();
1676
+ save('agent');
1651
1677
  setAgentLockActive();
1652
1678
  broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
1653
1679
  return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
@@ -1668,7 +1694,7 @@ export const TOOL_REGISTRY = [
1668
1694
  articleContext.coverImage = src;
1669
1695
  articleContext.coverImages = existing;
1670
1696
  setMetadata({ articleContext });
1671
- save();
1697
+ save('agent');
1672
1698
  broadcastMetadataChanged(getMetadata());
1673
1699
  return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
1674
1700
  }
@@ -1970,7 +1996,7 @@ export const TOOL_REGISTRY = [
1970
1996
  // Active doc: mutate state.metadata and let save() persist the frontmatter.
1971
1997
  // save()'s writeToDisk path invalidates the backlinks cache.
1972
1998
  setMetadata({ references: newReferences });
1973
- save();
1999
+ save('agent');
1974
2000
  broadcastMetadataChanged(getMetadata());
1975
2001
  }
1976
2002
  else {
@@ -10,6 +10,8 @@ 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, forceSnapshot } from './versions.js';
13
+ import { captureAttribution, bindBlameToVersion } from './attribution.js';
14
+ import { scheduleAgentCommit } from './commits.js';
13
15
  import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
14
16
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
17
  import { matchNodes } from './node-matcher.js';
@@ -1087,12 +1089,29 @@ export function resetDocVersion() {
1087
1089
  // adr: adr/pending-overlay-model.md
1088
1090
  let saveTimer = null;
1089
1091
  const SAVE_DEBOUNCE_MS = 500;
1090
- export function debouncedSave() {
1092
+ // The actor whose edits are sitting in the current debounce window. Set at
1093
+ // schedule time (NOT flush time) so attribution can't bleed across actors.
1094
+ // adr: adr/document-history-attribution.md (invariant 3 — no module-global shim;
1095
+ // this is save-scoped: it tracks the SCHEDULER of the pending batch and is
1096
+ // cleared the moment that batch flushes).
1097
+ let pendingSaveActor = null;
1098
+ export function debouncedSave(actor = 'human') {
1099
+ // If a save is already pending under a DIFFERENT actor, flush that batch now
1100
+ // under ITS actor before this actor's edits join the window — otherwise the
1101
+ // single coalesced flush would attribute both actors' new sentences to one.
1102
+ if (saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
1103
+ clearTimeout(saveTimer);
1104
+ saveTimer = null;
1105
+ save(pendingSaveActor);
1106
+ }
1107
+ pendingSaveActor = actor;
1091
1108
  if (saveTimer)
1092
1109
  clearTimeout(saveTimer);
1093
1110
  saveTimer = setTimeout(() => {
1094
1111
  saveTimer = null;
1095
- save();
1112
+ const a = pendingSaveActor ?? 'human';
1113
+ pendingSaveActor = null;
1114
+ save(a);
1096
1115
  }, SAVE_DEBOUNCE_MS);
1097
1116
  }
1098
1117
  /** Cancel any pending debounced save. Call before doc switch (which does its own save). */
@@ -1101,6 +1120,7 @@ export function cancelDebouncedSave() {
1101
1120
  clearTimeout(saveTimer);
1102
1121
  saveTimer = null;
1103
1122
  }
1123
+ pendingSaveActor = null;
1104
1124
  }
1105
1125
  export function applyChanges(changes) {
1106
1126
  // Bump version BEFORE applying so new overlay entries created by
@@ -1120,8 +1140,10 @@ export function applyChanges(changes) {
1120
1140
  for (const listener of listeners) {
1121
1141
  listener(processed, version);
1122
1142
  }
1123
- // Debounced save — coalesces rapid agent writes into a single disk write
1124
- debouncedSave();
1143
+ // Debounced save — coalesces rapid agent writes into a single disk write.
1144
+ // applyChanges is an AGENT door (MCP write tools land here); tag the batch
1145
+ // so the flush attributes its new content to the agent.
1146
+ debouncedSave('agent');
1125
1147
  // Update pending doc cache for the active document
1126
1148
  updatePendingCacheForActiveDoc();
1127
1149
  // Find the last created node ID for chaining inserts
@@ -2129,7 +2151,7 @@ export function getPendingDocInfo() {
2129
2151
  // ============================================================================
2130
2152
  // PERSISTENCE
2131
2153
  // ============================================================================
2132
- function writeToDisk() {
2154
+ function writeToDisk(actor = 'human') {
2133
2155
  // No-op gate: when the in-memory document hasn't been mutated since the
2134
2156
  // last successful write (or byte-equality skip), bail before any work.
2135
2157
  // Skips the full serialize + matcher pipeline (~50ms on medium docs), the
@@ -2270,6 +2292,34 @@ function writeToDisk() {
2270
2292
  // The serializer no longer emits `meta.pending` (overlay handles that).
2271
2293
  const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
2272
2294
  markdown = result.markdown;
2295
+ // Author attribution (Tier A + Tier B). MUST run here — alongside the
2296
+ // overlay save and BEFORE the "content identical" / external-write /
2297
+ // destructive-save guards below. An agent that adds ONLY pending content
2298
+ // leaves the canonical body byte-identical, so those guards short-circuit
2299
+ // the disk write; but the MERGED state (state.document) DID change and was
2300
+ // just persisted to the overlay sidecar — so its attribution must be
2301
+ // captured too. Capture from the MERGED doc (NOT canonical) so an agent's
2302
+ // pending proposals are stamped 'agent' at write time; because blame is
2303
+ // anchored to the sentence content hash, a later human ACCEPT leaves the
2304
+ // hash unchanged and the agent origin holds (accept never launders).
2305
+ // The version binding happens after the body write (a pending-only save
2306
+ // produces no new snapshot, which is correct — it isn't a new version).
2307
+ // adr: adr/document-history-attribution.md
2308
+ if (state.docId) {
2309
+ try {
2310
+ captureAttribution(state.docId, tiptapToBlocks(state.document), actor, Date.now());
2311
+ // Agent-finished commit trigger (debounced, per-doc). Fires from the
2312
+ // CAPTURE site — the only place that reliably sees every agent edit tool
2313
+ // (write_to_pad/populate/edit_text) with the exact target docId. The
2314
+ // spinner broadcast (broadcastWritingFinished) was the wrong hook: not
2315
+ // all write tools fire it. adr: adr/document-history-attribution.md
2316
+ if (actor === 'agent' && state.filePath)
2317
+ scheduleAgentCommit(state.docId, state.filePath);
2318
+ }
2319
+ catch (err) {
2320
+ console.error('[Attribution] capture failed:', err);
2321
+ }
2322
+ }
2273
2323
  }
2274
2324
  if (existsSync(state.filePath)) {
2275
2325
  // Skip write if content is identical (prevents phantom git changes on doc switch)
@@ -2342,11 +2392,22 @@ function writeToDisk() {
2342
2392
  // Record that disk now matches in-memory at this docVersion. Subsequent
2343
2393
  // save() calls without further mutations will bail at the top-level gate.
2344
2394
  lastSavedDocVersion = docVersion;
2345
- // Best-effort version snapshot — never blocks saves
2395
+ // Best-effort version snapshot — never blocks saves. Returns the cut ts
2396
+ // (or null if throttled/unchanged) so attribution can bind to this version.
2397
+ // Attribution itself was already captured in the else branch above (so a
2398
+ // pending-only save, which skips the body write, is still attributed); here
2399
+ // we just stamp the blame with the version cut when a real snapshot landed.
2400
+ let snapshotTs = null;
2346
2401
  try {
2347
- snapshotIfNeeded(state.docId, state.filePath);
2402
+ snapshotTs = snapshotIfNeeded(state.docId, state.filePath);
2348
2403
  }
2349
2404
  catch { /* ignore */ }
2405
+ if (snapshotTs && !isExternalDoc(state.filePath) && state.docId) {
2406
+ try {
2407
+ bindBlameToVersion(state.docId, snapshotTs);
2408
+ }
2409
+ catch { /* best-effort */ }
2410
+ }
2350
2411
  // Auto-sync references from prose: legacy `doc:` prose links still render
2351
2412
  // (PadLink extension), but the graph/crawl/backlinks-panel read the
2352
2413
  // structural `references:` field. After every save, scan the body for
@@ -2370,7 +2431,22 @@ function writeToDisk() {
2370
2431
  invalidateBacklinksCache();
2371
2432
  }
2372
2433
  }
2373
- export function save() {
2434
+ export function save(actor) {
2435
+ // Resolve the save-scoped actor. Explicit arg wins; else fall back to the
2436
+ // actor who scheduled the pending debounce batch this save is flushing; else
2437
+ // 'human' (system/user-initiated saves that don't author prose produce no
2438
+ // attribution spans anyway). adr: adr/document-history-attribution.md
2439
+ // If an explicit actor differs from a pending-batch actor, flush that batch
2440
+ // first under its own actor so this save never absorbs another actor's edits.
2441
+ if (actor && saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
2442
+ clearTimeout(saveTimer);
2443
+ saveTimer = null;
2444
+ const prior = pendingSaveActor;
2445
+ pendingSaveActor = null;
2446
+ writeToDisk(prior);
2447
+ }
2448
+ const resolvedActor = actor ?? pendingSaveActor ?? 'human';
2449
+ pendingSaveActor = null;
2374
2450
  // Auto-title from body content if the title is still default/empty.
2375
2451
  // Runs BEFORE filePath assignment so a brand-new doc lands at its
2376
2452
  // derived-title filename directly (no temp-file detour). For already-
@@ -2406,7 +2482,7 @@ export function save() {
2406
2482
  state.isTemp = false;
2407
2483
  }
2408
2484
  }
2409
- writeToDisk();
2485
+ writeToDisk(resolvedActor);
2410
2486
  }
2411
2487
  export function load() {
2412
2488
  ensureDataDir();
@@ -2809,6 +2885,23 @@ export function saveDocToFile(filename, doc) {
2809
2885
  if (docId) {
2810
2886
  const overlay = extractOverlay(doc);
2811
2887
  saveOverlay(docId, overlay);
2888
+ // This routes a BROWSER doc-update to a non-active file — the content is
2889
+ // the human's. Snapshot + attribute as 'human'. adr: adr/document-history-attribution.md
2890
+ if (!isExternalDoc(targetPath)) {
2891
+ let snapshotTs = null;
2892
+ try {
2893
+ snapshotTs = snapshotIfNeeded(docId, targetPath);
2894
+ }
2895
+ catch { /* ignore */ }
2896
+ try {
2897
+ captureAttribution(docId, tiptapToBlocks(doc), 'human', Date.now());
2898
+ if (snapshotTs)
2899
+ bindBlameToVersion(docId, snapshotTs);
2900
+ }
2901
+ catch (err) {
2902
+ console.error('[Attribution] saveDocToFile capture failed:', err);
2903
+ }
2904
+ }
2812
2905
  }
2813
2906
  // Backlinks cache invalidate — browser sent a doc-update for a non-active
2814
2907
  // doc; the prose-link set on that doc may have changed.
@@ -3041,6 +3134,29 @@ function flushDocToFile(filename, doc, title, metadata) {
3041
3134
  if (docId) {
3042
3135
  const overlay = extractOverlay(doc);
3043
3136
  saveOverlay(docId, overlay);
3137
+ // Door 3: non-active agent writes (populate_document / write_to_pad /
3138
+ // edit_text targeting a non-active doc). This path bypasses writeToDisk
3139
+ // and historically produced NO snapshot — so it had neither a restore
3140
+ // floor nor attribution. Add both. This door is ALWAYS the agent (humans
3141
+ // only edit the active doc in the browser), so actor is 'agent'.
3142
+ // adr: adr/document-history-attribution.md
3143
+ if (!isExternalDoc(targetPath)) {
3144
+ let snapshotTs = null;
3145
+ try {
3146
+ snapshotTs = snapshotIfNeeded(docId, targetPath);
3147
+ }
3148
+ catch { /* ignore */ }
3149
+ try {
3150
+ captureAttribution(docId, tiptapToBlocks(doc), 'agent', Date.now());
3151
+ if (snapshotTs)
3152
+ bindBlameToVersion(docId, snapshotTs);
3153
+ // Door 3 is always an agent write (non-active). Schedule its commit.
3154
+ scheduleAgentCommit(docId, targetPath);
3155
+ }
3156
+ catch (err) {
3157
+ console.error('[Attribution] door-3 capture failed:', err);
3158
+ }
3159
+ }
3044
3160
  }
3045
3161
  setPendingCacheEntry(filename, countPending(doc.content));
3046
3162
  // Backlinks cache invalidation — non-active write paths (populate_document on
@@ -70,30 +70,46 @@ function seedLastSnapshot(docId) {
70
70
  const content = readFileSync(join(dir, `${latest}.md`), 'utf-8');
71
71
  lastSnapshot.set(docId, { time: latest, hash: contentHash(content) });
72
72
  }
73
+ /**
74
+ * Pick a free, strictly-increasing integer timestamp for a new snapshot file.
75
+ * Two snapshots in the same millisecond would collide on `${Date.now()}.md`;
76
+ * bumping (rather than adding a `-N` suffix) keeps the filename a parseable
77
+ * integer so listVersions/seedLastSnapshot's parseInt never yields NaN.
78
+ */
79
+ function freeSnapshotTs(docId) {
80
+ const dir = docDir(docId);
81
+ let now = Date.now();
82
+ while (existsSync(join(dir, `${now}.md`)))
83
+ now++;
84
+ return now;
85
+ }
73
86
  /**
74
87
  * Snapshot after every writeToDisk() — skips if content unchanged or within throttle window.
75
- * Called in best-effort mode (caller wraps in try/catch).
88
+ * Called in best-effort mode (caller wraps in try/catch). Returns the timestamp
89
+ * written (so attribution can bind the blame state to this version cut), or
90
+ * null when the snapshot was skipped.
76
91
  */
77
92
  export function snapshotIfNeeded(docId, filePath) {
78
93
  if (!docId || !filePath || !existsSync(filePath))
79
- return;
94
+ return null;
80
95
  seedLastSnapshot(docId);
81
96
  const markdown = readFileSync(filePath, 'utf-8');
82
97
  const hash = contentHash(markdown);
83
- const now = Date.now();
84
98
  const last = lastSnapshot.get(docId);
85
99
  if (last) {
86
100
  // Skip if content hasn't changed (regardless of time)
87
101
  if (hash === last.hash)
88
- return;
102
+ return null;
89
103
  // Skip if within minimum interval even if content changed
90
- if ((now - last.time) < MIN_INTERVAL_MS)
91
- return;
104
+ if ((Date.now() - last.time) < MIN_INTERVAL_MS)
105
+ return null;
92
106
  }
93
107
  ensureDocDir(docId);
108
+ const now = freeSnapshotTs(docId);
94
109
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
95
110
  lastSnapshot.set(docId, { time: now, hash });
96
111
  pruneVersions(docId);
112
+ return now;
97
113
  }
98
114
  /**
99
115
  * Force a snapshot regardless of dedup. Used before restores as a safety net.
@@ -103,8 +119,8 @@ export function forceSnapshot(docId, filePath) {
103
119
  return;
104
120
  const markdown = readFileSync(filePath, 'utf-8');
105
121
  const hash = contentHash(markdown);
106
- const now = Date.now();
107
122
  ensureDocDir(docId);
123
+ const now = freeSnapshotTs(docId);
108
124
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
109
125
  lastSnapshot.set(docId, { time: now, hash });
110
126
  }
@@ -120,8 +136,15 @@ export function writeSnapshotMarkdown(docId, markdown) {
120
136
  if (!docId)
121
137
  return 0;
122
138
  const hash = contentHash(markdown);
123
- const now = Date.now();
139
+ // Dedup: if the most recent snapshot already holds this exact content, reuse
140
+ // its timestamp instead of writing a duplicate .md. Prevents a commit
141
+ // snapshot from duplicating the auto-snapshot the same save just wrote.
142
+ seedLastSnapshot(docId);
143
+ const last = lastSnapshot.get(docId);
144
+ if (last && last.hash === hash)
145
+ return last.time;
124
146
  ensureDocDir(docId);
147
+ const now = freeSnapshotTs(docId);
125
148
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
126
149
  lastSnapshot.set(docId, { time: now, hash });
127
150
  return now;
package/dist/server/ws.js CHANGED
@@ -5,6 +5,7 @@ import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
6
6
  import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
+ import { commitFromFile } from './commits.js';
8
9
  import { canonicalizeIdentifier } from './helpers.js';
9
10
  import { nodeTextPreview, diagLog } from './pending-overlay.js';
10
11
  import { generateRequestId, withRequestId } from './logger.js';
@@ -294,7 +295,7 @@ export function setupWebSocket(server) {
294
295
  const result = syncBrowserDocUpdate(msg.document, browserVersion);
295
296
  diagLog(`[WS] doc-update SYNC-MERGED stale v${browserVersion}→v${serverVersion} preservedServerEntries=${result.preservedServerEntries}`);
296
297
  updatePendingCacheForActiveDoc();
297
- debouncedSave();
298
+ debouncedSave('human');
298
299
  }
299
300
  else if (browserFilename && browserFilename !== getActiveFilename()) {
300
301
  // Browser sent a doc-update for a different document (race: server switched away).
@@ -321,7 +322,7 @@ export function setupWebSocket(server) {
321
322
  diagLog(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
322
323
  updateDocument(msg.document);
323
324
  updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
324
- debouncedSave();
325
+ debouncedSave('human');
325
326
  }
326
327
  }
327
328
  // Browser requests fresh state on reconnect (instead of pushing stale state)
@@ -351,7 +352,7 @@ export function setupWebSocket(server) {
351
352
  broadcastDocumentsChanged();
352
353
  }
353
354
  else {
354
- debouncedSave();
355
+ debouncedSave('human');
355
356
  debouncedBroadcastDocumentsChanged();
356
357
  }
357
358
  }
@@ -512,6 +513,19 @@ export function setupWebSocket(server) {
512
513
  stripPendingAttrs();
513
514
  save();
514
515
  updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
516
+ // Commit trigger: accepting agent changes is a version boundary.
517
+ // Bundles the agent's attributed edit-events into a commit (the
518
+ // changeset actors reflect who authored; trigger records the accept).
519
+ // adr: adr/document-history-attribution.md
520
+ if (action === 'accept') {
521
+ try {
522
+ const did = getDocId();
523
+ const fp = getFilePath();
524
+ if (did && fp)
525
+ commitFromFile(did, fp, { trigger: 'accept', actor: 'human', nowTs: Date.now() });
526
+ }
527
+ catch { /* best-effort */ }
528
+ }
515
529
  }
516
530
  else {
517
531
  // Race path: resolved doc is NOT the active one (server switched away).
@@ -691,6 +705,11 @@ activityDocId) {
691
705
  });
692
706
  return writeKey;
693
707
  }
708
+ // NOTE: the agent-finished commit trigger lives at the CAPTURE site
709
+ // (state.ts writeToDisk / flushDocToFile via scheduleAgentCommit), NOT here.
710
+ // broadcastWritingFinished is the spinner mechanism and is not fired by every
711
+ // agent edit tool (write_to_pad never calls it), so it was the wrong hook.
712
+ // adr: adr/document-history-attribution.md
694
713
  // key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
695
714
  export function broadcastWritingFinished(key) {
696
715
  if (key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.36.2",
3
+ "version": "0.37.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
@@ -40,7 +40,7 @@ You are a writing collaborator. You read documents and make edits **exclusively
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
 
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.
43
+ **If the subagent isn't installed** (older openwriter, or the user skipped setup): 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 setup` 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
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.
@@ -60,7 +60,7 @@ You are a writing collaborator. You read documents and make edits **exclusively
60
60
 
61
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.
62
62
 
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.
63
+ **If the subagent isn't installed** (older openwriter, or the user skipped setup): 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 setup` and restart Claude Code." Then proceed with their original request; don't loop on the failure.
64
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:
65
65
 
66
66
  **Doc level** (one link, header bold):
@@ -650,7 +650,7 @@ The plugin ships with the Author's Voice skill built in (`plugins/authors-voice/
650
650
 
651
651
  ```bash
652
652
  npm install -g openwriter@latest
653
- npx openwriter install-skill
653
+ npx openwriter setup
654
654
  ```
655
655
 
656
656
  Then restart your Claude Code session (`/mcp` to reconnect).
@@ -3,7 +3,7 @@
3
3
  ## Quick install
4
4
 
5
5
  ```bash
6
- npx openwriter install-skill
6
+ npx openwriter setup
7
7
  ```
8
8
 
9
9
  This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
@@ -54,7 +54,7 @@ The enrichment minion is NOT auto-discovered. Place it at one of:
54
54
  - `~/.config/opencode/agents/openwriter-enrichment-minion.md` (global, all projects)
55
55
  - `.opencode/agents/openwriter-enrichment-minion.md` (this project only, repo root)
56
56
 
57
- Source file lives at `~/.claude/skills/openwriter/agents/openwriter-enrichment-minion.md` after `npx openwriter install-skill`. Copy it to one of the paths above and restart OpenCode. The filename becomes the agent name OpenCode resolves when the parent dispatches it.
57
+ Source file lives at `~/.claude/skills/openwriter/agents/openwriter-enrichment-minion.md` after `npx openwriter setup`. Copy it to one of the paths above and restart OpenCode. The filename becomes the agent name OpenCode resolves when the parent dispatches it.
58
58
 
59
59
  ## After setup
60
60