@erdemtuna/doc-review 0.8.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/README.md CHANGED
@@ -37,6 +37,20 @@ no longer need them.
37
37
 
38
38
  ## How to use /doc-review
39
39
 
40
+ Review is opt-in: explicitly invoke `/doc-review` or ask to open an interactive
41
+ browser review. Writing, updating, or asking for a general review of content does
42
+ not automatically open the browser or start polling. An explicitly started review
43
+ continues until you end it or switch tasks.
44
+
45
+ After updating installed skill instructions, start a fresh agent session or reload
46
+ skills (in Copilot CLI, `/skills reload`). Setup copies the executing package's
47
+ skill template; rerunning setup from an older package can restore older behavior.
48
+ Setup updates its own `AGENTS.md` guidance inside `<!-- BEGIN doc-review -->` and
49
+ `<!-- END doc-review -->` markers. Exact, known older generated blocks are migrated.
50
+ Custom guidance is preserved with a migration message rather than overwritten;
51
+ update it yourself if it requests automatic review. Invalid or duplicate markers
52
+ must be corrected before setup can proceed.
53
+
40
54
  ![Doc Review visual editor](assets/doc-review.png)
41
55
 
42
56
  Open an HTML or Markdown file:
@@ -87,6 +101,38 @@ npx -y @erdemtuna/doc-review poll path/to/file.html --ack b_0123456789abcdef --t
87
101
  A stale or repeated batch ID is harmless: it never clears newer feedback. The
88
102
  complete acknowledgement command is included in each response's `next_step`.
89
103
 
104
+ ### Feedback reliability and limits
105
+
106
+ `poll` without `--timeout` waits for at most 12 hours. Explicit timeouts include
107
+ server discovery, reconnection, and retry backoff, not just time spent connected.
108
+ Recoverable connection drops are retried; invalid responses, authorization errors,
109
+ and incompatible servers fail visibly. A timeout does not discard feedback: use
110
+ `status` to check it, then start another poll if the review is still wanted.
111
+
112
+ Each edit text or HTML field is limited to 200,000 Unicode code points. Oversized
113
+ edits carry `truncated: true` and `truncated_fields` naming incomplete fields.
114
+ The agent must not use partial text or HTML as a complete replacement or invent
115
+ the rest. It must obtain the full edit from an authoritative source or ask you
116
+ for it before acknowledging the batch.
117
+
118
+ External writes to a Markdown source refresh its rendered baseline without
119
+ clearing unsent feedback. That preserves your edits; it does not automatically
120
+ resolve conflicts with the changed source. HTML autosaves keep their existing
121
+ behavior.
122
+
123
+ ### Upgrading from v0.8.0
124
+
125
+ The updated CLI requires server protocol 13. An older server that is still
126
+ running is not silently reused or forcibly replaced. End active reviews and
127
+ shut down that specific old server (or let it exit when idle) before restarting
128
+ Doc Review. Keep the `.doc-review` state directory: pending batches and their
129
+ exact receipt IDs survive a controlled restart.
130
+
131
+ After installing the updated package, rerun `doc-review setup --global` for
132
+ personal skills and `doc-review setup` in projects with generated guidance.
133
+ Reload skills or start a fresh agent session afterward. A newer CLI paired with
134
+ older instructions is not a complete upgrade.
135
+
90
136
  ## What this skill lets you do
91
137
 
92
138
  - **Edit text directly and tweak basic formatting** (e.g., bold, italic).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erdemtuna/doc-review",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Review and edit agent-generated files and localhost pages in the browser, then send the whole batch back to your agent.",
5
5
  "author": "Peter Yang",
6
6
  "homepage": "https://github.com/erdemtuna/doc-review#readme",
package/src/SKILL.md CHANGED
@@ -1,10 +1,23 @@
1
1
  ---
2
2
  name: doc-review
3
- description: Open an HTML file, Markdown file, or localhost page in a View-first browser review so the user can optionally edit, leave contextual comments, and send all feedback back to you. Use after writing or updating something the user will read — specs, plans, reports, newsletter drafts, landing pages, slide decks, and locally running web pages.
3
+ description: Open an HTML file, Markdown file, or localhost page for View-first interactive browser feedback. Use only when the user explicitly invokes /doc-review or requests an interactive browser review. Do not invoke merely because you write, update, discuss, or review a document or web page.
4
4
  ---
