@erdemtuna/doc-review 0.7.0 → 0.8.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.
package/src/serialize.js CHANGED
@@ -9,20 +9,6 @@
9
9
  export const UI_ATTR = "data-eh-ui";
10
10
  export const MARK_ATTR = "data-eh-mark";
11
11
 
12
- /** Keep a hydrated app editable even if its framework removes the attribute. */
13
- export function keepBodyEditable(body) {
14
- const enforce = () => {
15
- if (body.getAttribute("contenteditable") !== "true") {
16
- body.setAttribute("contenteditable", "true");
17
- }
18
- };
19
- enforce();
20
- const Observer = body.ownerDocument.defaultView.MutationObserver;
21
- const observer = new Observer(enforce);
22
- observer.observe(body, { attributes: true, attributeFilter: ["contenteditable"] });
23
- return observer;
24
- }
25
-
26
12
  /** Serialize a document to the HTML that should live on disk. */
27
13
  export function serializeDocument(doc) {
28
14
  const clone = doc.documentElement.cloneNode(true);
package/src/server.js CHANGED
@@ -4,11 +4,13 @@ import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { atomicWrite, Store, resolveAsset } from "./state.js";
7
+ import { normalizeCommentAnchor } from "./comment-anchor.js";
7
8
  import { injectSdk, stripSdk } from "./html-transform.js";
8
9
  import { isMarkdown, renderMarkdownPage } from "./markdown.js";
9
10
  import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath, stateDir, targetKey } from "./paths.js";
10
11
  import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
11
12
  import { invocation, shellQuote } from "./setup.js";
13
+ import { limitEditFields } from "./edit-limits.js";
12
14
 
13
15
  const here = path.dirname(fileURLToPath(import.meta.url));
14
16
 
@@ -223,8 +225,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
223
225
  const current = hash(html);
224
226
  // Our own autosave must never bounce back as a reload.
225
227
  if (lastWritten.get(key) === current) return;
228
+ try {
229
+ store.setPristine(key, html, { keepEdits: isMarkdown(page.file) });
230
+ } catch (err) {
231
+ console.error(`Could not refresh review baseline for ${page.file}: ${err.message}`);
232
+ return;
233
+ }
226
234
  lastWritten.set(key, current);
227
- store.setPristine(key, html);
228
235
  for (const session of sessionsForKey(key)) {
229
236
  invalidateSessionRender(session);
230
237
  emit(session, "reload", { key });
@@ -274,7 +281,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
274
281
  id: c.id,
275
282
  kind: c.kind,
276
283
  quote: c.quote,
277
- anchor: c.anchor,
284
+ anchor: c.anchor == null ? c.anchor : normalizeCommentAnchor(c.kind, c.anchor),
278
285
  feedback: c.feedback,
279
286
  ...(c.correction ? { correction: true, correction_of: c.correctionOf } : {}),
280
287
  })),
@@ -283,6 +290,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
283
290
  kind: e.kind,
284
291
  before: e.before,
285
292
  after: e.after,
293
+ ...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
286
294
  ...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
287
295
  ...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
288
296
  ...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
@@ -313,6 +321,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
313
321
  const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
314
322
  const hasUrl = pages.some((p) => p.kind === "url");
315
323
  const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
324
+ const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
316
325
  const id = `b_${crypto.randomBytes(12).toString("hex")}`;
317
326
  const entry = store.page(session.entryKey);
318
327
  const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
@@ -325,9 +334,14 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
325
334
  sent_at: new Date().toISOString(),
326
335
  next_step:
327
336
  "Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
328
- "changes the human already made: `after` is their exact new wording, so carry it across verbatim, and " +
337
+ "changes the human already made: unless marked `truncated`, `after` is their exact new wording, so carry it across verbatim, and " +
329
338
  "never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
330
339
  "use the HTML version, translated into the source's own syntax. " +
