@erdemtuna/doc-review 0.12.0 → 0.13.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 +23 -25
- package/lib/SKILL.md +65 -202
- package/lib/agent-handoff.js +24 -0
- package/lib/agent-output.js +287 -0
- package/lib/anchor-text.js +31 -19
- package/lib/chrome-api.js +23 -4
- package/lib/chrome.html +0 -25
- package/lib/cli.js +282 -104
- package/lib/comment-target.js +4 -0
- package/lib/contracts/agent.js +122 -0
- package/lib/contracts/feedback.js +369 -1
- package/lib/contracts/frame.js +81 -0
- package/lib/contracts/history.js +189 -1
- package/lib/contracts/index.js +7 -2
- package/lib/contracts/page-boundary.js +187 -0
- package/lib/contracts/validation.js +200 -0
- package/lib/conversation-anchor-controller.js +84 -0
- package/lib/conversation-capture.js +81 -0
- package/lib/conversation-controller.js +798 -0
- package/lib/conversation-save.js +175 -0
- package/lib/conversation-server.js +184 -0
- package/lib/conversation-shell.js +1013 -0
- package/lib/conversation-store.js +809 -0
- package/lib/frame-controller.js +8 -8
- package/lib/frame-policy.js +1 -0
- package/lib/history-policy.js +4 -15
- package/lib/history-server.js +42 -326
- package/lib/html-transform.js +19 -4
- package/lib/icons.js +323 -0
- package/lib/new-message-target.js +27 -0
- package/lib/paths.js +2 -2
- package/lib/poll-transport.js +109 -26
- package/lib/positioning.js +51 -0
- package/lib/references/context-and-recovery.md +130 -0
- package/lib/references/response-contract.md +108 -0
- package/lib/references/source-edits.md +47 -0
- package/lib/revision-store.js +1 -1
- package/lib/save-controller.js +28 -14
- package/lib/sdk.js +357 -35
- package/lib/server.js +73 -595
- package/lib/setup.js +17 -48
- package/lib/state.js +21 -7
- package/lib/thread-anchor-controller.js +56 -0
- package/lib/toolbar-controller.js +3 -3
- package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
- package/lib/ui/chrome.css +1079 -2782
- package/lib/ui/chrome.js +81 -17
- package/package.json +2 -2
- package/lib/chrome-client.js +0 -1706
package/lib/cli.js
CHANGED
|
@@ -2,20 +2,43 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, serverProtocolMatches, statePath,
|
|
7
|
+
import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, serverProtocolMatches, statePath, } from "./paths.js";
|
|
7
8
|
import { readServerLock } from "./server-lock.js";
|
|
8
|
-
import { installSkills,
|
|
9
|
-
import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
|
|
9
|
+
import { installSkills, invocation } from "./setup.js";
|
|
10
|
+
import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, mutationUntilDeadline, parseServerResponse, pollUntilDeadline, readUntilDeadline, requestRaw } from "./poll-transport.js";
|
|
11
|
+
import { agentHandoff } from "./agent-handoff.js";
|
|
12
|
+
import { readAgent, serializeAgent } from "./agent-output.js";
|
|
13
|
+
import { Conversations, validateConversations } from "./conversation-store.js";
|
|
14
|
+
import { acceptedMutationSchema, agentOpenSchema, agentPollSchema, agentReferenceSchema, agentReadResponseSchema, completeResponseSchema, contractFailure, ContractError, CONTRACT_LIMITS, id, object, openReviewRequestSchema, receiptLookupSchema, reviewReadRequestSchema, reviewSchema, text, transportOutcomeSchema, } from "./contracts/index.js";
|
|
10
15
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
16
|
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
|
|
17
|
+
const cliInvocation = invocation();
|
|
12
18
|
const HELP = `doc-review ${pkg.version}
|
|
13
19
|
|
|
14
|
-
doc-review <file-or-localhost-url>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
doc-review
|
|
20
|
+
doc-review <file-or-localhost-url> [--request-id <id>] [--no-browser]
|
|
21
|
+
Create/join a durable review; print JSON identity and commands
|
|
22
|
+
doc-review poll --review <id> --entry <key> [--timeout <secs>]
|
|
23
|
+
Wait for accepted work; default 12 hours (43200 seconds)
|
|
24
|
+
doc-review context --review <id> --entry <key> --submission <id> --thread <id> [--limit <1-100>] [--cursor <token>]
|
|
25
|
+
Read only earlier submitted exchanges, never unsent drafts
|
|
26
|
+
doc-review history --review <id> --entry <key> [--before <submission>] [--limit <1-100>] [--cursor <token>]
|
|
27
|
+
Recover prior notes/results; historical intent is not permission
|
|
28
|
+
doc-review submission --review <id> --entry <key> --submission <id> [--cursor <token>]
|
|
29
|
+
Read the complete paged inventory, including result outcomes
|
|
30
|
+
doc-review content --review <id> --entry <key> --submission <id> --field <path> [--cursor <token>]
|
|
31
|
+
Read exact scoped content; follow continuations until complete
|
|
32
|
+
--output-file <new-file> Export exact content (omit --field for the whole submission)
|
|
33
|
+
doc-review response-template --review <id> --entry <key> --submission <id> --output-file <new-file>
|
|
34
|
+
Complete inventory with stable requestId; blanks MUST be filled
|
|
35
|
+
doc-review respond --review <id> --entry <key> --response-file <file> [--timeout <secs>]
|
|
36
|
+
Submit complete JSON, including stable requestId and expectedVersion
|
|
37
|
+
doc-review receipt --review <id> --entry <key> --request-id <id>
|
|
38
|
+
Read receipt evidence; not-found is not proof of rejection
|
|
39
|
+
doc-review status --review <id> --entry <key>
|
|
40
|
+
Read status/evidence without starting a server (disk if offline)
|
|
41
|
+
--timeout <secs> One discovery/retry deadline; other commands default to 60 seconds
|
|
19
42
|
doc-review setup Teach Claude Code / Codex how to use doc-review
|
|
20
43
|
doc-review setup --global ...for every project, not just this one
|
|
21
44
|
|
|
@@ -23,15 +46,20 @@ Everything runs locally. No account, no cloud, no database.
|
|
|
23
46
|
Use Review / Changes in the browser for retained content history.
|
|
24
47
|
Plain HTML edits autosave. Self-contained file scripts run automatically with feedback-only edits.
|
|
25
48
|
Use More for optional script-disabled recovery. Comparison capture does not block sending feedback.
|
|
26
|
-
|
|
49
|
+
Only complete responses handle work. Target-only polling and acknowledgement-only completion are retired.
|
|
50
|
+
Discuss does not authorize editing; each request-change has its own scope.
|
|
51
|
+
End freezes reviewer content, not accepted work. Delivery does not imply a live handler.
|
|
52
|
+
JSON errors exit 1; unknown mutation acceptance exits 2. Retry the same response file, not source edits.
|
|
27
53
|
`;
|
|
28
54
|
// --------------------------------------------------------------- server glue
|
|
29
55
|
function readServerRecord() {
|
|
30
56
|
try {
|
|
31
57
|
return JSON.parse(fs.readFileSync(serverPath(), "utf8"));
|
|
32
58
|
}
|
|
33
|
-
catch {
|
|
34
|
-
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === "ENOENT")
|
|
61
|
+
return null;
|
|
62
|
+
throw new ContractError("INTERNAL_ERROR", `Cannot read server discovery record: ${error.message}`);
|
|
35
63
|
}
|
|
36
64
|
}
|
|
37
65
|
const request = requestRaw;
|
|
@@ -52,7 +80,7 @@ async function alive(server, deadline) {
|
|
|
52
80
|
throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
|
|
53
81
|
}
|
|
54
82
|
if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
|
|
55
|
-
throw new Error(`Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
|
|
83
|
+
throw new Error(`Incompatible live doc-review server (health protocol ${health.protocol}, record ${server.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
|
|
56
84
|
"End active reviews and stop/restart the old doc-review server before retrying. " +
|
|
57
85
|
"Its live writer lock and queued feedback have not been changed.");
|
|
58
86
|
}
|
|
@@ -104,24 +132,66 @@ function openBrowser(url) {
|
|
|
104
132
|
child.unref();
|
|
105
133
|
}
|
|
106
134
|
// ------------------------------------------------------------------ commands
|
|
107
|
-
|
|
135
|
+
const diagnostic = (message) => process.stderr.write(message);
|
|
136
|
+
const sessionSchema = object({ sessionId: id, review: reviewSchema, path: text() });
|
|
137
|
+
const timeout = (options, fallback = 60) => createDeadline(options.timeout === undefined ? fallback : Number(options.timeout));
|
|
138
|
+
const reference = (options) => agentReferenceSchema.parse({ reviewId: options.review, entryKey: options.entry });
|
|
139
|
+
const read = (body, decoder, deadline) => readUntilDeadline({ body, decoder, deadline, discover: ensureServer, diagnostic });
|
|
140
|
+
const agentRead = async (body, deadline, discover = ensureServer) => {
|
|
141
|
+
const result = await readUntilDeadline({
|
|
142
|
+
body: { ...body, invocation: cliInvocation }, decoder: agentReadResponseSchema, deadline, discover, diagnostic, route: "/api/conversation/agent",
|
|
143
|
+
});
|
|
144
|
+
if (result.operation !== body.operation)
|
|
145
|
+
throw new ContractError("SCOPE_MISMATCH", "Wrong agent operation.");
|
|
146
|
+
const scope = result.value.identity ?? result.value.review ?? result.value;
|
|
147
|
+
if (scope.reviewId !== body.reviewId || (scope.entryKey !== undefined && scope.entryKey !== body.entryKey) ||
|
|
148
|
+
(body.submissionId !== undefined && scope.submissionId !== body.submissionId)) {
|
|
149
|
+
throw new ContractError("SCOPE_MISMATCH", "Wrong agent read identity.");
|
|
150
|
+
}
|
|
151
|
+
return result.value;
|
|
152
|
+
};
|
|
153
|
+
function checkMutationEnvelope(body) {
|
|
154
|
+
// Reject undeliverable identities BEFORE acceptance, never turn a committed response into an error.
|
|
155
|
+
serializeAgent({
|
|
156
|
+
state: "accepted", value: { ok: true, receipt: {
|
|
157
|
+
receiptId: "x".repeat(128), requestId: body.requestId,
|
|
158
|
+
reviewId: body.reviewId ?? "x".repeat(128), entryKey: body.entryKey ?? "x".repeat(128),
|
|
159
|
+
operation: body.operation, acceptedAt: Number.MAX_SAFE_INTEGER,
|
|
160
|
+
value: { reviewVersion: Number.MAX_SAFE_INTEGER, submissionId: body.submissionId ?? "x".repeat(128), resultId: "x".repeat(128) },
|
|
161
|
+
} }, reserve: "x".repeat(2048),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function openCommand(input, options) {
|
|
108
165
|
const target = canonicalTarget(input);
|
|
109
166
|
if (target.kind === "file" && !fs.existsSync(target.value)) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
throw new ContractError("NOT_FOUND", `File not found: ${target.value}`);
|
|
168
|
+
}
|
|
169
|
+
const deadline = timeout(options);
|
|
170
|
+
const body = openReviewRequestSchema.parse({ operation: "open", target: target.value, requestId: options["request-id"] ?? randomUUID() });
|
|
171
|
+
checkMutationEnvelope(body);
|
|
172
|
+
const accepted = await mutationUntilDeadline({ body, deadline, discover: ensureServer, diagnostic });
|
|
173
|
+
const scope = { reviewId: accepted.receipt.reviewId, entryKey: accepted.receipt.entryKey };
|
|
174
|
+
// Opening a browser is not part of durable acceptance. Preserve the receipt if attachment fails.
|
|
175
|
+
try {
|
|
176
|
+
const server = await ensureServer(deadline);
|
|
177
|
+
const raw = await request(server, {
|
|
178
|
+
method: "POST", path: "/api/conversation/session", headers: { "content-type": "application/json" },
|
|
179
|
+
timeout: deadline.remaining(),
|
|
180
|
+
}, { operation: "read-review", ...scope });
|
|
181
|
+
const session = sessionSchema.parse(parseServerResponse(raw));
|
|
182
|
+
if (session.review.reviewId !== scope.reviewId || session.review.entryKey !== scope.entryKey ||
|
|
183
|
+
session.path !== `/r/${scope.reviewId}`)
|
|
184
|
+
throw new ContractError("SCOPE_MISMATCH", "Session belongs to another review.");
|
|
185
|
+
const url = `http://127.0.0.1:${server.port}${session.path}`;
|
|
186
|
+
const output = agentOpenSchema.parse({ ...accepted, review: session.review, url, handoff: agentHandoff(scope, null, cliInvocation) });
|
|
187
|
+
await print(output);
|
|
188
|
+
if (!options["no-browser"])
|
|
189
|
+
openBrowser(url);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
error.accepted = accepted;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
125
195
|
}
|
|
126
196
|
/**
|
|
127
197
|
* The consumer is an agent reading a pipe. process.exit() does not wait for
|
|
@@ -131,53 +201,148 @@ async function openCommand(input) {
|
|
|
131
201
|
function writeStdout(text) {
|
|
132
202
|
return new Promise((resolve) => process.stdout.write(text, resolve));
|
|
133
203
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
204
|
+
const print = (value) => writeStdout(serializeAgent(value));
|
|
205
|
+
async function pollCommand(options) {
|
|
206
|
+
const scope = reference(options);
|
|
207
|
+
const deadline = timeout(options, DEFAULT_POLL_SECONDS);
|
|
208
|
+
const result = await pollUntilDeadline({ reference: scope, deadline, discover: ensureServer, diagnostic });
|
|
209
|
+
let submission;
|
|
210
|
+
try {
|
|
211
|
+
submission = result.state === "work" ? await agentRead({
|
|
212
|
+
operation: "submission", ...scope, submissionId: result.submission.submissionId,
|
|
213
|
+
}, deadline) : undefined;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (error.code !== "POLL_DEADLINE" && !(isRecoverableTransportError(error) && deadline.remaining() <= 0))
|
|
217
|
+
throw error;
|
|
218
|
+
return print(agentPollSchema.parse({ state: "timeout", ...scope, handoff: agentHandoff(scope, null, cliInvocation) }));
|
|
219
|
+
}
|
|
220
|
+
await print(agentPollSchema.parse({
|
|
221
|
+
...result, ...(submission ? { submission } : { handoff: agentHandoff(scope, null, cliInvocation) }),
|
|
222
|
+
}));
|
|
144
223
|
}
|
|
145
224
|
/**
|
|
146
|
-
*
|
|
225
|
+
* No work wait or server startup. Asks the running server when there is one;
|
|
147
226
|
* otherwise reads the persisted state directly, so a dead server still
|
|
148
227
|
* reports feedback that is waiting for a fresh poll.
|
|
149
228
|
*/
|
|
150
|
-
async function statusCommand(
|
|
151
|
-
const
|
|
229
|
+
async function statusCommand(options) {
|
|
230
|
+
const scope = reference(options);
|
|
231
|
+
const deadline = timeout(options);
|
|
152
232
|
const saved = readServerRecord();
|
|
153
|
-
if (
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
233
|
+
if (await alive(saved, deadline)) {
|
|
234
|
+
const discover = async () => saved;
|
|
235
|
+
const output = await agentRead({ operation: "status", ...scope, ...queryOptions(options) }, deadline, discover);
|
|
236
|
+
return print(output);
|
|
159
237
|
}
|
|
160
|
-
let data
|
|
238
|
+
let data;
|
|
161
239
|
try {
|
|
162
240
|
data = JSON.parse(fs.readFileSync(statePath(), "utf8"));
|
|
241
|
+
validateConversations(data);
|
|
163
242
|
}
|
|
164
|
-
catch {
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
},
|
|
243
|
+
catch (error) {
|
|
244
|
+
throw new ContractError(error.code === "ENOENT" ? "NOT_FOUND" : "INTERNAL_ERROR", `Cannot read validated conversation state (no legacy fallback): ${error.message}`);
|
|
245
|
+
}
|
|
246
|
+
const conversations = new Conversations({ data });
|
|
247
|
+
await print(readAgent(conversations, { operation: "status", ...scope, ...queryOptions(options) }, cliInvocation, "disk").value);
|
|
248
|
+
}
|
|
249
|
+
const queryOptions = (options) => ({
|
|
250
|
+
...(options.limit === undefined ? {} : { limit: Number(options.limit) }),
|
|
251
|
+
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
|
|
252
|
+
});
|
|
253
|
+
async function readCommand(operation, options) {
|
|
254
|
+
const body = {
|
|
255
|
+
operation, ...reference(options), ...queryOptions(options),
|
|
256
|
+
...(options.submission ? { submissionId: options.submission } : {}),
|
|
257
|
+
...(options.thread ? { threadId: options.thread } : {}),
|
|
258
|
+
...(options.before ? { before: options.before } : {}),
|
|
259
|
+
...(options.field ? { field: options.field } : {}),
|
|
260
|
+
...(options.version ? { version: Number(options.version) } : {}),
|
|
179
261
|
};
|
|
180
|
-
|
|
262
|
+
const deadline = timeout(options);
|
|
263
|
+
const destination = options["output-file"] ? path.resolve(options["output-file"]) : null;
|
|
264
|
+
if (operation === "response-template" && !destination)
|
|
265
|
+
throw new ContractError("INVALID_INPUT", "Template requires --output-file.");
|
|
266
|
+
if (!destination)
|
|
267
|
+
return print(await agentRead(body, deadline));
|
|
268
|
+
if (options.cursor)
|
|
269
|
+
throw new ContractError("INVALID_INPUT", "Exports must start at the beginning, without --cursor.");
|
|
270
|
+
if (fs.existsSync(destination))
|
|
271
|
+
throw new ContractError("REQUEST_CONFLICT", "Artifact exists. Inspect/reuse it; never regenerate a retry response.");
|
|
272
|
+
if (operation === "content")
|
|
273
|
+
body.field ??= ".";
|
|
274
|
+
if (operation === "response-template")
|
|
275
|
+
body.requestId = randomUUID();
|
|
276
|
+
const temporary = path.join(path.dirname(destination), `.doc-review-${randomUUID()}.tmp`);
|
|
277
|
+
let fd;
|
|
278
|
+
try {
|
|
279
|
+
fd = fs.openSync(temporary, "wx", 0o600);
|
|
280
|
+
const digest = createHash("sha256");
|
|
281
|
+
let first, written = 0;
|
|
282
|
+
for (;;) {
|
|
283
|
+
const chunk = await agentRead(body, deadline);
|
|
284
|
+
first ??= chunk;
|
|
285
|
+
if (chunk.offset !== written || chunk.sha256 !== first.sha256 ||
|
|
286
|
+
JSON.stringify(chunk.identity) !== JSON.stringify(first.identity)) {
|
|
287
|
+
throw new ContractError("SCOPE_MISMATCH", "Export chunks changed identity/content.");
|
|
288
|
+
}
|
|
289
|
+
const data = Buffer.from(chunk.text);
|
|
290
|
+
fs.writeFileSync(fd, data);
|
|
291
|
+
digest.update(data);
|
|
292
|
+
written += data.length;
|
|
293
|
+
if (chunk.complete)
|
|
294
|
+
break;
|
|
295
|
+
body.cursor = chunk.nextCursor;
|
|
296
|
+
}
|
|
297
|
+
if (written !== first.utf8Bytes || digest.digest("hex") !== first.sha256)
|
|
298
|
+
throw new ContractError("SCOPE_MISMATCH", "Export integrity check failed.");
|
|
299
|
+
fs.fsyncSync(fd);
|
|
300
|
+
fs.closeSync(fd);
|
|
301
|
+
fd = undefined;
|
|
302
|
+
const receipt = { path: destination, identity: first.identity, encoding: first.encoding, utf8Bytes: written, sha256: first.sha256 };
|
|
303
|
+
serializeAgent(receipt);
|
|
304
|
+
// link is atomic and exclusive: never replace source files, symlinks or existing retry artifacts.
|
|
305
|
+
fs.linkSync(temporary, destination);
|
|
306
|
+
await print(receipt);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
if (fd !== undefined)
|
|
310
|
+
fs.closeSync(fd);
|
|
311
|
+
if (fs.existsSync(temporary))
|
|
312
|
+
fs.unlinkSync(temporary);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function respondCommand(options) {
|
|
316
|
+
const scope = reference(options);
|
|
317
|
+
if (!options["response-file"])
|
|
318
|
+
throw new ContractError("INVALID_INPUT", "respond requires --response-file.");
|
|
319
|
+
const file = fs.readFileSync(path.resolve(options["response-file"]));
|
|
320
|
+
if (file.byteLength > CONTRACT_LIMITS.requestBytes)
|
|
321
|
+
throw new ContractError("INPUT_TOO_LARGE", "Response file exceeds 24 MiB.");
|
|
322
|
+
let value;
|
|
323
|
+
try {
|
|
324
|
+
value = JSON.parse(file.toString("utf8"));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!(error instanceof SyntaxError))
|
|
328
|
+
throw error;
|
|
329
|
+
throw new ContractError("MALFORMED_JSON", "Response file must contain complete JSON.");
|
|
330
|
+
}
|
|
331
|
+
const body = completeResponseSchema.parse(value);
|
|
332
|
+
if (body.reviewId !== scope.reviewId || body.entryKey !== scope.entryKey)
|
|
333
|
+
throw new ContractError("SCOPE_MISMATCH", "Response file and command identity disagree.");
|
|
334
|
+
checkMutationEnvelope(body);
|
|
335
|
+
await print(await mutationUntilDeadline({ body, deadline: timeout(options), discover: ensureServer, diagnostic }));
|
|
336
|
+
}
|
|
337
|
+
async function receiptCommand(options) {
|
|
338
|
+
const scope = reference(options);
|
|
339
|
+
const body = reviewReadRequestSchema.parse({ operation: "receipt", ...scope, requestId: options["request-id"] });
|
|
340
|
+
const result = await read(body, receiptLookupSchema, timeout(options));
|
|
341
|
+
if ((result.state === "accepted" && (result.receipt.reviewId !== scope.reviewId || result.receipt.entryKey !== scope.entryKey ||
|
|
342
|
+
result.receipt.requestId !== body.requestId)) || (result.state === "not-found" && result.requestId !== body.requestId)) {
|
|
343
|
+
throw new ContractError("SCOPE_MISMATCH", "Wrong receipt lookup response.");
|
|
344
|
+
}
|
|
345
|
+
await print(result);
|
|
181
346
|
}
|
|
182
347
|
// ---------------------------------------------------------------------- main
|
|
183
348
|
const argv = process.argv.slice(2);
|
|
@@ -193,63 +358,76 @@ process.on("SIGINT", () => {
|
|
|
193
358
|
process.stderr.write("\nStopped waiting. Your feedback is safe — run the same command again to pick it up.\n");
|
|
194
359
|
process.exit(130);
|
|
195
360
|
});
|
|
196
|
-
function
|
|
197
|
-
const parsed = {
|
|
198
|
-
let sawTimeout = false;
|
|
361
|
+
function parseOptions(rest, allowed) {
|
|
362
|
+
const parsed = {};
|
|
199
363
|
for (let i = 0; i < rest.length; i += 1) {
|
|
200
364
|
const arg = rest[i];
|
|
201
|
-
if (arg === "--ack")
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
parsed.ackId = value;
|
|
207
|
-
}
|
|
208
|
-
else if (arg.startsWith("--ack=")) {
|
|
209
|
-
throw new Error("Use --ack <batch_id> with the batch ID as a separate argument.");
|
|
365
|
+
if (arg === "--ack" || arg.startsWith("--ack="))
|
|
366
|
+
throw new ContractError("INVALID_INPUT", "--ack is retired. Submit a complete response file with stable review identity.");
|
|
367
|
+
const match = /^--([a-z-]+)(?:=(.*))?$/.exec(arg);
|
|
368
|
+
if (!match || !allowed.includes(match[1]) || Object.hasOwn(parsed, match[1])) {
|
|
369
|
+
throw new ContractError("INVALID_INPUT", `Unknown/duplicate argument: ${arg}. Target-only agent commands are retired; use --review and --entry from open.`);
|
|
210
370
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
371
|
+
const key = match[1];
|
|
372
|
+
if (key === "no-browser") {
|
|
373
|
+
if (match[2] !== undefined)
|
|
374
|
+
throw new ContractError("INVALID_INPUT", "--no-browser takes no value.");
|
|
375
|
+
parsed[key] = true;
|
|
214
376
|
}
|
|
215
|
-
else
|
|
216
|
-
|
|
217
|
-
|
|
377
|
+
else {
|
|
378
|
+
const value = match[2] ?? rest[++i];
|
|
379
|
+
if (!value || value.startsWith("--"))
|
|
380
|
+
throw new ContractError("INVALID_INPUT", `--${key} requires a value.`);
|
|
381
|
+
parsed[key] = value;
|
|
218
382
|
}
|
|
219
|
-
else if (!arg.startsWith("-") && !parsed.file)
|
|
220
|
-
parsed.file = arg;
|
|
221
|
-
else
|
|
222
|
-
throw new Error(`Unknown poll argument: ${arg}`);
|
|
223
383
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (sawTimeout && (!Number.isFinite(parsed.timeoutSecs) || parsed.timeoutSecs <= 0)) {
|
|
227
|
-
throw new Error("--timeout wants a number of seconds, e.g. --timeout 300");
|
|
384
|
+
if (parsed.timeout !== undefined && (!Number.isFinite(Number(parsed.timeout)) || Number(parsed.timeout) <= 0)) {
|
|
385
|
+
throw new ContractError("INVALID_INPUT", "--timeout requires a positive finite number of seconds.");
|
|
228
386
|
}
|
|
229
387
|
return parsed;
|
|
230
388
|
}
|
|
231
389
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (!file)
|
|
241
|
-
throw new Error("Usage: doc-review status <file-or-localhost-url>");
|
|
242
|
-
await statusCommand(file);
|
|
390
|
+
const commands = { poll: pollCommand, status: statusCommand, respond: respondCommand, receipt: receiptCommand,
|
|
391
|
+
...Object.fromEntries(["context", "history", "submission", "content", "response-template"].map((name) => [name, (options) => readCommand(name, options)])) };
|
|
392
|
+
if (Object.hasOwn(commands, argv[0])) {
|
|
393
|
+
const extras = { poll: [], status: ["limit", "cursor"], context: ["submission", "thread", "limit", "cursor"],
|
|
394
|
+
history: ["before", "limit", "cursor"], submission: ["submission", "limit", "cursor"],
|
|
395
|
+
content: ["submission", "version", "field", "cursor", "output-file"], "response-template": ["submission", "output-file"],
|
|
396
|
+
respond: ["response-file"], receipt: ["request-id"] };
|
|
397
|
+
await commands[argv[0]](parseOptions(argv.slice(1), ["review", "entry", "timeout", ...extras[argv[0]]]));
|
|
243
398
|
}
|
|
244
399
|
else if (argv[0] === "setup") {
|
|
400
|
+
if (argv.slice(1).some((arg) => !["--global", "-g"].includes(arg)))
|
|
401
|
+
throw new ContractError("INVALID_INPUT", "Unknown setup argument.");
|
|
245
402
|
const isGlobal = argv.includes("--global") || argv.includes("-g");
|
|
246
403
|
installSkills(process.cwd(), { global: isGlobal }).forEach((line) => console.log(line));
|
|
247
404
|
}
|
|
248
405
|
else {
|
|
249
|
-
|
|
406
|
+
if (argv[0].startsWith("-"))
|
|
407
|
+
throw new ContractError("INVALID_INPUT", `Unknown command: ${argv[0]}`);
|
|
408
|
+
await openCommand(argv[0], parseOptions(argv.slice(1), ["request-id", "no-browser", "timeout"]));
|
|
250
409
|
}
|
|
251
410
|
}
|
|
252
411
|
catch (err) {
|
|
253
|
-
|
|
254
|
-
|
|
412
|
+
diagnostic(`${err.message || String(err)}\n`);
|
|
413
|
+
if (err.outcome) {
|
|
414
|
+
await print(transportOutcomeSchema(acceptedMutationSchema).parse(err.outcome));
|
|
415
|
+
process.exitCode = 2;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
if (err.accepted) {
|
|
419
|
+
diagnostic("Open was durably accepted, but browser attachment failed. Reuse its requestId to recover the link.\n");
|
|
420
|
+
await print(transportOutcomeSchema(acceptedMutationSchema).parse({ state: "accepted", value: err.accepted }));
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
const failure = contractFailure(err instanceof ContractError ? err : new ContractError("INTERNAL_ERROR", err.message || String(err)));
|
|
424
|
+
try {
|
|
425
|
+
await print(failure);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
await print(contractFailure(new ContractError("INPUT_TOO_LARGE", "Error details exceed the agent output budget.")));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
process.exitCode = 1;
|
|
432
|
+
}
|
|
255
433
|
}
|
package/lib/comment-target.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
2
|
+
export function sameThreadTarget(one, two) {
|
|
3
|
+
return one?.state === "found" && two?.state === "found" && one.rects.length === two.rects.length &&
|
|
4
|
+
one.rects.every((rect, index) => ["left", "top", "right", "bottom"].every((key) => Math.abs(rect[key] - two.rects[index][key]) < 1));
|
|
5
|
+
}
|
|
2
6
|
export function groupCommentTargets(targets) {
|
|
3
7
|
const groups = new Map();
|
|
4
8
|
for (const [id, element] of targets) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { handlingReceiptSchema, intentSchema, messageOutcomeSchema, editOutcomeSchema } from "./feedback.js";
|
|
2
|
+
import { reviewSchema } from "./page-boundary.js";
|
|
3
|
+
import { array, booleanValue, enumeration, id, integer, literal, nullable, object, optional, refine, reject, text, timestamp, union, version, } from "./validation.js";
|
|
4
|
+
export const AGENT_OUTPUT_BYTES = 16 * 1024;
|
|
5
|
+
export const agentReferenceSchema = object({ reviewId: id, entryKey: id });
|
|
6
|
+
const submissionScope = { reviewId: id, entryKey: id, submissionId: id };
|
|
7
|
+
export const agentReadRequestSchema = refine(object({
|
|
8
|
+
operation: enumeration(["submission", "context", "history", "content", "response-template", "status"]),
|
|
9
|
+
reviewId: id, entryKey: id,
|
|
10
|
+
submissionId: optional(id), threadId: optional(id), before: optional(id),
|
|
11
|
+
field: optional(text()), cursor: optional(text()), limit: optional(integer(1, 100)),
|
|
12
|
+
requestId: optional(id), version: optional(version),
|
|
13
|
+
invocation: optional(enumeration(["doc-review", "npx -y @erdemtuna/doc-review"])),
|
|
14
|
+
}), (request) => {
|
|
15
|
+
const allowed = {
|
|
16
|
+
submission: ["submissionId", "cursor", "limit"],
|
|
17
|
+
context: ["submissionId", "threadId", "cursor", "limit"],
|
|
18
|
+
history: ["before", "cursor", "limit"],
|
|
19
|
+
content: ["submissionId", "field", "version", "cursor"],
|
|
20
|
+
"response-template": ["submissionId", "requestId", "cursor"],
|
|
21
|
+
status: ["cursor", "limit"],
|
|
22
|
+
};
|
|
23
|
+
for (const key of Object.keys(request)) {
|
|
24
|
+
if (!["operation", "reviewId", "entryKey", "invocation", ...allowed[request.operation]].includes(key)) {
|
|
25
|
+
reject("INVALID_INPUT", `Field ${key} does not apply to ${request.operation}.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (["submission", "context", "content", "response-template"].includes(request.operation) && !request.submissionId) {
|
|
29
|
+
reject("INVALID_INPUT", "This operation requires a submission.");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
export const agentHandoffSchema = object({
|
|
33
|
+
pollCommand: text(), statusCommand: text(), responseCommand: text(),
|
|
34
|
+
historyCommand: text(), submissionCommand: optional(text()), templateCommand: optional(text()),
|
|
35
|
+
instructions: text(),
|
|
36
|
+
});
|
|
37
|
+
const identity = object({ ...submissionScope, version, field: text() });
|
|
38
|
+
const integrity = { utf8Bytes: integer(), sha256: id };
|
|
39
|
+
export const contentReferenceSchema = object({
|
|
40
|
+
kind: literal("reference"), identity, encoding: enumeration(["utf8", "json"]),
|
|
41
|
+
...integrity, command: text(), preview: optional(text(Infinity, false)),
|
|
42
|
+
fields: optional(array(object({ name: text(), encoding: enumeration(["utf8", "json"]), ...integrity }))),
|
|
43
|
+
});
|
|
44
|
+
export const agentTextSchema = union(object({ kind: literal("inline"), text: text(Infinity, false) }), contentReferenceSchema);
|
|
45
|
+
// JSON metadata is either retained exactly inline or retrieved through the same scoped content reader.
|
|
46
|
+
import { schema } from "./validation.js";
|
|
47
|
+
const jsonSchema = schema((value) => {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
reject("INVALID_INPUT", "Expected JSON data.");
|
|
50
|
+
return value;
|
|
51
|
+
});
|
|
52
|
+
export const agentDataSchema = union(object({ kind: literal("inline"), value: jsonSchema }), contentReferenceSchema);
|
|
53
|
+
export const agentItemSchema = union(object({ kind: literal("page"), pageKey: id, data: agentDataSchema }), object({ kind: literal("message"), pageKey: id, threadId: id, messageId: id, messageVersion: version,
|
|
54
|
+
intent: intentSchema, target: agentDataSchema, body: agentTextSchema, contextCommand: text() }), object({ kind: literal("edit"), pageKey: id, editId: id, editVersion: version,
|
|
55
|
+
editKind: enumeration(["edited", "deleted", "moved"]), label: agentTextSchema,
|
|
56
|
+
source: agentDataSchema, assets: agentDataSchema,
|
|
57
|
+
captureTruncated: booleanValue, truncatedFields: array(text()), content: agentDataSchema }), object({ kind: literal("response"), threadId: id, messageId: id, replyToMessageId: id,
|
|
58
|
+
outcome: messageOutcomeSchema, body: agentTextSchema }), object({ kind: literal("edit-outcome"), editId: id, editVersion: version, outcome: editOutcomeSchema, reason: agentTextSchema }));
|
|
59
|
+
const pageFields = { totalCount: integer(), returnedCount: integer(), complete: booleanValue, nextCursor: nullable(text()) };
|
|
60
|
+
export const agentInventorySchema = refine(object({ ...pageFields, items: array(agentItemSchema) }), (page) => {
|
|
61
|
+
if (page.returnedCount !== page.items.length || page.totalCount < page.returnedCount ||
|
|
62
|
+
page.complete !== (page.nextCursor === null))
|
|
63
|
+
reject("INVALID_INPUT", "Invalid inventory counts.");
|
|
64
|
+
});
|
|
65
|
+
const lifecycle = {
|
|
66
|
+
submissionId: id, version, state: enumeration(["queued", "delivered", "handled", "abandoned"]),
|
|
67
|
+
createdAt: timestamp, deliveredAt: nullable(timestamp), completedAt: nullable(timestamp),
|
|
68
|
+
abandonment: nullable(agentDataSchema),
|
|
69
|
+
};
|
|
70
|
+
const note = object({ intent: intentSchema, body: agentTextSchema });
|
|
71
|
+
const result = object({
|
|
72
|
+
resultId: id, title: enumeration(["What changed", "Agent response"]),
|
|
73
|
+
effect: enumeration(["reply-only", "changes-reported"]), body: agentTextSchema, summary: optional(agentTextSchema),
|
|
74
|
+
overallOutcome: optional(messageOutcomeSchema),
|
|
75
|
+
});
|
|
76
|
+
export const agentSubmissionSchema = object({
|
|
77
|
+
...submissionScope, version, state: lifecycle.state,
|
|
78
|
+
createdAt: timestamp, deliveredAt: nullable(timestamp), completedAt: nullable(timestamp),
|
|
79
|
+
abandonment: nullable(agentDataSchema), overallNote: optional(note), result: nullable(result),
|
|
80
|
+
receipt: nullable(agentDataSchema), inventory: agentInventorySchema, handoff: agentHandoffSchema,
|
|
81
|
+
});
|
|
82
|
+
export const agentOpenSchema = refine(object({
|
|
83
|
+
ok: literal(true), receipt: handlingReceiptSchema, review: reviewSchema, url: text(), handoff: agentHandoffSchema,
|
|
84
|
+
}), ({ receipt, review }) => {
|
|
85
|
+
if (receipt.operation !== "open" || receipt.reviewId !== review.reviewId || receipt.entryKey !== review.entryKey) {
|
|
86
|
+
reject("SCOPE_MISMATCH", "Open receipt and review disagree.");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
export const agentPollSchema = union(refine(object({ state: literal("work"), review: reviewSchema, submission: agentSubmissionSchema }), ({ review, submission }) => {
|
|
90
|
+
if (submission.state !== "delivered" || submission.reviewId !== review.reviewId || submission.entryKey !== review.entryKey) {
|
|
91
|
+
reject("SCOPE_MISMATCH", "Work is not delivered in this exact review.");
|
|
92
|
+
}
|
|
93
|
+
}), object({ state: literal("ended"), review: refine(reviewSchema, (review) => {
|
|
94
|
+
if (review.state !== "ended")
|
|
95
|
+
reject("INVALID_INPUT", "Only ended reviews stop polling.");
|
|
96
|
+
}), handoff: agentHandoffSchema }), object({ state: literal("timeout"), reviewId: id, entryKey: id, handoff: agentHandoffSchema }));
|
|
97
|
+
export const agentHistorySchema = object({
|
|
98
|
+
reviewId: id, entryKey: id, before: nullable(id), ...pageFields,
|
|
99
|
+
items: array(object({ ...lifecycle, overallNote: optional(note), result: nullable(result), command: text() })),
|
|
100
|
+
});
|
|
101
|
+
export const agentContextSchema = object({
|
|
102
|
+
...submissionScope, threadId: id, ...pageFields,
|
|
103
|
+
items: array(object({
|
|
104
|
+
submissionId: id, state: lifecycle.state, deliveredAt: nullable(timestamp), completedAt: nullable(timestamp),
|
|
105
|
+
messageId: id, messageVersion: version, intent: intentSchema, body: agentTextSchema,
|
|
106
|
+
response: nullable(object({ outcome: messageOutcomeSchema, body: agentTextSchema })),
|
|
107
|
+
})),
|
|
108
|
+
});
|
|
109
|
+
export const agentContentSchema = object({
|
|
110
|
+
identity, encoding: enumeration(["utf8", "json"]), ...integrity,
|
|
111
|
+
offset: integer(), returnedBytes: integer(), complete: booleanValue, nextCursor: nullable(text()), text: text(Infinity, false),
|
|
112
|
+
});
|
|
113
|
+
export const agentStatusSchema = object({
|
|
114
|
+
source: enumeration(["server", "disk"]), review: reviewSchema,
|
|
115
|
+
openThreadCount: integer(), attentionEditCount: integer(),
|
|
116
|
+
pendingMessageCount: integer(), pendingEditCount: integer(),
|
|
117
|
+
work: nullable(object({ submissionId: id, state: enumeration(["queued", "delivered"]), version })),
|
|
118
|
+
blockers: object({ ...pageFields, items: array(jsonSchema) }),
|
|
119
|
+
latestSubmission: nullable(object({ ...lifecycle, result: nullable(result) })),
|
|
120
|
+
handoff: agentHandoffSchema,
|
|
121
|
+
});
|
|
122
|
+
export const agentReadResponseSchema = union(object({ operation: literal("submission"), value: agentSubmissionSchema }), object({ operation: literal("context"), value: agentContextSchema }), object({ operation: literal("history"), value: agentHistorySchema }), object({ operation: literal("content"), value: agentContentSchema }), object({ operation: literal("response-template"), value: agentContentSchema }), object({ operation: literal("status"), value: agentStatusSchema }));
|