5
5
 
6
6
  # doc-review
7
7
 
8
+ ## Activation
9
+
10
+ Start only when the user explicitly invokes `/doc-review` or asks to open an
11
+ interactive browser review. A generic request to review, proofread, analyze, write,
12
+ or update content is not permission to open this workflow. Another skill's
13
+ automatic review step is not user permission.
14
+
15
+ Without that request, respond normally without opening a review or polling for
16
+ feedback. Once the user starts a review, continue its feedback loop until they
17
+ end it; stop if they cancel or switch to a different task.
18
+
19
+ ## Review behavior
20
+
8
21
  The user reviews your HTML, Markdown, or localhost page in a real browser. It starts in View,
9
22
  where normal page controls work. They can switch to Edit, comment explicitly in either mode,
10
23
  and send the whole batch at once.
@@ -36,7 +49,8 @@ carried; newer comments and corrections survive.
36
49
 
37
50
  ## The loop
38
51
 
39
- 1. Write or update the HTML or Markdown file, or start the local page being reviewed.
52
+ 1. After the explicit review request, use the requested file or localhost route.
53
+ Create or update content, or start a local page, only as needed for that request.
40
54
  2. Open it for the user:
41
55
 
42
56
  ```sh
@@ -56,6 +70,12 @@ carried; newer comments and corrections survive.
56
70
  npx -y @erdemtuna/doc-review poll path/to/file.html --timeout 600
57
71
  ```
58
72
 
73
+ Without `--timeout`, the CLI stops after 12 hours. An explicit timeout covers
74
+ the entire operation, including server discovery, reconnects, and backoff.
75
+ Recoverable connection drops retry within that deadline; terminal errors
76
+ require action rather than another automatic poll. The 600-second command
77
+ above deliberately uses a shorter deadline.
78
+
59
79
  Keep this command in the foreground. Do not end your turn while it is waiting.
60
80
  If your shell returns a process or session handle, keep waiting on that handle
61
81
  until the command exits. If it prints `{"status":"timeout"}`, no feedback has
@@ -119,8 +139,13 @@ One batch covers every page the user visited, grouped by file or localhost URL.
119
139
 
120
140
  ## Rules
121
141
 
142
+ - Edits marked **`truncated`** contain incomplete fields listed in
143
+ `truncated_fields`. Each text/HTML field is limited to 200,000 Unicode code
144
+ points. Never apply a truncated field as a complete replacement or guess the
145
+ missing content. Obtain the complete edit from an authoritative source, or ask
146
+ the user for it. Do not acknowledge the batch until all feedback is handled.
122
147
  - **`edits` are changes the user already made.** `after` is their exact wording —
123
- carry it across verbatim and never revert it. If the HTML was generated from
148
+ unless marked truncated, carry it across verbatim and never revert it. If the HTML was generated from
124
149
  something else (MDX, Markdown, a template), apply `after` to the **source** too,
125
150
  or their fix disappears on the next build.
126
151
  - When `before_html`/`after_html` are present, the user changed formatting, not
@@ -0,0 +1,53 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ const RETRY_DELAYS_MS = [10, 20, 40, 80, 160];
5
+ const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EACCES", "EBUSY"]);
6
+ const waitState = new Int32Array(new SharedArrayBuffer(4));
7
+
8
+ // Keep Store transactions synchronous; rename backoff blocks for at most 310ms.
9
+ const sleepSync = (ms) => Atomics.wait(waitState, 0, 0, ms);
10
+
11
+ export function createAtomicWriter({ fileSystem = fs, sleep = sleepSync, report = console.error } = {}) {
12
+ return function atomicWrite(file, data) {
13
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
14
+ let fd;
15
+ let created = false;
16
+ try {
17
+ fd = fileSystem.openSync(tmp, "wx");
18
+ created = true;
19
+ fileSystem.writeFileSync(fd, data);
20
+ fileSystem.closeSync(fd);
21
+ fd = undefined;
22
+ for (let attempt = 0; ; attempt += 1) {
23
+ try {
24
+ fileSystem.renameSync(tmp, file);
25
+ return;
26
+ } catch (err) {
27
+ if (!TRANSIENT_RENAME_ERRORS.has(err.code) || attempt === RETRY_DELAYS_MS.length) throw err;
28
+ sleep(RETRY_DELAYS_MS[attempt]);
29
+ }
30
+ }
31
+ } catch (err) {
32
+ if (fd !== undefined) {
33
+ try {
34
+ fileSystem.closeSync(fd);
35
+ } catch (cleanupError) {
36
+ report(`Could not close atomic-write temporary file ${tmp}: ${cleanupError.message}`);
37
+ }
38
+ }
39
+ if (created) {
40
+ try {
41
+ fileSystem.unlinkSync(tmp);
42
+ } catch (cleanupError) {
43
+ if (cleanupError.code !== "ENOENT") {
44
+ report(`Could not remove atomic-write temporary file ${tmp}: ${cleanupError.message}`);
45
+ }
46
+ }
47
+ }
48
+ throw err;
49
+ }
50
+ };
51
+ }
52
+
53
+ export const atomicWrite = createAtomicWriter();
package/src/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
- import http from "node:http";
4
3
  import path from "node:path";
