@erdemtuna/doc-review 0.12.0 → 0.14.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 +1118 -2787
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
@@ -1 +1,369 @@
1
- export {};
1
+ import { array, booleanValue, canonicalJson, CONTRACT_LIMITS, enumeration, id, integer, literal, nullable, object, optional, refine, reject, text, timestamp, union, unique, version, } from "./validation.js";
2
+ export const intentSchema = enumeration(["discuss", "request-change"]);
3
+ export const DEFAULT_REVIEWER_INTENT = "discuss";
4
+ export function intentBadge(intent) {
5
+ return intentSchema.parse(intent) === "discuss" ? "Discussion" : "Change requested";
6
+ }
7
+ export const conversationTargetSchema = union(object({
8
+ kind: literal("selection"),
9
+ anchor: object({
10
+ quote: text(CONTRACT_LIMITS.quoteUnits),
11
+ prefix: optional(text(CONTRACT_LIMITS.contextUnits, false)),
12
+ suffix: optional(text(CONTRACT_LIMITS.contextUnits, false)),
13
+ selector: optional(text(CONTRACT_LIMITS.selectorUnits)),
14
+ }),
15
+ }), object({
16
+ kind: literal("element"),
17
+ anchor: object({
18
+ selector: text(CONTRACT_LIMITS.selectorUnits),
19
+ label: optional(text(CONTRACT_LIMITS.labelUnits, false)),
20
+ }),
21
+ }));
22
+ export const overallNoteSchema = object({ body: text(), intent: intentSchema });
23
+ export const messageOutcomeSchema = enumeration(["applied", "answered", "clarification-needed", "deferred"]);
24
+ export const editOutcomeSchema = enumeration(["applied", "already-saved", "deferred"]);
25
+ const messageFields = {
26
+ messageId: id, reviewId: id, threadId: id, version, sequence: integer(1),
27
+ createdAt: timestamp, updatedAt: timestamp, body: text(),
28
+ };
29
+ export const reviewerMessageSchema = object({
30
+ ...messageFields, author: literal("reviewer"), intent: intentSchema, submissionId: nullable(id),
31
+ });
32
+ export const agentMessageSchema = object({
33
+ ...messageFields, author: literal("agent"), submissionId: id, replyToMessageId: id,
34
+ outcome: messageOutcomeSchema,
35
+ });
36
+ export const conversationMessageSchema = union(reviewerMessageSchema, agentMessageSchema);
37
+ export const conversationThreadSchema = object({
38
+ threadId: id, reviewId: id, pageKey: id, version, sequence: integer(1),
39
+ target: conversationTargetSchema, status: enumeration(["open", "resolved"]),
40
+ createdAt: timestamp, updatedAt: timestamp,
41
+ });
42
+ const editTextSchema = text(CONTRACT_LIMITS.editCodePoints, false, true);
43
+ const editTextFields = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
44
+ const editContentFields = {
45
+ label: text(), before: optional(editTextSchema), after: optional(editTextSchema),
46
+ before_html: optional(editTextSchema), after_html: optional(editTextSchema),
47
+ truncated: booleanValue, truncated_fields: array(enumeration(editTextFields), editTextFields.length),
48
+ staged_assets: array(object({ id, preview_src: text() }), CONTRACT_LIMITS.editAssets),
49
+ };
50
+ export const directEditContentSchema = refine(union(object({ ...editContentFields, kind: literal("edited") }), object({ ...editContentFields, kind: literal("deleted") }), object({ ...editContentFields, kind: literal("moved"), moved_after: editTextSchema, moved_before: editTextSchema })), (edit) => {
51
+ unique(edit.truncated_fields);
52
+ unique(edit.staged_assets.map((asset) => asset.id));
53
+ if (edit.truncated !== (edit.truncated_fields.length > 0))
54
+ reject("INVALID_INPUT", "Inconsistent truncation metadata.");
55
+ for (const field of edit.truncated_fields) {
56
+ if (!(field in edit))
57
+ reject("INVALID_INPUT", "Truncated field must be preserved.");
58
+ }
59
+ if (edit.before === undefined && edit.before_html === undefined)
60
+ reject("INVALID_INPUT", "An edit requires its before content.");
61
+ if (edit.kind === "edited" && edit.after === undefined && edit.after_html === undefined) {
62
+ reject("INVALID_INPUT", "An edited block requires its after content.");
63
+ }
64
+ });
65
+ export const saveEvidenceSchema = object({
66
+ evidenceId: id, reviewId: id, pageKey: id, editId: id, editVersion: version,
67
+ sourceHash: id, savedAt: timestamp,
68
+ });
69
+ export const directEditSchema = refine(object({
70
+ editId: id, reviewId: id, pageKey: id, version, sequence: integer(1),
71
+ author: literal("reviewer"), createdAt: timestamp, updatedAt: timestamp,
72
+ content: directEditContentSchema,
73
+ source: union(object({ state: literal("pending") }), object({ state: literal("saved"), evidence: saveEvidenceSchema })),
74
+ assets: array(object({ id, path: text(), preview_src: text() }), CONTRACT_LIMITS.editAssets),
75
+ }), (edit) => {
76
+ unique(edit.assets.map((asset) => asset.id));
77
+ if (canonicalJson(edit.assets.map(({ id: assetId, preview_src }) => ({ id: assetId, preview_src }))) !==
78
+ canonicalJson(edit.content.staged_assets))
79
+ reject("INVALID_INPUT", "Durable assets must match staged references.");
80
+ if (edit.source.state === "saved") {
81
+ const evidence = edit.source.evidence;
82
+ if (evidence.reviewId !== edit.reviewId || evidence.pageKey !== edit.pageKey ||
83
+ evidence.editId !== edit.editId || evidence.editVersion !== edit.version || edit.content.truncated) {
84
+ reject("SAVE_EVIDENCE_CONFLICT", "Save evidence does not describe this exact complete edit.");
85
+ }
86
+ }
87
+ });
88
+ export function validateSaveEvidence(edit, currentSourceHash, writable) {
89
+ directEditSchema.parse(edit);
90
+ if (edit.source.state !== "saved" || !writable || edit.source.evidence.sourceHash !== currentSourceHash) {
91
+ reject("SAVE_EVIDENCE_CONFLICT", "Current writable source does not match server-owned save evidence.");
92
+ }
93
+ }
94
+ export const messageSelectionSchema = object({ threadId: id, messageId: id, version });
95
+ export const editSelectionSchema = object({ pageKey: id, editId: id, version });
96
+ export const submittedMessageSchema = object({
97
+ pageKey: id, target: conversationTargetSchema, message: reviewerMessageSchema,
98
+ });
99
+ export const submissionSchema = refine(object({
100
+ submissionId: id, reviewId: id, entryKey: id, version, sequence: integer(1),
101
+ state: enumeration(["queued", "delivered", "handled", "abandoned"]),
102
+ createdAt: timestamp, deliveredAt: nullable(timestamp), completedAt: nullable(timestamp),
103
+ pageKeys: array(id), exclusionKeys: array(id),
104
+ messages: array(submittedMessageSchema), edits: array(directEditSchema),
105
+ overallNote: optional(overallNoteSchema),
106
+ resultId: nullable(id), abandonment: nullable(object({ requestId: id, at: timestamp, reason: text() })),
107
+ }), (submission) => {
108
+ unique(submission.pageKeys);
109
+ unique(submission.exclusionKeys);
110
+ unique(submission.messages.map(({ message }) => message.messageId));
111
+ unique(submission.edits.map((edit) => edit.editId));
112
+ if (!submission.pageKeys.length ||
113
+ (!submission.messages.length && !submission.edits.length && !submission.overallNote)) {
114
+ reject("EMPTY_SUBMISSION", "Select known pages and at least one message, edit, or overall note.");
115
+ }
116
+ const expectedKeys = exclusionKeys(submission.entryKey, submission.pageKeys);
117
+ if (canonicalJson(expectedKeys) !== canonicalJson(submission.exclusionKeys)) {
118
+ reject("INVALID_INPUT", "Exclusions must be the sorted entry and submitted page target keys.");
119
+ }
120
+ for (const { pageKey, message } of submission.messages) {
121
+ if (message.reviewId !== submission.reviewId || message.submissionId !== submission.submissionId ||
122
+ !submission.pageKeys.includes(pageKey))
123
+ reject("SCOPE_MISMATCH", "Message is outside the submission.");
124
+ }
125
+ for (const edit of submission.edits) {
126
+ if (edit.reviewId !== submission.reviewId || !submission.pageKeys.includes(edit.pageKey)) {
127
+ reject("SCOPE_MISMATCH", "Edit is outside the submission.");
128
+ }
129
+ }
130
+ if ((submission.state === "queued" && submission.deliveredAt !== null) ||
131
+ ((submission.state === "delivered" || submission.state === "handled") && submission.deliveredAt === null) ||
132
+ ((submission.state === "handled" || submission.state === "abandoned") !== (submission.completedAt !== null)) ||
133
+ ((submission.state === "handled") !== (submission.resultId !== null)) ||
134
+ ((submission.state === "abandoned") !== (submission.abandonment !== null))) {
135
+ reject("INVALID_INPUT", "Inconsistent submission lifecycle evidence.");
136
+ }
137
+ });
138
+ export const inlineResponseSchema = object({
139
+ threadId: id, messageId: id, messageVersion: version, body: text(), outcome: messageOutcomeSchema,
140
+ });
141
+ export const directEditOutcomeSchema = object({
142
+ editId: id, editVersion: version, outcome: editOutcomeSchema, reason: text(),
143
+ });
144
+ export const completeResponseSchema = object({
145
+ operation: literal("respond"), reviewId: id, entryKey: id, submissionId: id,
146
+ requestId: id, expectedVersion: version,
147
+ responses: array(inlineResponseSchema), editOutcomes: array(directEditOutcomeSchema),
148
+ resultNote: text(), summary: optional(text()), overallOutcome: optional(messageOutcomeSchema),
149
+ });
150
+ export function validateResponseCoverage(input, delivered) {
151
+ const response = completeResponseSchema.parse(input);
152
+ const submission = submissionSchema.parse(delivered);
153
+ if (response.reviewId !== submission.reviewId || response.entryKey !== submission.entryKey ||
154
+ response.submissionId !== submission.submissionId)
155
+ reject("SCOPE_MISMATCH", "Response belongs to another submission.");
156
+ if (submission.state === "abandoned")
157
+ reject("SUBMISSION_ABANDONED", "Abandonment won; late completion is rejected.");
158
+ if (submission.state === "handled")
159
+ reject("ALREADY_HANDLED", "Use the original response request for replay.");
160
+ if (submission.state !== "delivered")
161
+ reject("RESPONSE_COVERAGE", "Only delivered work can be completed.");
162
+ if (response.expectedVersion !== submission.version)
163
+ reject("VERSION_CONFLICT", "Submission version changed.");
164
+ const messages = new Map(submission.messages.map(({ message }) => [message.messageId, message]));
165
+ const edits = new Map(submission.edits.map((edit) => [edit.editId, edit]));
166
+ if (response.responses.length !== messages.size || response.editOutcomes.length !== edits.size) {
167
+ reject("RESPONSE_COVERAGE", "Every submitted message and exact edit must have one outcome.");
168
+ }
169
+ for (const reply of response.responses) {
170
+ const message = messages.get(reply.messageId);
171
+ if (!message || reply.threadId !== message.threadId || reply.messageVersion !== message.version) {
172
+ reject("RESPONSE_COVERAGE", "Unknown, duplicate, or mismatched message response.");
173
+ }
174
+ if (message.intent === "discuss" && reply.outcome === "applied")
175
+ reject("RESPONSE_COVERAGE", "Discuss cannot authorize Applied.");
176
+ messages.delete(reply.messageId);
177
+ }
178
+ for (const outcome of response.editOutcomes) {
179
+ const edit = edits.get(outcome.editId);
180
+ if (!edit || outcome.editVersion !== edit.version)
181
+ reject("RESPONSE_COVERAGE", "Unknown, duplicate, or mismatched edit.");
182
+ if ((outcome.outcome === "already-saved" && edit.source.state !== "saved") ||
183
+ (outcome.outcome === "applied" && (edit.source.state === "saved" || edit.content.truncated))) {
184
+ reject("SAVE_EVIDENCE_CONFLICT", "Outcome cannot claim new work or saved evidence for this edit.");
185
+ }
186
+ edits.delete(outcome.editId);
187
+ }
188
+ if ((submission.overallNote !== undefined) !== (response.overallOutcome !== undefined) ||
189
+ (submission.overallNote?.intent === "discuss" && response.overallOutcome === "applied")) {
190
+ reject("RESPONSE_COVERAGE", "Overall outcome requires its own nonempty note and intent.");
191
+ }
192
+ return response;
193
+ }
194
+ export function responseEffect(response) {
195
+ completeResponseSchema.parse(response);
196
+ return response.responses.some((reply) => reply.outcome === "applied") ||
197
+ response.editOutcomes.some((edit) => edit.outcome === "applied") || response.overallOutcome === "applied"
198
+ ? "changes-reported" : "reply-only";
199
+ }
200
+ export const submissionResultSchema = object({
201
+ resultId: id, reviewId: id, submissionId: id, createdAt: timestamp, sequence: integer(1),
202
+ author: literal("agent"), body: text(), summary: optional(text()), title: enumeration(["What changed", "Agent response"]),
203
+ effect: enumeration(["reply-only", "changes-reported"]),
204
+ responses: array(agentMessageSchema), editOutcomes: array(directEditOutcomeSchema),
205
+ overallOutcome: optional(messageOutcomeSchema),
206
+ });
207
+ export function resultTitle(response) {
208
+ completeResponseSchema.parse(response);
209
+ return responseEffect(response) === "changes-reported" ||
210
+ response.editOutcomes.some((edit) => edit.outcome === "already-saved") ? "What changed" : "Agent response";
211
+ }
212
+ const mutationFields = { reviewId: id, entryKey: id, requestId: id, expectedVersion: version };
213
+ export const joinReviewPageRequestSchema = object({
214
+ ...mutationFields, operation: literal("join-page"), target: text(),
215
+ });
216
+ export const sendRequestSchema = refine(object({
217
+ ...mutationFields, operation: literal("send"),
218
+ pageKeys: array(id), messages: array(messageSelectionSchema), edits: array(editSelectionSchema),
219
+ overallNote: optional(overallNoteSchema),
220
+ }), (request) => {
221
+ unique(request.pageKeys);
222
+ unique(request.messages.map((message) => message.messageId));
223
+ unique(request.edits.map((edit) => edit.editId));
224
+ if (!request.pageKeys.length || (!request.messages.length && !request.edits.length && !request.overallNote)) {
225
+ reject("EMPTY_SUBMISSION", "Send requires selected known pages and feedback.");
226
+ }
227
+ if (request.edits.some((edit) => !request.pageKeys.includes(edit.pageKey)))
228
+ reject("SCOPE_MISMATCH", "Unselected edit page.");
229
+ });
230
+ export const reviewerMutationSchema = union(joinReviewPageRequestSchema, object({ ...mutationFields, operation: literal("create-thread"), pageKey: id, target: conversationTargetSchema,
231
+ body: text(), intent: intentSchema }), object({ ...mutationFields, operation: literal("reply"), threadId: id, body: text(), intent: intentSchema }), object({ ...mutationFields, operation: literal("update-message"), threadId: id, messageId: id,
232
+ messageVersion: version, body: text(), intent: intentSchema }), object({ ...mutationFields, operation: literal("set-thread-status"), threadId: id, status: enumeration(["open", "resolved"]) }), object({ ...mutationFields, operation: literal("delete-thread"), threadId: id }), object({ ...mutationFields, operation: literal("record-edit"), pageKey: id,
233
+ editId: optional(id), editVersion: optional(version), content: directEditContentSchema }), object({ ...mutationFields, operation: literal("save-edit"), pageKey: id, editId: id, editVersion: version,
234
+ expectedSourceHash: id, html: text(Infinity, false) }), object({ ...mutationFields, operation: literal("revert"), pageKey: id, expectedSourceHash: id, baselineRevisionId: id }), sendRequestSchema, object({ ...mutationFields, operation: literal("end"), confirmUnsentReadOnly: literal(true) }));
235
+ export const abandonRequestSchema = object({
236
+ ...mutationFields, operation: literal("abandon"), submissionId: id,
237
+ confirmExternalWorkMayContinue: literal(true), reason: text(),
238
+ });
239
+ export const receiptValueSchema = object({
240
+ reviewVersion: version,
241
+ pageKey: optional(id), threadId: optional(id), messageId: optional(id), editId: optional(id),
242
+ submissionId: optional(id), resultId: optional(id),
243
+ });
244
+ export const mutationOperationSchema = enumeration([
245
+ "open", "join-page", "create-thread", "reply", "update-message", "set-thread-status", "delete-thread",
246
+ "record-edit", "save-edit", "revert", "send", "end", "abandon", "respond",
247
+ ]);
248
+ export const handlingReceiptSchema = refine(object({
249
+ receiptId: id, requestId: id, reviewId: id, entryKey: id,
250
+ operation: mutationOperationSchema, acceptedAt: timestamp, value: receiptValueSchema,
251
+ }), (receipt) => {
252
+ const required = {
253
+ "join-page": ["pageKey"],
254
+ "create-thread": ["threadId", "messageId"], reply: ["threadId", "messageId"],
255
+ "update-message": ["threadId", "messageId"], "set-thread-status": ["threadId"], "delete-thread": ["threadId"],
256
+ "record-edit": ["editId"], "save-edit": ["editId"], send: ["submissionId"],
257
+ abandon: ["submissionId"], respond: ["submissionId", "resultId"],
258
+ };
259
+ for (const field of required[receipt.operation] ?? []) {
260
+ if (receipt.value[field] === undefined)
261
+ reject("INVALID_INPUT", `Receipt requires ${field}.`);
262
+ }
263
+ const allowed = new Set(["reviewVersion", ...required[receipt.operation] ?? []]);
264
+ for (const field of Object.keys(receipt.value)) {
265
+ if (!allowed.has(field))
266
+ reject("INVALID_INPUT", `Receipt operation does not produce ${field}.`);
267
+ }
268
+ });
269
+ export const acceptedMutationSchema = object({ ok: literal(true), receipt: handlingReceiptSchema });
270
+ export const receiptLookupSchema = union(object({ state: literal("accepted"), receipt: handlingReceiptSchema }), object({ state: literal("not-found"), requestId: id }));
271
+ /** Call after authentication/scope checks, but before ANY version/lifecycle/exclusion check. */
272
+ export function exactReplay(request, previous) {
273
+ if (!previous)
274
+ return null;
275
+ const receipt = handlingReceiptSchema.parse(previous.receipt);
276
+ if (request.requestId !== receipt.requestId || canonicalJson(request) !== previous.canonicalPayload) {
277
+ reject("REQUEST_CONFLICT", "Request identity was already used with different content.");
278
+ }
279
+ if (request.operation !== receipt.operation || (request.reviewId !== undefined && request.reviewId !== receipt.reviewId) ||
280
+ (request.entryKey !== undefined && request.entryKey !== receipt.entryKey)) {
281
+ reject("INVALID_INPUT", "Stored replay receipt does not match its request scope/operation.");
282
+ }
283
+ return receipt;
284
+ }
285
+ export function exclusionKeys(entryKey, pageKeys) {
286
+ id.parse(entryKey);
287
+ array(id).parse(pageKeys);
288
+ return [...new Set([entryKey, ...pageKeys])].sort();
289
+ }
290
+ export function outstandingSubmissions(targets, submissions) {
291
+ return submissions.filter((submission) => {
292
+ submissionSchema.parse(submission);
293
+ return (submission.state === "queued" || submission.state === "delivered") &&
294
+ submission.exclusionKeys.some((key) => targets.includes(key));
295
+ }).map((submission) => submission.submissionId);
296
+ }
297
+ export function assertNoOutstandingWork(targets, submissions) {
298
+ if (outstandingSubmissions(targets, submissions).length)
299
+ reject("WORK_OUTSTANDING", "A known canonical target has outstanding work.");
300
+ }
301
+ export function validateAbandonment(input, current) {
302
+ const request = abandonRequestSchema.parse(input);
303
+ submissionSchema.parse(current);
304
+ if (request.reviewId !== current.reviewId || request.entryKey !== current.entryKey ||
305
+ request.submissionId !== current.submissionId)
306
+ reject("SCOPE_MISMATCH", "Submission belongs to another review.");
307
+ if (current.state === "handled")
308
+ reject("ALREADY_HANDLED", "Response committed first; abandonment is inapplicable.");
309
+ if (current.state === "abandoned")
310
+ reject("SUBMISSION_ABANDONED", "Use the original abandonment request for replay.");
311
+ if (request.expectedVersion !== current.version)
312
+ reject("VERSION_CONFLICT", "Submission version changed.");
313
+ return request;
314
+ }
315
+ /** Validate the complete transaction's public artifacts, not an acknowledgement-only receipt. */
316
+ export function validateResponseCommit(submission, input, resultInput, receiptInput) {
317
+ const response = validateResponseCoverage(input, submission);
318
+ const result = submissionResultSchema.parse(resultInput);
319
+ const receipt = handlingReceiptSchema.parse(receiptInput);
320
+ if (result.reviewId !== submission.reviewId || result.submissionId !== submission.submissionId ||
321
+ result.body !== response.resultNote || result.summary !== response.summary || result.title !== resultTitle(response) ||
322
+ result.effect !== responseEffect(response) || result.overallOutcome !== response.overallOutcome ||
323
+ canonicalJson(result.editOutcomes) !== canonicalJson(response.editOutcomes) ||
324
+ result.responses.length !== response.responses.length) {
325
+ reject("RESPONSE_COVERAGE", "Result does not represent the complete accepted response.");
326
+ }
327
+ unique(result.responses.map((reply) => reply.messageId));
328
+ for (let index = 0; index < response.responses.length; index++) {
329
+ const inputReply = response.responses[index];
330
+ const savedReply = result.responses[index];
331
+ if (savedReply.reviewId !== submission.reviewId || savedReply.submissionId !== submission.submissionId ||
332
+ savedReply.threadId !== inputReply.threadId || savedReply.replyToMessageId !== inputReply.messageId ||
333
+ savedReply.body !== inputReply.body || savedReply.outcome !== inputReply.outcome) {
334
+ reject("RESPONSE_COVERAGE", "Persisted reply association differs from accepted response.");
335
+ }
336
+ }
337
+ if (receipt.operation !== "respond" || receipt.requestId !== response.requestId ||
338
+ receipt.reviewId !== result.reviewId || receipt.entryKey !== submission.entryKey ||
339
+ receipt.value.submissionId !== result.submissionId || receipt.value.resultId !== result.resultId) {
340
+ reject("RESPONSE_COVERAGE", "Atomic result and handling receipt disagree.");
341
+ }
342
+ return { result, receipt };
343
+ }
344
+ export const submissionReadSchema = refine(object({
345
+ submission: submissionSchema, result: nullable(submissionResultSchema), receipt: nullable(handlingReceiptSchema),
346
+ }), ({ submission, result, receipt }) => {
347
+ if (submission.state !== "handled") {
348
+ if (result !== null || receipt !== null)
349
+ reject("INVALID_INPUT", "Uncompleted work cannot claim a handling result.");
350
+ return;
351
+ }
352
+ if (!result || !receipt || result.resultId !== submission.resultId)
353
+ reject("RESPONSE_COVERAGE", "Handled submission requires its result and receipt.");
354
+ const responses = result.responses.map((reply) => {
355
+ const message = submission.messages.find((item) => item.message.messageId === reply.replyToMessageId)?.message;
356
+ if (!message)
357
+ reject("RESPONSE_COVERAGE", "Reply references an unknown submitted message.");
358
+ return { threadId: reply.threadId, messageId: message.messageId, messageVersion: message.version,
359
+ body: reply.body, outcome: reply.outcome };
360
+ });
361
+ const deliveredVersion = submission.version - 1;
362
+ validateResponseCommit({ ...submission, version: deliveredVersion, state: "delivered", completedAt: null, resultId: null }, {
363
+ operation: "respond", reviewId: submission.reviewId, entryKey: submission.entryKey, submissionId: submission.submissionId,
364
+ requestId: receipt.requestId, expectedVersion: deliveredVersion,
365
+ responses, editOutcomes: result.editOutcomes, resultNote: result.body,
366
+ ...(result.summary === undefined ? {} : { summary: result.summary }),
367
+ ...(result.overallOutcome === undefined ? {} : { overallOutcome: result.overallOutcome }),
368
+ }, result, receipt);
369
+ });
@@ -1,3 +1,5 @@
1
+ import { conversationTargetSchema } from "./feedback.js";
2
+ import { array, booleanValue, enumeration, id, integer, literal, object, optional, refine, reject, schema, union, unique, } from "./validation.js";
1
3
  export function isThemePayload(value) {
2
4
  return typeof value === "object" && value !== null &&
3
5
  "theme" in value && (value.theme === "light" || value.theme === "dark") &&
@@ -11,3 +13,82 @@ export function isFrameIdentity(value) {
11
13
  "generation" in value && typeof value.generation === "number" &&
12
14
  "pageKey" in value && typeof value.pageKey === "string";
13
15
  }
16
+ const finiteCoordinate = schema((value, path) => typeof value === "number" && Number.isFinite(value)
17
+ ? value : reject("INVALID_INPUT", "Expected finite geometry.", path));
18
+ const dimension = refine(finiteCoordinate, (value) => {
19
+ if (value < 0)
20
+ reject("INVALID_INPUT", "Negative geometry dimension.");
21
+ });
22
+ export const conversationRectSchema = refine(object({
23
+ left: finiteCoordinate, top: finiteCoordinate, right: finiteCoordinate, bottom: finiteCoordinate,
24
+ width: dimension, height: dimension,
25
+ }), (rect) => {
26
+ if (rect.right < rect.left || rect.bottom < rect.top)
27
+ reject("INVALID_INPUT", "Inverted geometry.");
28
+ });
29
+ const renderScopeFields = { capability: id, reviewId: id, pageKey: id, renderId: id, generation: integer(1) };
30
+ const projectionScopeFields = { ...renderScopeFields, projectionRevision: integer(1) };
31
+ export const anchorProjectionSchema = object({ threadId: id, target: conversationTargetSchema, resolved: optional(booleanValue) });
32
+ export const frameAnchorsSchema = refine(object({
33
+ type: literal("eh:threadAnchors"), ...projectionScopeFields, anchors: array(anchorProjectionSchema),
34
+ }), ({ anchors }) => unique(anchors.map((anchor) => anchor.threadId)));
35
+ const anchorStateFields = { threadId: id };
36
+ export const anchorStateSchema = union(object({
37
+ ...anchorStateFields, state: literal("found"),
38
+ rects: refine(array(conversationRectSchema), (rects) => {
39
+ if (!rects.length)
40
+ reject("INVALID_INPUT", "Found target needs geometry.");
41
+ }),
42
+ viewport: object({ width: dimension, height: dimension }),
43
+ relation: enumeration(["visible", "above", "below", "left", "right"]),
44
+ }), object({ ...anchorStateFields, state: literal("missing") }), object({ ...anchorStateFields, state: literal("ambiguous"), candidateCount: integer(2) }), object({ ...anchorStateFields, state: literal("unavailable"),
45
+ reason: enumeration(["render-loading", "render-changed", "render-unavailable", "hidden", "not-measurable", "invalid-selector"]) }));
46
+ export const frameAnchorStatesSchema = refine(object({
47
+ type: literal("eh:threadAnchorStates"), ...projectionScopeFields, anchors: array(anchorStateSchema),
48
+ }), ({ anchors }) => unique(anchors.map((anchor) => anchor.threadId)));
49
+ export const frameThreadActionSchema = object({
50
+ type: literal("eh:threadAction"), ...projectionScopeFields,
51
+ action: enumeration(["activate", "reveal", "dismiss"]), threadId: id,
52
+ });
53
+ function validateRenderScope(states, projection) {
54
+ if (states.reviewId !== projection.reviewId || states.pageKey !== projection.pageKey ||
55
+ states.renderId !== projection.renderId || states.generation !== projection.generation ||
56
+ states.capability !== projection.capability) {
57
+ reject("SCOPE_MISMATCH", "Stale or foreign render geometry/action.");
58
+ }
59
+ }
60
+ const renderScopeSchema = object(renderScopeFields);
61
+ /** Classify only parsed messages; foreign scope and future revisions are errors. */
62
+ export function isCurrentFrameProjection(value, projection) {
63
+ validateRenderScope(value, projection);
64
+ if (value.projectionRevision > projection.projectionRevision) {
65
+ reject("SCOPE_MISMATCH", "Unknown future anchor projection revision.");
66
+ }
67
+ return value.projectionRevision === projection.projectionRevision;
68
+ }
69
+ export function sameFrameAnchorProjection(left, right) {
70
+ return Object.keys(renderScopeFields).every((key) => left[key] === right[key]) &&
71
+ left.anchors.length === right.anchors.length && left.anchors.every((anchor) => right.anchors.some((other) => anchor.threadId === other.threadId && !!anchor.resolved === !!other.resolved &&
72
+ JSON.stringify(anchor.target) === JSON.stringify(other.target)));
73
+ }
74
+ export function validateFrameThreadAction(input, projectionInput) {
75
+ const action = frameThreadActionSchema.parse(input);
76
+ const projection = frameAnchorsSchema.parse(projectionInput);
77
+ if (!isCurrentFrameProjection(action, projection))
78
+ reject("SCOPE_MISMATCH", "Stale anchor projection action.");
79
+ if (!projection.anchors.some((anchor) => anchor.threadId === action.threadId)) {
80
+ reject("SCOPE_MISMATCH", "Thread action is outside the current projection.");
81
+ }
82
+ return action;
83
+ }
84
+ export function validateFrameAnchorStates(input, projectionInput) {
85
+ const states = frameAnchorStatesSchema.parse(input);
86
+ const projection = frameAnchorsSchema.parse(projectionInput);
87
+ if (!isCurrentFrameProjection(states, projection))
88
+ reject("SCOPE_MISMATCH", "Stale anchor projection geometry.");
89
+ if (states.anchors.length !== projection.anchors.length ||
90
+ states.anchors.some((state) => !projection.anchors.some((anchor) => anchor.threadId === state.threadId))) {
91
+ reject("SCOPE_MISMATCH", "Anchor states must cover exactly the projected threads.");
92
+ }
93
+ return states;
94
+ }
@@ -1 +1,189 @@
1
- export {};
1
+ import { agentMessageSchema, conversationThreadSchema, directEditSchema, directEditOutcomeSchema, reviewerMessageSchema, } from "./feedback.js";
2
+ import { array, canonicalJson, ContractError, CONTRACT_LIMITS, enumeration, id, integer, literal, nullable, object, optional, refine, reject, text, timestamp, } from "./validation.js";
3
+ export const pagingScopeSchema = refine(object({
4
+ reviewId: id, entryKey: id,
5
+ collection: enumeration(["threads", "context", "history", "pages", "edits", "edit-attention", "comparisons"]),
6
+ pageKey: nullable(id), threadId: nullable(id), submissionId: nullable(id),
7
+ status: enumeration(["open", "resolved", "all"]),
8
+ }), (scope) => {
9
+ if ((scope.collection === "context") !== (scope.threadId !== null))
10
+ reject("INVALID_INPUT", "Only context requires a thread.");
11
+ if ((scope.collection === "comparisons") !== (scope.submissionId !== null))
12
+ reject("INVALID_INPUT", "Only comparisons requires a submission.");
13
+ if (scope.collection !== "threads" && scope.status !== "all")
14
+ reject("INVALID_INPUT", "Status filter applies only to threads.");
15
+ });
16
+ export const pageQuerySchema = object({
17
+ limit: optional(integer(1, CONTRACT_LIMITS.pageMaximum)), cursor: optional(text()),
18
+ });
19
+ export const conversationListRequestSchema = object({
20
+ operation: literal("list"), scope: pagingScopeSchema, query: pageQuerySchema,
21
+ });
22
+ export const pageCursorSchema = object({
23
+ scope: pagingScopeSchema, highWater: integer(), before: integer(1),
24
+ });
25
+ export function encodePageCursor(cursor) {
26
+ return canonicalJson(pageCursorSchema.parse(cursor));
27
+ }
28
+ export function decodePageCursor(raw, scope) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ }
33
+ catch (error) {
34
+ if (!(error instanceof SyntaxError))
35
+ throw error;
36
+ return reject("INVALID_CURSOR", "Malformed cursor.");
37
+ }
38
+ let cursor;
39
+ try {
40
+ cursor = pageCursorSchema.parse(parsed);
41
+ }
42
+ catch (error) {
43
+ if (!(error instanceof ContractError))
44
+ throw error;
45
+ return reject("INVALID_CURSOR", "Invalid cursor shape.");
46
+ }
47
+ if (canonicalJson(cursor.scope) !== canonicalJson(pagingScopeSchema.parse(scope)) || cursor.before > cursor.highWater) {
48
+ reject("INVALID_CURSOR", "Cursor belongs to different filters/review or exceeds its high-water mark.");
49
+ }
50
+ return cursor;
51
+ }
52
+ export function pagedSchema(item) {
53
+ return refine(object({
54
+ items: array(item, CONTRACT_LIMITS.pageMaximum), nextCursor: nullable(text()), totalCount: integer(), highWater: integer(),
55
+ }), (page) => {
56
+ if (page.totalCount < page.items.length || (!page.items.length && page.nextCursor !== null)) {
57
+ reject("INVALID_INPUT", "Inconsistent page counts/cursor.");
58
+ }
59
+ });
60
+ }
61
+ export function validatePageOutput(input, item, scope, queryInput) {
62
+ const page = pagedSchema(item).parse(input);
63
+ const query = pageQuerySchema.parse(queryInput);
64
+ const cursor = query.cursor === undefined ? null : decodePageCursor(query.cursor, scope);
65
+ if (page.items.length > (query.limit ?? CONTRACT_LIMITS.pageDefault) ||
66
+ (cursor && page.highWater !== cursor.highWater))
67
+ reject("INVALID_INPUT", "Page exceeds requested window.");
68
+ const sequences = page.items.map((entry) => entry.sequence);
69
+ const ordered = [...sequences].sort((a, b) => scope.collection === "context" ? a - b : b - a);
70
+ if (new Set(sequences).size !== sequences.length || canonicalJson(ordered) !== canonicalJson(sequences) ||
71
+ sequences.some((sequence) => sequence > page.highWater || (cursor && sequence >= cursor.before))) {
72
+ reject("INVALID_INPUT", "Page order/high-water mismatch.");
73
+ }
74
+ if (page.nextCursor !== null) {
75
+ const next = decodePageCursor(page.nextCursor, scope);
76
+ if (next.highWater !== page.highWater || next.before !== Math.min(...sequences)) {
77
+ reject("INVALID_CURSOR", "Next cursor does not continue this page.");
78
+ }
79
+ }
80
+ return page;
81
+ }
82
+ /** Inputs are the authorized, filtered collection. Sequence is immutable and review-wide. */
83
+ export function paginate(records, scopeInput, queryInput, currentSequence) {
84
+ const scope = pagingScopeSchema.parse(scopeInput);
85
+ const query = pageQuerySchema.parse(queryInput);
86
+ const limit = query.limit ?? CONTRACT_LIMITS.pageDefault;
87
+ const cursor = query.cursor === undefined ? null : decodePageCursor(query.cursor, scope);
88
+ const sequences = records.map((record) => integer(1).parse(record.sequence));
89
+ if (new Set(sequences).size !== sequences.length)
90
+ reject("INVALID_INPUT", "Duplicate creation sequence.");
91
+ integer().parse(currentSequence);
92
+ if (cursor && cursor.highWater > currentSequence)
93
+ reject("INVALID_CURSOR", "Cursor exceeds the review's current sequence.");
94
+ const highWater = cursor?.highWater ?? currentSequence;
95
+ const bounded = records.filter((record) => record.sequence <= highWater).sort((a, b) => b.sequence - a.sequence);
96
+ const remaining = bounded.filter((record) => !cursor || record.sequence < cursor.before);
97
+ const selected = remaining.slice(0, limit);
98
+ const nextCursor = remaining.length > selected.length
99
+ ? encodePageCursor({ scope, highWater, before: selected[selected.length - 1].sequence }) : null;
100
+ // Context windows load backwards but read chronologically.
101
+ return { items: scope.collection === "context" ? selected.reverse() : selected, nextCursor, totalCount: bounded.length, highWater };
102
+ }
103
+ export const exchangeSchema = refine(object({
104
+ sequence: integer(1), reviewer: reviewerMessageSchema, response: nullable(agentMessageSchema),
105
+ }), ({ sequence, reviewer, response }) => {
106
+ if (sequence !== reviewer.sequence)
107
+ reject("INVALID_INPUT", "Exchange ordering belongs to its reviewer message.");
108
+ if (response && (response.replyToMessageId !== reviewer.messageId || response.threadId !== reviewer.threadId ||
109
+ response.reviewId !== reviewer.reviewId || response.submissionId !== reviewer.submissionId ||
110
+ (reviewer.intent === "discuss" && response.outcome === "applied"))) {
111
+ reject("INVALID_INPUT", "Reply is not associated with this reviewer message.");
112
+ }
113
+ });
114
+ export function latestExchange(messages, replies, highWater = Number.MAX_SAFE_INTEGER) {
115
+ const latest = messages.filter((message) => message.sequence <= highWater).sort((a, b) => b.sequence - a.sequence)[0];
116
+ if (!latest)
117
+ return null;
118
+ const matches = replies.filter((reply) => reply.replyToMessageId === latest.messageId && reply.sequence <= highWater);
119
+ if (matches.length > 1)
120
+ reject("INVALID_INPUT", "Multiple replies to one submitted message.");
121
+ return exchangeSchema.parse({ sequence: latest.sequence, reviewer: latest, response: matches[0] ?? null });
122
+ }
123
+ export const threadSummarySchema = refine(object({
124
+ thread: conversationThreadSchema, sequence: integer(1),
125
+ messageCount: integer(), pendingMessageCount: integer(),
126
+ latestExchange: nullable(exchangeSchema),
127
+ }), (summary) => {
128
+ if (summary.sequence !== summary.thread.sequence || summary.pendingMessageCount > summary.messageCount ||
129
+ ((summary.messageCount === 0) !== (summary.latestExchange === null)) ||
130
+ (summary.latestExchange && (summary.latestExchange.reviewer.threadId !== summary.thread.threadId ||
131
+ summary.latestExchange.reviewer.reviewId !== summary.thread.reviewId))) {
132
+ reject("INVALID_INPUT", "Invalid thread summary association/counts.");
133
+ }
134
+ });
135
+ export const threadPageSchema = pagedSchema(threadSummarySchema);
136
+ export const contextPageSchema = pagedSchema(exchangeSchema);
137
+ export function contextWindow(messages, replies, scope, query, currentSequence) {
138
+ if (scope.collection !== "context" || scope.threadId === null)
139
+ reject("INVALID_INPUT", "Context requires one thread.");
140
+ for (const message of [...messages, ...replies]) {
141
+ if (message.reviewId !== scope.reviewId || message.threadId !== scope.threadId)
142
+ reject("SCOPE_MISMATCH", "Foreign context message.");
143
+ }
144
+ const parsedQuery = pageQuerySchema.parse(query);
145
+ const highWater = parsedQuery.cursor === undefined ? currentSequence : decodePageCursor(parsedQuery.cursor, scope).highWater;
146
+ const exchanges = messages.filter((message) => message.sequence <= highWater).map((message) => latestExchange([message], replies, highWater));
147
+ return validatePageOutput(paginate(exchanges, scope, query, currentSequence), exchangeSchema, scope, query);
148
+ }
149
+ export const resultSummarySchema = object({
150
+ resultId: id, body: text(), summary: optional(text()), createdAt: timestamp,
151
+ title: enumeration(["What changed", "Agent response"]), effect: enumeration(["reply-only", "changes-reported"]),
152
+ });
153
+ export const submissionHistoryItemSchema = refine(object({
154
+ sequence: integer(1), reviewId: id, submissionId: id, createdAt: timestamp,
155
+ state: enumeration(["queued", "delivered", "handled", "abandoned"]),
156
+ result: nullable(resultSummarySchema),
157
+ comparisonStatus: enumeration(["not-requested", "pending", "ready", "partial", "failed", "unavailable"]),
158
+ comparisonCount: integer(),
159
+ }), (item) => {
160
+ if ((item.state === "handled") !== (item.result !== null))
161
+ reject("INVALID_INPUT", "Only handled history has a result.");
162
+ });
163
+ export const submissionHistoryPageSchema = pagedSchema(submissionHistoryItemSchema);
164
+ export const comparisonReferenceSchema = object({
165
+ sequence: integer(1), reviewId: id, submissionId: id, pageKey: id,
166
+ baselineRevisionId: nullable(id), resultRevisionId: nullable(id),
167
+ status: enumeration(["not-requested", "pending", "ready", "partial", "failed", "unavailable"]),
168
+ reason: nullable(text()),
169
+ });
170
+ export const comparisonReferencePageSchema = pagedSchema(comparisonReferenceSchema);
171
+ /** Only review-local unsubmitted edits; submitted versions remain in submission reads. */
172
+ export const directEditPageSchema = pagedSchema(directEditSchema);
173
+ export const editAttentionSchema = refine(object({
174
+ sequence: integer(1), edit: directEditSchema,
175
+ stage: enumeration(["unsent", "queued", "delivered", "deferred", "abandoned"]),
176
+ submissionId: nullable(id), outcome: nullable(directEditOutcomeSchema), reason: nullable(text()),
177
+ }), (item) => {
178
+ if (item.sequence !== item.edit.sequence ||
179
+ (item.stage === "unsent") !== (item.submissionId === null) ||
180
+ (item.stage === "deferred") !== (item.outcome !== null) ||
181
+ (["deferred", "abandoned"].includes(item.stage)) !== (item.reason !== null)) {
182
+ reject("INVALID_INPUT", "Invalid edit attention lifecycle.");
183
+ }
184
+ if (item.outcome && (item.outcome.editId !== item.edit.editId || item.outcome.editVersion !== item.edit.version ||
185
+ item.outcome.outcome !== "deferred" || item.outcome.reason !== item.reason)) {
186
+ reject("INVALID_INPUT", "Attention outcome does not describe this exact edit.");
187
+ }
188
+ });
189
+ export const editAttentionPageSchema = pagedSchema(editAttentionSchema);