@erdemtuna/doc-review 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/edit-limits.js +23 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
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,12 +24,16 @@ 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
|
|
31
31
|
|
|
32
32
|
Everything runs locally. No account, no cloud, no database.
|
|
33
|
+
Use Review / Changes in the browser for retained content history.
|
|
34
|
+
Plain HTML edits autosave. Self-contained file scripts run automatically with feedback-only edits.
|
|
35
|
+
Use More for optional script-disabled recovery. Comparison capture does not block sending feedback.
|
|
36
|
+
Acknowledgement handles feedback; browser result capture may complete later.
|
|
33
37
|
`;
|
|
34
38
|
|
|
35
39
|
// --------------------------------------------------------------- server glue
|
|
@@ -42,72 +46,64 @@ function readServerRecord() {
|
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
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
|
-
}
|
|
49
|
+
const request = requestRaw;
|
|
71
50
|
|
|
72
|
-
async function alive(server) {
|
|
51
|
+
async function alive(server, deadline) {
|
|
52
|
+
if (!server?.port || !server.instance_id) return false;
|
|
53
|
+
const lock = readServerLock();
|
|
54
|
+
if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
|
|
73
55
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
|
|
56
|
+
deadline?.check();
|
|
57
|
+
const res = await request(server, {
|
|
58
|
+
method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
|
|
59
|
+
}, undefined, { time: deadline?.time });
|
|
60
|
+
deadline?.check();
|
|
61
|
+
const health = parseServerResponse(res);
|
|
62
|
+
if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
|
|
63
|
+
throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
|
|
64
|
+
}
|
|
65
|
+
if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
|
|
68
|
+
"End active reviews and stop/restart the old doc-review server before retrying. " +
|
|
69
|
+
"Its live writer lock and queued feedback have not been changed.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (isRecoverableTransportError(err)) return false;
|
|
75
|
+
throw err;
|
|
86
76
|
}
|
|
87
77
|
}
|
|
88
78
|
|
|
89
|
-
async function ensureServer() {
|
|
79
|
+
async function ensureServer(deadline = createDeadline(20)) {
|
|
80
|
+
deadline.check();
|
|
90
81
|
ensureStateDir();
|
|
91
82
|
for (let launch = 0; launch < 3; launch += 1) {
|
|
92
83
|
const saved = readServerRecord();
|
|
93
|
-
if (
|
|
84
|
+
if (await alive(saved, deadline)) return saved;
|
|
85
|
+
deadline.check();
|
|
94
86
|
|
|
95
87
|
const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
|
|
96
88
|
detached: true,
|
|
97
89
|
stdio: "ignore",
|
|
98
90
|
});
|
|
91
|
+
let launchError;
|
|
92
|
+
child.on("error", (err) => { launchError = err; });
|
|
99
93
|
child.unref();
|
|
100
94
|
|
|
101
95
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
102
|
-
await
|
|
96
|
+
await deadline.sleep(100);
|
|
97
|
+
if (launchError) throw launchError;
|
|
103
98
|
const record = readServerRecord();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
if (await alive(record, deadline)) return record;
|
|
100
|
+
if (child.exitCode !== null && child.exitCode !== 0) {
|
|
101
|
+
throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
|
|
102
|
+
}
|
|
107
103
|
if (child.exitCode !== null && !readServerLock()) break;
|
|
108
104
|
}
|
|
109
105
|
}
|
|
110
|
-
throw new Error("Could not start the local doc-review server.");
|
|
106
|
+
throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
|
|
111
107
|
}
|
|
112
108
|
|
|
113
109
|
function openBrowser(url) {
|
|
@@ -142,48 +138,6 @@ async function openCommand(input) {
|
|
|
142
138
|
console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
|
|
143
139
|
}
|
|
144
140
|
|
|
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
141
|
/**
|
|
188
142
|
* The consumer is an agent reading a pipe. process.exit() does not wait for
|
|
189
143
|
* pending stdout writes, so a large payload could arrive truncated — always
|
|
@@ -193,50 +147,18 @@ function writeStdout(text) {
|
|
|
193
147
|
return new Promise((resolve) => process.stdout.write(text, resolve));
|
|
194
148
|
}
|
|
195
149
|
|
|
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 } = {}) {
|
|
150
|
+
async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
|
|
151
|
+
const deadline = createDeadline(timeoutSecs);
|
|
207
152
|
const target = canonicalTarget(input).value;
|
|
208
|
-
let server = await ensureServer();
|
|
209
153
|
|
|
210
154
|
const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
|
|
211
155
|
process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
|
|
212
156
|
|
|
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);
|
|
157
|
+
const batch = await pollUntilDeadline({
|
|
158
|
+
target, ackId, deadline, discover: ensureServer,
|
|
159
|
+
diagnostic: (text) => process.stderr.write(text),
|
|
160
|
+
});
|
|
161
|
+
await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
|
|
240
162
|
}
|
|
241
163
|
|
|
242
164
|
/**
|
|
@@ -297,7 +219,7 @@ process.on("SIGINT", () => {
|
|
|
297
219
|
});
|
|
298
220
|
|
|
299
221
|
function parsePollArgs(rest) {
|
|
300
|
-
const parsed = { file: "", ackId: "", timeoutSecs:
|
|
222
|
+
const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
|
|
301
223
|
let sawTimeout = false;
|
|
302
224
|
for (let i = 0; i < rest.length; i += 1) {
|
|
303
225
|
const arg = rest[i];
|
package/src/comment-target.js
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
2
2
|
|
|
3
|
+
export function groupCommentTargets(targets) {
|
|
4
|
+
const groups = new Map();
|
|
5
|
+
for (const [id, element] of targets) {
|
|
6
|
+
if (!element?.isConnected) continue;
|
|
7
|
+
if (!groups.has(element)) groups.set(element, []);
|
|
8
|
+
groups.get(element).push(id);
|
|
9
|
+
}
|
|
10
|
+
return groups;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function nextCommentId(ids, activeId) {
|
|
14
|
+
return ids.length ? ids[(ids.indexOf(activeId) + 1) % ids.length] : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Retarget dwell is independent of initial intent; revisiting a candidate
|
|
18
|
+
* does not restart its clock. The caller owns corridor and selection policy. */
|
|
19
|
+
export function createHoverIntent(commit, {
|
|
20
|
+
initial = 150, retarget = 100, exit = 120,
|
|
21
|
+
schedule = setTimeout, unschedule = clearTimeout,
|
|
22
|
+
} = {}) {
|
|
23
|
+
let current = null;
|
|
24
|
+
let candidate = null;
|
|
25
|
+
let timer = null;
|
|
26
|
+
const cancel = () => {
|
|
27
|
+
unschedule(timer);
|
|
28
|
+
timer = null;
|
|
29
|
+
candidate = null;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
cancel,
|
|
33
|
+
reset() { cancel(); current = null; },
|
|
34
|
+
request(key, payload, immediate = false) {
|
|
35
|
+
if (key === current && !immediate) { cancel(); return; }
|
|
36
|
+
if (timer !== null && candidate === key && !immediate) return;
|
|
37
|
+
cancel();
|
|
38
|
+
candidate = key;
|
|
39
|
+
const apply = () => {
|
|
40
|
+
timer = null;
|
|
41
|
+
current = key;
|
|
42
|
+
candidate = null;
|
|
43
|
+
commit(payload);
|
|
44
|
+
};
|
|
45
|
+
if (immediate) apply();
|
|
46
|
+
else timer = schedule(apply, key === null ? exit : current === null ? initial : retarget);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeSelectionRange(map, range) {
|
|
52
|
+
let first = null;
|
|
53
|
+
let last = null;
|
|
54
|
+
// Element offsets index children, not characters. Intersect the filtered text
|
|
55
|
+
// nodes so an exclusive endpoint before the next block never includes it.
|
|
56
|
+
for (const entry of map) {
|
|
57
|
+
const length = entry.node.nodeValue.length;
|
|
58
|
+
if (range.comparePoint(entry.node, length) < 0 || range.comparePoint(entry.node, 0) > 0) continue;
|
|
59
|
+
const from = entry.node === range.startContainer ? range.startOffset : 0;
|
|
60
|
+
const to = entry.node === range.endContainer ? range.endOffset : length;
|
|
61
|
+
if (to <= from) continue;
|
|
62
|
+
first ||= { entry, offset: from };
|
|
63
|
+
last = { entry, offset: to };
|
|
64
|
+
}
|
|
65
|
+
if (!first || !last) return null;
|
|
66
|
+
const normalized = range.cloneRange();
|
|
67
|
+
normalized.setStart(first.entry.node, first.offset);
|
|
68
|
+
normalized.setEnd(last.entry.node, last.offset);
|
|
69
|
+
return {
|
|
70
|
+
start: first.entry.start + first.offset,
|
|
71
|
+
end: last.entry.start + last.offset,
|
|
72
|
+
range: normalized,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function sameRange(one, two) {
|
|
77
|
+
return !!one && !!two &&
|
|
78
|
+
one.startContainer === two.startContainer && one.startOffset === two.startOffset &&
|
|
79
|
+
one.endContainer === two.endContainer && one.endOffset === two.endOffset;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function pointInCommentApproach(point, target, action) {
|
|
83
|
+
if (!target || !action || action.width <= 0 || action.height <= 0) return false;
|
|
84
|
+
return point.x >= Math.min(target.left, action.left) &&
|
|
85
|
+
point.x <= Math.max(target.right, action.right) &&
|
|
86
|
+
point.y >= Math.min(target.top, action.top) &&
|
|
87
|
+
point.y <= Math.max(target.bottom, action.bottom);
|
|
88
|
+
}
|
|
89
|
+
|
|
3
90
|
export function sanitizeRect(rect) {
|
|
4
91
|
if (!rect) return null;
|
|
5
92
|
const values = ["left", "top", "right", "bottom", "width", "height"];
|