5
4
  import { spawn } from "node:child_process";
6
5
  import { fileURLToPath } from "node:url";
@@ -15,6 +14,7 @@ import {
15
14
  } from "./paths.js";
16
15
  import { readServerLock } from "./server-lock.js";
17
16
  import { installSkills, shellQuote } from "./setup.js";
17
+ import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
18
18
 
19
19
  const here = path.dirname(fileURLToPath(import.meta.url));
20
20
  const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
@@ -24,7 +24,7 @@ const HELP = `doc-review ${pkg.version}
24
24
  doc-review <file-or-localhost-url> Open a file or localhost page for review
25
25
  doc-review poll <target> Wait for feedback, print it as JSON (for agents)
26
26
  --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
27
- --timeout <secs> Exit with {"status":"timeout"} if nothing arrives
27
+ --timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
28
28
  doc-review status <target> Report whether feedback is waiting, without blocking
29
29
  doc-review setup Teach Claude Code / Codex how to use doc-review
30
30
  doc-review setup --global ...for every project, not just this one
@@ -42,72 +42,64 @@ function readServerRecord() {
42
42
  }
43
43
  }
44
44
 
45
- function request(server, options, body) {
46
- const port = typeof server === "number" ? server : server.port;
47
- const token = typeof server === "number" ? "" : server.token || "";
48
- return new Promise((resolve, reject) => {
49
- const req = http.request(
50
- {
51
- host: "127.0.0.1",
52
- port,
53
- ...options,
54
- headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
55
- },
56
- (res) => {
57
- let raw = "";
58
- res.setEncoding("utf8");
59
- res.on("data", (chunk) => {
60
- raw += chunk;
61
- });
62
- res.on("end", () => resolve({ status: res.statusCode, raw }));
63
- }
64
- );
65
- req.on("error", reject);
66
- if (options.timeout) req.setTimeout(options.timeout, () => req.destroy(new Error("timeout")));
67
- if (body) req.write(JSON.stringify(body));
68
- req.end();
69
- });
70
- }
45
+ const request = requestRaw;
71
46
 
72
- async function alive(server) {
47
+ async function alive(server, deadline) {
48
+ if (!server?.port || !server.instance_id) return false;
49
+ const lock = readServerLock();
50
+ if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
73
51
  try {
74
- const lock = readServerLock();
75
- if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
76
- const res = await request(server, { method: "GET", path: "/health", timeout: 1200 });
77
- if (res.status !== 200) return false;
78
- const health = JSON.parse(res.raw);
79
- return (
80
- serverProtocolMatches(health.protocol) &&
81
- health.pid === server.pid &&
82
- health.instance_id === server.instance_id
83
- );
84
- } catch {
85
- return false;
52
+ deadline?.check();
53
+ const res = await request(server, {
54
+ method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
55
+ }, undefined, { time: deadline?.time });
56
+ deadline?.check();
57
+ const health = parseServerResponse(res);
58
+ if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
59
+ throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
60
+ }
61
+ if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
62
+ throw new Error(
63
+ `Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
64
+ "End active reviews and stop/restart the old doc-review server before retrying. " +
65
+ "Its live writer lock and queued feedback have not been changed.",
66
+ );
67
+ }
68
+ return true;
69
+ } catch (err) {
70
+ if (isRecoverableTransportError(err)) return false;
71
+ throw err;
86
72
  }
87
73
  }
88
74
 
