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