340
+ (hasTruncation
341
+ ? "Some edits are marked `truncated`; `truncated_fields` lists incomplete fields. Never apply incomplete text or HTML " +
342
+ "as a complete replacement or invent missing content. Recover the full edit only from an authoritative source, " +
343
+ "or ask the user for it. Do not acknowledge this batch until all feedback is handled. "
344
+ : "") +
331
345
  (hasMarkdown
332
346
  ? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
333
347
  "the change to the Markdown source, keeping its formatting syntax. "
@@ -562,6 +576,10 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
562
576
  if (route === "/chrome.css") return serveFile(res, path.join(here, "chrome.css"));
563
577
  if (route === "/chrome.js") return serveFile(res, path.join(here, "chrome-client.js"));
564
578
  if (route === "/chrome-session.js") return serveFile(res, path.join(here, "chrome-session.js"));
579
+ if (route === "/icons.js") return serveFile(res, path.join(here, "icons.js"), opaqueModuleCors(req));
580
+ if (route === "/positioning.js") return serveFile(res, path.join(here, "positioning.js"), opaqueModuleCors(req));
581
+ if (route === "/review-mode.js") return serveFile(res, path.join(here, "review-mode.js"), opaqueModuleCors(req));
582
+ if (route === "/comment-target.js") return serveFile(res, path.join(here, "comment-target.js"), opaqueModuleCors(req));
565
583
  if (route === "/sdk.js") return serveFile(res, path.join(here, "sdk.js"), opaqueModuleCors(req));
566
584
  if (route === "/editing.js") return serveFile(res, path.join(here, "editing.js"), opaqueModuleCors(req));
567
585
  if (route === "/anchor-text.js") return serveFile(res, path.join(here, "anchor-text.js"), opaqueModuleCors(req));
@@ -829,15 +847,21 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
829
847
 
830
848
  if (action === "comment" && req.method === "POST") {
831
849
  const body = await readBody(req);
850
+ const kind = body.kind === "element" ? "element" : "selection";
851
+ const feedback = String(body.feedback || "").trim();
832
852
  const comment = {
833
853
  id: uid("c"),
834
- kind: body.kind === "element" ? "element" : "selection",
854
+ kind,
835
855
  quote: String(body.quote || ""),
836
- anchor: body.anchor || null,
837
- feedback: String(body.feedback || ""),
856
+ anchor: normalizeCommentAnchor(
857
+ kind,
858
+ body.anchor || (kind === "selection" ? { quote: String(body.quote || "") } : null)
859
+ ),
860
+ feedback,
838
861
  createdAt: Date.now(),
839
862
  };
840
863
  if (!comment.feedback) return json(res, 400, { error: "empty feedback" });
864
+ if (!comment.anchor) return json(res, 400, { error: "invalid comment anchor" });
841
865
  store.addComment(key, comment);
842
866
  return json(res, 200, { comment, page: pageState(key) });
843
867
  }
@@ -862,7 +886,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
862
886
  const body = await readBody(req);
863
887
  const label = String(body.label || "Document");
864
888
  const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
865
- const cap = (s) => (typeof s === "string" ? s.slice(0, 4000) : undefined);
889
+ const limited = limitEditFields({
890
+ before: body.before, after: body.after, before_html: body.before_html, after_html: body.after_html,
891
+ ...(kind === "moved" ? { moved_after: body.moved_after, moved_before: body.moved_before } : {}),
892
+ });
893
+ const fields = limited.fields;
866
894
  const stagedRoot = path.join(stateDir(), "pasted", key);
867
895
  const stagedAssets = Array.isArray(body.staged_assets)
868
896
  ? body.staged_assets
@@ -882,10 +910,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
882
910
  .map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
883
911
  : [];
884
912
  const extra = {
885
- ...(kind === "moved" ? { moved_after: cap(body.moved_after) || "", moved_before: cap(body.moved_before) || "" } : {}),
913
+ truncated: limited.truncated,
914
+ truncated_fields: limited.truncated_fields,
915
+ ...(kind === "moved" ? { moved_after: fields.moved_after || "", moved_before: fields.moved_before || "" } : {}),
886
916
  ...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
887
917
  };
888
- store.addEdit(key, label, kind, cap(body.before), cap(body.after), cap(body.before_html), cap(body.after_html), extra);
918
+ store.addEdit(key, label, kind, fields.before, fields.after, fields.before_html, fields.after_html, extra);
889
919
  return json(res, 200, { page: pageState(key) });
890
920
  }
891
921
 
