@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.
Files changed (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. 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, targetKey, } from "./paths.js";
7
+ import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, serverProtocolMatches, statePath, } from "./paths.js";
7
8
  import { readServerLock } from "./server-lock.js";
8
- import { installSkills, shellQuote } from "./setup.js";
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> Open a file or localhost page for review
15
- doc-review poll <target> Wait for feedback, print it as JSON (for agents)
16
- --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
17
- --timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
18
- doc-review status <target> Report whether feedback is waiting, without blocking
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
- Acknowledgement handles feedback; browser result capture may complete later.
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
- return null;
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
- async function openCommand(input) {
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
- console.error(`File not found: ${target.value}`);
111
- process.exit(1);
112
- }
113
- const server = await ensureServer();
114
- const res = await request(server, { method: "POST", path: "/api/session", headers: { "content-type": "application/json" } }, { target: target.value });
115
- const body = JSON.parse(res.raw);
116
- if (res.status !== 200) {
117
- console.error(body.error || "Could not open that file.");
118
- process.exit(1);
119
- }
120
- const url = `http://127.0.0.1:${server.port}${body.path}`;
121
- openBrowser(url);
122
- console.log(`Reviewing ${target.kind === "url" ? target.value : path.basename(target.value)}`);
123
- console.log(url);
124
- console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
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
- async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
135
- const deadline = createDeadline(timeoutSecs);
136
- const target = canonicalTarget(input).value;
137
- const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
138
- process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
139
- const batch = await pollUntilDeadline({
140
- target, ackId, deadline, discover: ensureServer,
141
- diagnostic: (text) => process.stderr.write(text),
142
- });
143
- await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
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
- * Instant answer, no blocking. Asks the running server when there is one;
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(input) {
151
- const target = canonicalTarget(input).value;
229
+ async function statusCommand(options) {
230
+ const scope = reference(options);
231
+ const deadline = timeout(options);
152
232
  const saved = readServerRecord();
153
- if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) {
154
- const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
155
- if (res.status === 200) {
156
- process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
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 = { pages: {}, batches: {} };
238
+ let data;
161
239
  try {
162
240
  data = JSON.parse(fs.readFileSync(statePath(), "utf8"));
241
+ validateConversations(data);
163
242
  }
164
- catch {
165
- // No state yet: everything below reads as empty.
166
- }
167
- const key = targetKey(target);
168
- const pending = (data.batches || {})[key];
169
- const page = (data.pages || {})[key];
170
- const payload = {
171
- status: pending ? "feedback-waiting" : "idle",
172
- feedback_waiting: !!pending,
173
- agent_listening: false,
174
- server_running: false,
175
- unsent: {
176
- comments: page ? page.comments.length : 0,
177
- edits: page ? page.edits.length : 0,
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
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
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 parsePollArgs(rest) {
197
- const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
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
- const value = rest[(i += 1)];
203
- if (!value || value.startsWith("-")) {
204
- throw new Error("--ack requires the batch_id from the feedback response, e.g. --ack b_123");
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
- else if (arg === "--timeout") {
212
- sawTimeout = true;
213
- parsed.timeoutSecs = Number(rest[(i += 1)]);
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 if (arg.startsWith("--timeout=")) {
216
- sawTimeout = true;
217
- parsed.timeoutSecs = Number(arg.slice("--timeout=".length));
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
- // A malformed value must fail loudly — NaN or 0 silently waiting forever is
225
- // the exact hang the flag exists to prevent.
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
- if (argv[0] === "poll") {
233
- const { file, ackId, timeoutSecs } = parsePollArgs(argv.slice(1));
234
- if (!file)
235
- throw new Error("Usage: doc-review poll <file-or-localhost-url> [--ack <batch_id>] [--timeout <secs>]");
236
- await pollCommand(file, { ackId, timeoutSecs });
237
- }
238
- else if (argv[0] === "status") {
239
- const file = argv.find((a, i) => i > 0 && !a.startsWith("-"));
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
- await openCommand(argv[0]);
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
- console.error(err.message || String(err));
254
- process.exit(1);
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
  }
@@ -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 }));