89
- async function ensureServer() {
75
+ async function ensureServer(deadline = createDeadline(20)) {
76
+ deadline.check();
90
77
  ensureStateDir();
91
78
  for (let launch = 0; launch < 3; launch += 1) {
92
79
  const saved = readServerRecord();
93
- if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) return saved;
80
+ if (await alive(saved, deadline)) return saved;
81
+ deadline.check();
94
82
 
95
83
  const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
96
84
  detached: true,
97
85
  stdio: "ignore",
98
86
  });
87
+ let launchError;
88
+ child.on("error", (err) => { launchError = err; });
99
89
  child.unref();
100
90
 
101
91
  for (let attempt = 0; attempt < 60; attempt += 1) {
102
- await new Promise((r) => setTimeout(r, 100));
92
+ await deadline.sleep(100);
93
+ if (launchError) throw launchError;
103
94
  const record = readServerRecord();
104
- // Same protocol gate as above: a still-running server from an older
105
- // version answers /health too, and must not be adopted here.
106
- if (serverProtocolMatches(record?.protocol) && record.port && record.instance_id && (await alive(record))) return record;
95
+ if (await alive(record, deadline)) return record;
96
+ if (child.exitCode !== null && child.exitCode !== 0) {
97
+ throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
98
+ }
107
99
  if (child.exitCode !== null && !readServerLock()) break;
108
100
  }
109
101
  }
110
- throw new Error("Could not start the local doc-review server.");
102
+ throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
111
103
  }
112
104
 