@@ -0,0 +1,128 @@
1
+ export const GUIDANCE_BEGIN = "<!-- BEGIN doc-review -->";
2
+ export const GUIDANCE_END = "<!-- END doc-review -->";
3
+
4
+ // Historical generated text is deliberately independent of the current guidance.
5
+ const LEGACY_TAIL = `
6
+ \`npx -y @erdemtuna/doc-review <file.html>\`. For a locally running web page, open the real
7
+ route with \`npx -y @erdemtuna/doc-review http://localhost:3000/path\` instead of recreating
8
+ it as a static file. Then block on
9
+ \`npx -y @erdemtuna/doc-review poll <target> --timeout 600\` until they send feedback.
10
+ If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
11
+ poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
12
+ arrives, apply it, then run the exact acknowledgement command in its
13
+ \`next_step\`, which uses \`--ack <batch_id>\`.
14
+
15
+ Keep the poll command in the foreground and do not end the turn while it waits.
16
+ If the shell returns a process or session handle, keep waiting on that handle until
17
+ the command exits. \`npx -y @erdemtuna/doc-review status <target>\` reports instantly
18
+ whether feedback is already waiting, without blocking.
19
+
20
+ The batch groups feedback by page under \`pages\`, so fix every page listed. Items
21
+ under \`edits\` are changes the user already made: \`after\` is their exact wording,
22
+ so carry it across verbatim and never revert it — and if the HTML was generated
23
+ from MDX or Markdown, apply it to the source too. Markdown files open rendered
24
+ and are never written by doc-review: apply their comments and edits to the
25
+ Markdown source, keeping its syntax. There is no reply channel; the user sees
26
+ your work when the page reloads. For a localhost page, direct edits and deletions
27
+ arrive with \`kind: "url"\`; find and update the matching MDX, TSX, template, or
28
+ component source. Never write the rendered HTTP response over project source.`;
29
+
30
+ export const LEGACY_GUIDANCE = Object.freeze([
31
+ `## Reviewing files and localhost pages with doc-review
32
+
33
+ After writing an HTML or Markdown file the user will read, open it for them with${LEGACY_TAIL}`,
34
+ `## Reviewing files and localhost pages with doc-review
35
+
36
+ Start only when the user explicitly invokes /doc-review or requests an
37
+ interactive browser review. Writing, updating, discussing, or generically reviewing
38
+ content does not authorize opening a review or polling. Another skill's automatic
39
+ review step is not user permission. Otherwise respond normally.
40
+
41
+ After that explicit request, open the requested HTML or Markdown file with${LEGACY_TAIL}`,
42
+ ]);
43
+
44
+ const migrationMessage = "AGENTS.md contains custom or unrecognized doc-review guidance — left it unchanged. " +
45
+ "Manually reconcile that guidance, then wrap only the setup-owned section in " +
46
+ `${GUIDANCE_BEGIN} and ${GUIDANCE_END}, or remove it and re-run setup.`;
47
+
48
+ function invalidMarkers() {
49
+ throw new Error("AGENTS.md has malformed, duplicate, or ambiguous doc-review ownership markers. " +
50
+ "Keep exactly one standalone BEGIN/END pair in order, then re-run setup. No setup files were changed.");
51
+ }
52
+
53
+ function render(body, newline) {
54
+ return `${GUIDANCE_BEGIN}\n${body.trim()}\n${GUIDANCE_END}`.replaceAll("\n", newline);
55
+ }
56
+
57
+ /** Plan the complete edit before setup writes any files. Never normalize user text. */
58
+ export function updateGuidance(existing, body) {
59
+ const lines = [...existing.matchAll(/[^\n]*(?:\n|$)/g)]
60
+ .filter((match) => match[0])
61
+ .map((match) => ({
62
+ text: match[0].replace(/\r?\n$/, ""),
63
+ start: match.index,
64
+ end: match.index + match[0].replace(/\r?\n$/, "").length,
65
+ }));
66
+ const markers = lines.filter(({ text }) => /doc-review/i.test(text) &&
67
+ (/\b(?:BEGIN|END)\b/i.test(text) && /<!--|-->|^\s*(?:BEGIN|END)\b/i.test(text)));
68
+
69
+ if (markers.length) {
70
+ if (markers.length !== 2 || markers[0].text !== GUIDANCE_BEGIN || markers[1].text !== GUIDANCE_END) {
71
+ invalidMarkers();
72
+ }
73
+ const [begin, end] = markers;
74
+ const newline = existing.slice(begin.end).startsWith("\r\n") ? "\r\n" : "\n";
75
+ return {
76
+ contents: existing.slice(0, begin.start) + render(body, newline) + existing.slice(end.end),
77
+ message: "Updated setup-owned AGENTS.md guidance (Codex)",
78
+ };
79
+ }
80
+
81
+ const candidates = [];
82
+ for (const legacy of LEGACY_GUIDANCE) {
83
+ for (const command of ["npx -y @erdemtuna/doc-review", "doc-review"]) {
84
+ for (const newline of ["\n", "\r\n"]) {
85
+ const text = legacy.replaceAll("npx -y @erdemtuna/doc-review", command).replaceAll("\n", newline);
86
+ let start = existing.indexOf(text);
87
+ while (start !== -1) {
88
+ const end = start + text.length;
89
+ const before = existing.slice(0, start);
90
+ const after = existing.slice(end);
91
+ // A complete generated section, not a substring of customized prose.
92
+ if ((start === 0 || before.endsWith("\n") || before === "\uFEFF") &&
93
+ /^(?:\r?\n|$)/.test(after) &&
94
+ /^(?:\s*$|(?:\r?\n)+(?=#{1,2} ))/.test(after)) {
95
+ candidates.push({ start, end, newline });
96
+ }
97
+ start = existing.indexOf(text, start + text.length);
98
+ }
99
+ }
100
+ }
101
+ }
102
+ if (candidates.length > 1) {
103
+ throw new Error("AGENTS.md contains multiple legacy doc-review sections; ownership is ambiguous. " +
104
+ "Reconcile them before re-running setup. No setup files were changed.");
105
+ }
106
+ if (candidates.length === 1) {
107
+ const { start, end, newline } = candidates[0];
108
+ const surrounding = existing.slice(0, start) + existing.slice(end);
109
+ if (!/doc-review/i.test(surrounding)) {
110
+ return {
111
+ contents: existing.slice(0, start) + render(body, newline) + existing.slice(end),
112
+ message: "Migrated legacy AGENTS.md guidance to setup-owned markers (Codex)",
113
+ };
114
+ }
115
+ }
116
+ if (/doc-review/i.test(existing)) {
117
+ return { contents: existing, message: migrationMessage };
118
+ }
119
+
120
+ const newline = existing.includes("\r\n") ? "\r\n" : "\n";
121
+ const separator = !existing || existing.endsWith(newline + newline)
122
+ ? ""
123
+ : existing.endsWith("\n") ? newline : newline + newline;
124
+ return {
125
+ contents: existing + separator + render(body, newline) + newline,
126
+ message: `${existing ? "Updated" : "Created"} AGENTS.md (Codex)`,
127
+ };
128
+ }
package/src/setup.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { updateGuidance } from "./setup-guidance.js";
6
7
 
7
8
  const here = path.dirname(fileURLToPath(import.meta.url));
8
9
  export const PACKAGE_NAME = "@erdemtuna/doc-review";
@@ -50,15 +51,26 @@ export const skillFor = (cmd) => readSkill().replaceAll(NPX_COMMAND, cmd);
50
51
  const CODEX_BLOCK = `
51
52
  ## Reviewing files and localhost pages with doc-review
52
53
 
53
- After writing an HTML or Markdown file the user will read, open it for them with
54
+ Start only when the user explicitly invokes /doc-review or requests an
55
+ interactive browser review. Writing, updating, discussing, or generically reviewing
56
+ content does not authorize opening a review or polling. Another skill's automatic
57
+ review step is not user permission. Otherwise respond normally.
58
+
59
+ After that explicit request, open the requested HTML or Markdown file with
54
60
  \`${NPX_COMMAND} <file.html>\`. For a locally running web page, open the real
55
61
  route with \`${NPX_COMMAND} http://localhost:3000/path\` instead of recreating
56
62
  it as a static file. Then block on
57
63
  \`${NPX_COMMAND} poll <target> --timeout 600\` until they send feedback.
58
64
  If it prints \`{"status":"timeout"}\`, no feedback arrived yet — run the same
59
65
  poll command again to keep waiting. When a \`{"status":"feedback"}\` batch
60
- arrives, apply it, then run the exact acknowledgement command in its
61
- \`next_step\`, which uses \`--ack <batch_id>\`.
66
+ arrives, handle every item, then run the exact acknowledgement command in its
67
+ \`next_step\`, which uses \`--ack <batch_id>\`. Keep that exact batch ID on retries;
68
+ never acknowledge an unhandled batch.
69
+
70
+ Without \`--timeout\`, the CLI defaults to a 12-hour cutoff. An explicit
71
+ \`--timeout\` is one end-to-end deadline, including server discovery and reconnect
72
+ attempts. Keep using the bounded foreground \`--timeout 600\` loop above;
73
+ those polls do not wait 12 hours.
62
74
 
63
75
  Keep the poll command in the foreground and do not end the turn while it waits.
64
76
  If the shell returns a process or session handle, keep waiting on that handle until
@@ -66,9 +78,19 @@ the command exits. \`${NPX_COMMAND} status <target>\` reports instantly
66
78
  whether feedback is already waiting, without blocking.
67
79
 
68
80
  The batch groups feedback by page under \`pages\`, so fix every page listed. Items
69
- under \`edits\` are changes the user already made: \`after\` is their exact wording,
70
- so carry it across verbatim and never revert it — and if the HTML was generated
71
- from MDX or Markdown, apply it to the source too. Markdown files open rendered
81
+ under \`edits\` are changes the user already made. For non-truncated edits,
82
+ \`after\` is their exact wording: carry it across verbatim and never revert it.
83
+ If the HTML was generated from MDX or Markdown, apply complete edits to the
84
+ source too.
85
+
86
+ Edit fields are limited to 200,000 Unicode code points each. An edit with
87
+ \`truncated: true\` identifies clipped fields in the \`truncated_fields\` array.
88
+ Never apply incomplete text or HTML as a complete replacement or invent missing
89
+ text. Recover the full edit only from an authoritative source; otherwise ask the
90
+ user for the complete edit. Do not acknowledge the batch until every item,
91
+ including truncated edits, has been handled.
92
+
93
+ Markdown files open rendered
72
94
  and are never written by doc-review: apply their comments and edits to the
73
95
  Markdown source, keeping its syntax. There is no reply channel; the user sees
74
96
  your work when the page reloads. For a localhost page, direct edits and deletions
@@ -79,6 +101,17 @@ component source. Never write the rendered HTTP response over project source.
79
101
  export function installSkills(cwd, { global: isGlobal = false, home = os.homedir(), command } = {}) {
80
102
  const done = [];
81
103
  const cmd = command || invocation();
104
+ const agents = path.join(cwd, "AGENTS.md");
105
+ let guidance;
106
+ let existing;
107
+ if (!isGlobal) {
108
+ const bytes = fs.existsSync(agents) ? fs.readFileSync(agents) : Buffer.alloc(0);
109
+ existing = bytes.toString("utf8");
110
+ if (!Buffer.from(existing, "utf8").equals(bytes)) {
111
+ throw new Error("AGENTS.md is not valid UTF-8; convert it before re-running setup. No setup files were changed.");
112
+ }
113
+ guidance = updateGuidance(existing, CODEX_BLOCK.replaceAll(NPX_COMMAND, cmd));
114
+ }
82
115
 
83
116
  const skillRoots = isGlobal
84
117
  ? [
@@ -96,15 +129,8 @@ export function installSkills(cwd, { global: isGlobal = false, home = os.homedir
96
129
  }
97
130
 
98
131
  if (!isGlobal) {
99
- const agents = path.join(cwd, "AGENTS.md");
100
- const existing = fs.existsSync(agents) ? fs.readFileSync(agents, "utf8") : "";
101
- if (existing.includes("doc-review")) {
102
- done.push("AGENTS.md already mentions doc-review — left it alone");
103
- } else {
104
- const block = CODEX_BLOCK.replaceAll(NPX_COMMAND, cmd);
105
- fs.writeFileSync(agents, existing ? `${existing.trimEnd()}\n${block}` : block.trimStart());
106
- done.push(`${existing ? "Updated" : "Created"} AGENTS.md (Codex)`);
107
- }
132
+ if (guidance.contents !== existing) fs.writeFileSync(agents, guidance.contents);
133
+ done.push(guidance.message);
108
134
  }
109
135
 
110
136
  done.push("", `Agents will be told to run: ${cmd}`);
package/src/state.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { normalizeCommentAnchor } from "./comment-anchor.js";
4
5
  import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
6
+ export { atomicWrite } from "./atomic-write.js";
7
+ import { atomicWrite } from "./atomic-write.js";
5
8
 
6
9
  /** Anything untouched this long is review debris, not work in progress. */
7
10
  const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -46,6 +49,17 @@ function normalizeState(parsed, makeBatchId) {
46
49
  receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
47
50
  };
48
51
  let changed = !parsed.batches || !parsed.receipts;
52
+ const normalizeAnchor = (comment) => {
53
+ if (!comment || comment.anchor == null) return;
54
+ const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
55
+ if (JSON.stringify(normalized) !== JSON.stringify(comment.anchor)) {
56
+ comment.anchor = normalized;
57
+ changed = true;
58
+ }
59
+ };
60
+ for (const page of Object.values(data.pages)) {
61
+ for (const comment of page?.comments || []) normalizeAnchor(comment);
62
+ }
49
63
  for (const record of Object.values(data.batches)) {
50
64
  if (!record || typeof record !== "object" || !record.batch || !Array.isArray(record.cleanup)) {
51
65
  throw new Error("Invalid doc-review state: malformed feedback batch.");
@@ -73,28 +87,13 @@ function normalizeState(parsed, makeBatchId) {
73
87
  if (!DELIVERY_STATES.has(record.delivery_state)) {
74
88
  throw new Error(`Invalid doc-review state: unknown delivery state ${record.delivery_state}.`);
75
89
  }
90
+ for (const page of record.batch.pages || []) {
91
+ for (const comment of page.comments || []) normalizeAnchor(comment);
92
+ }
76
93
  }
77
94
  return { data, changed };
78
95
  }
79
96
 
80
- /**
81
- * Atomic write via a unique sibling tmp file. The name is unguessable and the
82
- * create is exclusive, so a pre-planted symlink can never redirect the write,
83
- * and a failed rename never leaves a predictable orphan behind.
84
- */
85
- export function atomicWrite(file, data) {
86
- const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
87
- fs.writeFileSync(tmp, data, { flag: "wx" });
88
- try {
89
- fs.renameSync(tmp, file);
90
- } catch (err) {
91
- try {
92
- fs.unlinkSync(tmp);
93
- } catch {}
94
- throw err;
95
- }
96
- }
97
-
98
97
  /**
99
98
  * All durable state lives in one JSON file. No database, no network.
100
99
  *
@@ -234,8 +233,12 @@ export class Store {
234
233
  }
235
234
 
236
235
  addComment(key, comment) {
236
+ const anchor = comment.anchor == null
237
+ ? comment.anchor
238
+ : normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
239
+ if (comment.anchor != null && !anchor) throw new Error("Invalid comment anchor.");
237
240
  return this.update(key, (page) => {
238
- page.comments.push(comment);
241
+ page.comments.push({ ...comment, ...(comment.anchor !== undefined ? { anchor } : {}) });
239
242
  });
240
243
  }
241
244
 
@@ -324,6 +327,19 @@ export class Store {
324
327
  if (afterHtml !== undefined) row.after_html = afterHtml;
325
328
  // A re-move of the same block replaces its landing spot.
326
329
  if (extra) {
330
+ if (Array.isArray(extra.truncated_fields)) {
331
+ const replaced = new Set([
332
+ ...(after !== undefined ? ["after"] : []),
333
+ ...(afterHtml !== undefined ? ["after_html"] : []),
334
+ ...["moved_after", "moved_before"].filter((field) => extra[field] !== undefined),
335
+ ]);
336
+ // Original before text is retained across edits, including its truncation.
337
+ const truncatedFields = [...new Set([
338
+ ...(row.truncated_fields || []).filter((field) => !replaced.has(field)),
339
+ ...extra.truncated_fields.filter((field) => replaced.has(field)),
340
+ ])];
341
+ extra = { ...extra, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
342
+ }
327
343
  if (extra.staged_assets) {
328
344
  const assets = [...(row.staged_assets || []), ...extra.staged_assets];
329
345
  extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
@@ -344,10 +360,10 @@ export class Store {
344
360
  }
345
361
 
346
362
  /** After the agent writes, its version becomes the new revert target. */
347
- setPristine(key, html) {
363
+ setPristine(key, html, { keepEdits = false } = {}) {
348
364
  return this.update(key, (page) => {
349
365
  page.pristine = html;
350
- page.edits = [];
366
+ if (!keepEdits) page.edits = [];
351
367
  });
352
368
  }
353
369