@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 +46 -0
- package/package.json +1 -1
- package/src/SKILL.md +28 -3
- package/src/atomic-write.js +53 -0
- package/src/cli.js +48 -130
- package/src/edit-limits.js +23 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/server.js +24 -5
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +17 -20
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
|

|
|
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.
|
|
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
|
|
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.
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
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
|
|
92
|
+
await deadline.sleep(100);
|
|
93
|
+
if (launchError) throw launchError;
|
|
103
94
|
const record = readServerRecord();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
197
|
-
const
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
70
|
-
|
|
71
|
-
from MDX or Markdown, apply
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|