113
105
  function openBrowser(url) {
@@ -142,48 +134,6 @@ async function openCommand(input) {
142
134
  console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
143
135
  }
144
136
 
145
- /**
146
- * One long-poll attempt. Resolves { kind: "data", raw } when the server
147
- * answers, or { kind: "timeout" } when the caller's deadline passes first.
148
- */
149
- function pollOnce(server, target, ackId, timeoutMs) {
150
- const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
151
- return new Promise((resolve, reject) => {
152
- let done = false;
153
- const settle = (fn, value) => {
154
- if (done) return;
155
- done = true;
156
- clearTimeout(timer);
157
- fn(value);
158
- };
159
- const req = http.request(
160
- {
161
- host: "127.0.0.1",
162
- port: server.port,
163
- method: "GET",
164
- path: `/api/poll?${query}`,
165
- headers: { "x-doc-review-token": server.token || "" },
166
- },
167
- (res) => {
168
- let raw = "";
169
- res.setEncoding("utf8");
170
- res.on("data", (chunk) => {
171
- raw += chunk;
172
- });
173
- res.on("end", () => settle(resolve, { kind: "data", raw: raw.trim() }));
174
- }
175
- );
176
- const timer = timeoutMs
177
- ? setTimeout(() => {
178
- settle(resolve, { kind: "timeout" });
179
- req.destroy();
180
- }, timeoutMs)
181
- : null;
182
- req.on("error", (err) => settle(reject, err));
183
- req.end();
184
- });
185
- }
186
-
187
137
  /**
188
138
  * The consumer is an agent reading a pipe. process.exit() does not wait for
189
139
  * pending stdout writes, so a large payload could arrive truncated — always
@@ -193,50 +143,18 @@ function writeStdout(text) {
193
143
  return new Promise((resolve) => process.stdout.write(text, resolve));
194
144
  }
195
145
 
196
- function printTimeout(waitedSecs) {
197
- const payload = {
198
- status: "timeout",
199
- waited_seconds: waitedSecs,
200
- next_step:
201
- "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
202
- };
203
- return writeStdout(`${JSON.stringify(payload, null, 2)}\n`);
204
- }
205
-
206
- async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
146
+ async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
147
+ const deadline = createDeadline(timeoutSecs);
207
148
  const target = canonicalTarget(input).value;
208
- let server = await ensureServer();
209
149
 
210
150
  const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
211
151
  process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
212
152
 
213
- const deadline = timeoutSecs ? Date.now() + timeoutSecs * 1000 : null;
214
- for (let attempt = 0; attempt < 3; attempt += 1) {
215
- const remaining = deadline ? deadline - Date.now() : 0;
216
- if (deadline && remaining <= 0) return printTimeout(timeoutSecs);
217
- let result;
218
- try {
219
- result = await pollOnce(server, target, ackId && attempt === 0 ? ackId : "", remaining);
220
- } catch (err) {
221
- process.stderr.write(`Lost the connection (${err.message}); retrying.\n`);
222
- server = await ensureServer();
223
- continue;
224
- }
225
- if (result.kind === "timeout") return printTimeout(timeoutSecs);
226
- if (!result.raw) {
227
- server = await ensureServer();
228
- continue;
229
- }
230
- try {
231
- const batch = JSON.parse(result.raw);
232
- await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
233
- return;
234
- } catch {
235
- process.stderr.write("Unexpected response from the doc-review server; retrying.\n");
236
- }
237
- }
238
- process.stderr.write("Gave up waiting for feedback.\n");
239
- process.exit(1);
153
+ const batch = await pollUntilDeadline({
154
+ target, ackId, deadline, discover: ensureServer,
155
+ diagnostic: (text) => process.stderr.write(text),
156
+ });
157
+ await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
240
158
  }
241
159
 
242
160
  /**
@@ -297,7 +215,7 @@ process.on("SIGINT", () => {
297
215
  });
298
216
 
299
217
  function parsePollArgs(rest) {
300
- const parsed = { file: "", ackId: "", timeoutSecs: 0 };
218
+ const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
301
219
  let sawTimeout = false;
302
220
  for (let i = 0; i < rest.length; i += 1) {
303
221
  const arg = rest[i];
@@ -0,0 +1,23 @@
1
+ export const MAX_EDIT_CHARACTERS = 200_000;
2
+
3
+ const EDIT_FIELDS = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
4
+
5
+ export function limitEditFields(input) {
6
+ const fields = {};
7
+ const truncatedFields = [];
8
+ for (const field of EDIT_FIELDS) {
9
+ const value = input[field];
10
+ if (typeof value !== "string") continue;
11
+ let end = 0;
12
+ let count = 0;
13
+ // Count Unicode code points, not UTF-16 halves of an astral character.
14
+ for (const character of value) {
15
+ if (count === MAX_EDIT_CHARACTERS) break;
16
+ end += character.length;
17
+ count += 1;
18
+ }
19
+ fields[field] = value.slice(0, end);
20
+ if (end < value.length) truncatedFields.push(field);
21
+ }
22
+ return { fields, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
23
+ }
package/src/paths.js CHANGED
@@ -5,7 +5,7 @@ import fs from "node:fs";
5
5
 
6
6
  // Bump this when the CLI and detached server no longer share the same request
7
7
  // contract. A new CLI must not silently reuse an older background server.
8
- export const SERVER_PROTOCOL = 12;
8
+ export const SERVER_PROTOCOL = 13;
9
9
 
10
10
  export function serverProtocolMatches(protocol) {
11
11
  return Number(protocol) === SERVER_PROTOCOL;
@@ -0,0 +1,222 @@
1
+ import http from "node:http";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ export const DEFAULT_POLL_SECONDS = 12 * 60 * 60;
5
+ const MAX_TIMER_MS = 2 ** 31 - 1;
6
+ const clock = {
7
+ now: () => performance.now(),
8
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
9
+ clearTimeout: (timer) => clearTimeout(timer),
10
+ };
11
+
12
+ function codedError(message, code) {
13
+ return Object.assign(new Error(message), { code });
14
+ }
15
+
16
+ export function createDeadline(seconds = DEFAULT_POLL_SECONDS, time = clock) {
17
+ const end = time.now() + seconds * 1000;
18
+ const remaining = () => Math.max(0, end - time.now());
19
+ const check = () => {
20
+ if (remaining() <= 0) throw codedError("Polling deadline reached.", "POLL_DEADLINE");
21
+ };
22
+ return {
23
+ seconds,
24
+ time,
25
+ remaining,
26
+ check,
27
+ async sleep(ms) {
28
+ check();
29
+ await new Promise((resolve) => time.setTimeout(resolve, Math.min(ms, remaining(), MAX_TIMER_MS)));
30
+ check();
31
+ },
32
+ };
33
+ }
34
+
35
+ export function isRecoverableTransportError(err) {
36
+ return new Set([
37
+ "ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH",
38
+ "ENETUNREACH", "ENETDOWN", "ECONNABORTED", "ERR_STREAM_PREMATURE_CLOSE",
39
+ "SERVER_START_PENDING",
40
+ ]).has(err?.code);
41
+ }
42
+
43
+ /**
44
+ * An absolute response deadline, not a socket-idle timeout: heartbeats and
45
+ * partially received JSON must not extend the caller's waiting budget.
46
+ */
47
+ export function requestRaw(server, options, body, {
48
+ timeoutMs = options.timeout,
49
+ time = clock,
50
+ signal,
51
+ timeoutCode = "ETIMEDOUT",
52
+ makeRequest = http.request,
53
+ } = {}) {
54
+ return new Promise((resolve, reject) => {
55
+ let req;
56
+ let res;
57
+ let timer;
58
+ let done = false;
59
+ let ended = false;
60
+ let raw = "";
61
+ const expires = timeoutMs == null ? null : time.now() + timeoutMs;
62
+ const premature = () => codedError("The server closed an incomplete response.", "ERR_STREAM_PREMATURE_CLOSE");
63
+ const cleanupResponse = () => {
64
+ res?.removeListener("data", onData);
65
+ res?.removeListener("end", onEnd);
66
+ res?.removeListener("aborted", onAborted);
67
+ };
68
+ const settle = (err, value) => {
69
+ if (done) return;
70
+ done = true;
71
+ time.clearTimeout(timer);
72
+ signal?.removeEventListener("abort", onAbort);
73
+ cleanupResponse();
74
+ // Keep error handlers until close: destroying a socket may emit one last
75
+ // error asynchronously. Close removes the remaining owned listeners.
76
+ res?.destroy();
77
+ req?.destroy();
78
+ if (err) reject(err);
79
+ else resolve(value);
80
+ };
81
+ const onError = (err) => settle(err);
82
+ const onData = (chunk) => { raw += chunk; };
83
+ const onEnd = () => {
84
+ ended = true;
85
+ if (!res.complete) return settle(premature());
86
+ settle(null, { status: res.statusCode, raw });
87
+ };
88
+ const onAborted = () => settle(premature());
89
+ const onResponseClose = () => {
90
+ if (!ended) settle(premature());
91
+ cleanupResponse();
92
+ res.removeListener("error", onError);
93
+ res.removeListener("close", onResponseClose);
94
+ };
95
+ const onRequestClose = () => {
96
+ if (!done) settle(premature());
97
+ req.removeListener("error", onError);
98
+ req.removeListener("close", onRequestClose);
99
+ req.removeListener("response", onResponse);
100
+ };
101
+ const onAbort = () => settle(codedError("Polling cancelled.", "ABORT_ERR"));
102
+ const armTimer = () => {
103
+ const left = expires - time.now();
104
+ if (left <= 0) return settle(codedError("Polling request timed out.", timeoutCode));
105
+ timer = time.setTimeout(armTimer, Math.min(left, MAX_TIMER_MS));
106
+ };
107
+ const onResponse = (response) => {
108
+ res = response;
109
+ if (done) {
110
+ res.destroy();
111
+ return;
112
+ }
113
+ res.setEncoding("utf8");
114
+ res.on("data", onData);
115
+ res.on("end", onEnd);
116
+ res.on("error", onError);
117
+ res.on("aborted", onAborted);
118
+ res.on("close", onResponseClose);
119
+ };
120
+ try {
121
+ if (signal?.aborted) return onAbort();
122
+ const port = typeof server === "number" ? server : server.port;
123
+ const token = typeof server === "number" ? "" : server.token || "";
124
+ const { timeout: _timeout, ...requestOptions } = options;
125
+ req = makeRequest({
126
+ host: "127.0.0.1",
127
+ port,
128
+ ...requestOptions,
129
+ headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
130
+ });
131
+ req.on("response", onResponse);
132
+ req.on("error", onError);
133
+ req.on("close", onRequestClose);
134
+ signal?.addEventListener("abort", onAbort, { once: true });
135
+ if (expires !== null) armTimer();
136
+ if (done) return;
137
+ if (body) req.write(JSON.stringify(body));
138
+ req.end();
139
+ } catch (err) {
140
+ settle(err);
141
+ }
142
+ });
143
+ }
144
+
145
+ export function parseServerResponse(response) {
146
+ if (response.status !== 200) {
147
+ let detail = "";
148
+ try { detail = JSON.parse(response.raw).error || ""; } catch { /* HTTP status is sufficient. */ }
149
+ throw codedError(
150
+ `Doc-review server returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`,
151
+ "SERVER_RESPONSE_ERROR",
152
+ );
153
+ }
154
+ try {
155
+ const parsed = JSON.parse(response.raw);
156
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
157
+ return parsed;
158
+ } catch {
159
+ throw codedError("Malformed response from the doc-review server. End the review and restart the server.", "SERVER_RESPONSE_INVALID");
160
+ }
161
+ }
162
+
163
+ export async function pollOnce(server, target, ackId, deadline) {
164
+ deadline.check();
165
+ const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
166
+ const response = await requestRaw(server, {
167
+ method: "GET",
168
+ path: `/api/poll?${query}`,
169
+ }, undefined, {
170
+ timeoutMs: deadline.remaining(),
171
+ time: deadline.time,
172
+ timeoutCode: "POLL_DEADLINE",
173
+ });
174
+ // Graceful server disposal ends the space heartbeats without a JSON payload.
175
+ // HTTP is complete, but this poll was interrupted just like a dropped socket.
176
+ if (response.status === 200 && /^ +$/.test(response.raw)) {
177
+ throw codedError("The server ended the poll before sending feedback.", "ERR_STREAM_PREMATURE_CLOSE");
178
+ }
179
+ const batch = parseServerResponse(response);
180
+ if (!["feedback", "closed", "timeout"].includes(batch.status) ||
181
+ (batch.status === "feedback" && (typeof batch.batch_id !== "string" || !batch.batch_id || !Array.isArray(batch.pages)))) {
182
+ throw codedError("Malformed polling response from the doc-review server.", "SERVER_RESPONSE_INVALID");
183
+ }
184
+ return batch;
185
+ }
186
+
187
+ export async function pollUntilDeadline({
188
+ target,
189
+ ackId = "",
190
+ deadline = createDeadline(),
191
+ discover,
192
+ poll = pollOnce,
193
+ diagnostic = () => {},
194
+ }) {
195
+ let failures = 0;
196
+ for (;;) {
197
+ try {
198
+ deadline.check();
199
+ const server = await discover(deadline);
200
+ deadline.check();
201
+ // An uncertain send must repeat the supplied receipt, never a newer ID.
202
+ const batch = await poll(server, target, ackId, deadline);
203
+ deadline.check();
204
+ return batch;
205
+ } catch (err) {
206
+ if (err.code === "POLL_DEADLINE" || deadline.remaining() <= 0) {
207
+ return {
208
+ status: "timeout",
209
+ waited_seconds: deadline.seconds,
210
+ next_step: "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
211
+ };
212
+ }
213
+ if (!isRecoverableTransportError(err)) throw err;
214
+ diagnostic(`Lost the connection (${err.message}); retrying.\n`);
215
+ try {
216
+ await deadline.sleep(Math.min(250 * 2 ** Math.min(failures++, 5), 5000));
217
+ } catch (sleepError) {
218
+ if (sleepError.code !== "POLL_DEADLINE") throw sleepError;
219
+ }
220
+ }
221
+ }
222
+ }
package/src/server.js CHANGED
@@ -10,6 +10,7 @@ import { isMarkdown, renderMarkdownPage } from "./markdown.js";
10
10
  import { canonicalTarget, ensureStateDir, localUrl, SERVER_PROTOCOL, serverPath, stateDir, targetKey } from "./paths.js";
11
11
  import { acquireServerLock, releaseServerLock, removeOwnedServerRecord } from "./server-lock.js";
12
12
  import { invocation, shellQuote } from "./setup.js";
13
+ import { limitEditFields } from "./edit-limits.js";
13
14
 
14
15
  const here = path.dirname(fileURLToPath(import.meta.url));
15
16
 
@@ -224,8 +225,13 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
224
225
  const current = hash(html);
225
226
  // Our own autosave must never bounce back as a reload.
226
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
+ }
227
234
  lastWritten.set(key, current);
228
- store.setPristine(key, html);
229
235
  for (const session of sessionsForKey(key)) {
230
236
  invalidateSessionRender(session);
231
237
  emit(session, "reload", { key });
@@ -284,6 +290,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
284
290
  kind: e.kind,
285
291
  before: e.before,
286
292
  after: e.after,
293
+ ...(e.truncated ? { truncated: true, truncated_fields: e.truncated_fields } : {}),
287
294
  ...(e.before_html !== undefined && e.before_html !== e.before ? { before_html: e.before_html } : {}),
288
295
  ...(e.after_html !== undefined && e.after_html !== e.after ? { after_html: e.after_html } : {}),
289
296
  ...(Array.isArray(e.staged_assets) && e.staged_assets.length ? { staged_assets: e.staged_assets } : {}),
@@ -314,6 +321,7 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
314
321
  const hasMarkdown = pages.some((p) => p.kind === "file" && isMarkdown(p.file));
315
322
  const hasUrl = pages.some((p) => p.kind === "url");
316
323
  const hasCorrections = pages.some((p) => p.comments.some((c) => c.correction));
324
+ const hasTruncation = pages.some((p) => p.edits.some((e) => e.truncated));
317
325
  const id = `b_${crypto.randomBytes(12).toString("hex")}`;
318
326
  const entry = store.page(session.entryKey);
319
327
  const pollTarget = entry?.kind === "url" ? entry.url : entry?.file;
@@ -326,9 +334,14 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
326
334
  sent_at: new Date().toISOString(),
327
335
  next_step:
328
336
  "Apply this feedback. Each entry in `pages` names the reviewed file or localhost URL. Items under `edits` are " +
329
- "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 " +
330
338
  "never revert it. When an edit carries `after_html`, the human changed formatting (bold, italic, links) — " +
331
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
+ : "") +
332
345
  (hasMarkdown
333
346
  ? "Markdown pages were reviewed rendered, so quotes and `after` wording use the rendered text — apply " +
334
347
  "the change to the Markdown source, keeping its formatting syntax. "
@@ -873,7 +886,11 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
873
886
  const body = await readBody(req);
874
887
  const label = String(body.label || "Document");
875
888
  const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited";
876
- 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;
877
894
  const stagedRoot = path.join(stateDir(), "pasted", key);
878
895
  const stagedAssets = Array.isArray(body.staged_assets)
879
896
  ? body.staged_assets
@@ -893,10 +910,12 @@ export function createServer({ store: suppliedStore, storeOptions, owner = null,
893
910
  .map(({ path: assetPath, preview_src }) => ({ path: assetPath, preview_src }))
894
911
  : [];
895
912
  const extra = {
896
- ...(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 || "" } : {}),
897
916
  ...(stagedAssets.length ? { staged_assets: stagedAssets } : {}),
898
917
  };
899
- 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);
900
919
  return json(res, 200, { page: pageState(key) });
901
920
  }
902
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
@@ -3,6 +3,8 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { normalizeCommentAnchor } from "./comment-anchor.js";
5
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";
6
8
 
7
9
  /** Anything untouched this long is review debris, not work in progress. */
8
10
  const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -92,24 +94,6 @@ function normalizeState(parsed, makeBatchId) {
92
94
  return { data, changed };
93
95
  }
94
96
 
95
- /**
96
- * Atomic write via a unique sibling tmp file. The name is unguessable and the
97
- * create is exclusive, so a pre-planted symlink can never redirect the write,
98
- * and a failed rename never leaves a predictable orphan behind.
99
- */
100
- export function atomicWrite(file, data) {
101
- const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
102
- fs.writeFileSync(tmp, data, { flag: "wx" });
103
- try {
104
- fs.renameSync(tmp, file);
105
- } catch (err) {
106
- try {
107
- fs.unlinkSync(tmp);
108
- } catch {}
109
- throw err;
110
- }
111
- }
112
-
113
97
  /**
114
98
  * All durable state lives in one JSON file. No database, no network.
115
99
  *
@@ -343,6 +327,19 @@ export class Store {
343
327
  if (afterHtml !== undefined) row.after_html = afterHtml;
344
328
  // A re-move of the same block replaces its landing spot.
345
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
+ }
346
343
  if (extra.staged_assets) {
347
344
  const assets = [...(row.staged_assets || []), ...extra.staged_assets];
348
345
  extra = { ...extra, staged_assets: [...new Map(assets.map((asset) => [asset.path, asset])).values()] };
@@ -363,10 +360,10 @@ export class Store {
363
360
  }
364
361
 
365
362
  /** After the agent writes, its version becomes the new revert target. */
366
- setPristine(key, html) {
363
+ setPristine(key, html, { keepEdits = false } = {}) {
367
364
  return this.update(key, (page) => {
368
365
  page.pristine = html;
369
- page.edits = [];
366
+ if (!keepEdits) page.edits = [];
370
367
  });
371
368
  }
372
369