@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -2
  3. package/THIRD_PARTY_NOTICES.md +580 -0
  4. package/dist/bin/loopnote.mjs +5 -0
  5. package/dist/cli/confirm.js +38 -0
  6. package/dist/cli/picker.js +342 -0
  7. package/dist/cli/request.js +120 -0
  8. package/dist/cli/run.js +970 -0
  9. package/dist/contracts/api.js +640 -0
  10. package/dist/contracts/comparison.js +178 -0
  11. package/dist/contracts/draft.js +606 -0
  12. package/dist/contracts/request.js +124 -0
  13. package/dist/domain/anchor.js +122 -0
  14. package/dist/domain/comparison-key.js +51 -0
  15. package/dist/domain/errors.js +43 -0
  16. package/dist/domain/path-bytes.js +82 -0
  17. package/dist/domain/product-identity.js +66 -0
  18. package/dist/domain/source.js +4 -0
  19. package/dist/draft/mutate-draft.js +87 -0
  20. package/dist/export/render-review-markdown.js +163 -0
  21. package/dist/export/review-export.js +273 -0
  22. package/dist/git/availability.js +92 -0
  23. package/dist/git/candidates.js +413 -0
  24. package/dist/git/comparison.js +263 -0
  25. package/dist/git/exact-patch.js +702 -0
  26. package/dist/git/ignore-status.js +23 -0
  27. package/dist/git/inventory.js +160 -0
  28. package/dist/git/numstat.js +111 -0
  29. package/dist/git/objects.js +95 -0
  30. package/dist/git/raw-diff.js +84 -0
  31. package/dist/git/repository.js +144 -0
  32. package/dist/git/runner.js +145 -0
  33. package/dist/git/selector-drift.js +169 -0
  34. package/dist/git/standalone-resolution.js +174 -0
  35. package/dist/git/worktree-content.js +97 -0
  36. package/dist/git/worktree-inventory.js +391 -0
  37. package/dist/native/directory_exchange.node +0 -0
  38. package/dist/server/app.js +171 -0
  39. package/dist/server/attached-completion.js +85 -0
  40. package/dist/server/capabilities.js +1572 -0
  41. package/dist/server/draft-loader.js +134 -0
  42. package/dist/server/draft-recovery.js +5 -0
  43. package/dist/server/draft-store.js +380 -0
  44. package/dist/server/export-store.js +291 -0
  45. package/dist/server/gitignore-capability.js +197 -0
  46. package/dist/server/image-content.js +101 -0
  47. package/dist/server/lifecycle.js +72 -0
  48. package/dist/server/native-exchange-capability.js +45 -0
  49. package/dist/server/patch-snapshot.js +500 -0
  50. package/dist/server/routes.js +418 -0
  51. package/dist/server/security.js +123 -0
  52. package/dist/server/snapshot-fs.js +58 -0
  53. package/dist/server/support-client.js +76 -0
  54. package/dist/server/support-store.js +194 -0
  55. package/dist/server/worktree-snapshot.js +553 -0
  56. package/dist/web/assets/abap-08VXUWAP.js +1 -0
  57. package/dist/web/assets/apex-BWPQTe0t.js +1 -0
  58. package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
  59. package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
  60. package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
  61. package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
  62. package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
  63. package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
  64. package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
  65. package/dist/web/assets/cpp-C7h46wYY.js +1 -0
  66. package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
  67. package/dist/web/assets/csp-bTuwJoIa.js +1 -0
  68. package/dist/web/assets/css-DIMkf-bt.js +3 -0
  69. package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
  70. package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
  71. package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
  72. package/dist/web/assets/dart-onAF5SnQ.js +1 -0
  73. package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
  74. package/dist/web/assets/ecl-D05T4iGw.js +1 -0
  75. package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
  76. package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
  77. package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
  78. package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
  79. package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
  80. package/dist/web/assets/go-C-y9NEjX.js +1 -0
  81. package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
  82. package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
  83. package/dist/web/assets/hcl-CpzslTdj.js +1 -0
  84. package/dist/web/assets/html-BDAqstpo.js +1 -0
  85. package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
  86. package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
  87. package/dist/web/assets/index-CCeYBqPo.js +1111 -0
  88. package/dist/web/assets/index-CRj9ojWJ.css +1 -0
  89. package/dist/web/assets/ini-sBoK_t0W.js +1 -0
  90. package/dist/web/assets/java-BEtHBSE6.js +1 -0
  91. package/dist/web/assets/javascript-1Ywytw67.js +1 -0
  92. package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
  93. package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
  94. package/dist/web/assets/julia-Bri6UV-V.js +1 -0
  95. package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
  96. package/dist/web/assets/less-B9JPFI3C.js +2 -0
  97. package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
  98. package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
  99. package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
  100. package/dist/web/assets/lua-CsQS60Ue.js +1 -0
  101. package/dist/web/assets/m3-D-oSqn_W.js +1 -0
  102. package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
  103. package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
  104. package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
  105. package/dist/web/assets/msdax-DauUninz.js +1 -0
  106. package/dist/web/assets/mysql-SOo6toE5.js +1 -0
  107. package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
  108. package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
  109. package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
  110. package/dist/web/assets/perl-oz_6vUea.js +1 -0
  111. package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
  112. package/dist/web/assets/php-nr791fC2.js +1 -0
  113. package/dist/web/assets/pla-CopQ2nXW.js +1 -0
  114. package/dist/web/assets/postiats-43DmfD33.js +1 -0
  115. package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
  116. package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
  117. package/dist/web/assets/protobuf-C531GsRP.js +2 -0
  118. package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
  119. package/dist/web/assets/python-CET4IumD.js +1 -0
  120. package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
  121. package/dist/web/assets/r-BwWrilGY.js +1 -0
  122. package/dist/web/assets/razor-d-reaHeT.js +1 -0
  123. package/dist/web/assets/redis-ClamHrr6.js +1 -0
  124. package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
  125. package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
  126. package/dist/web/assets/ruby-DezsRK8O.js +1 -0
  127. package/dist/web/assets/rust-DdL9SqIa.js +1 -0
  128. package/dist/web/assets/sb-CcwsVR0C.js +1 -0
  129. package/dist/web/assets/scala-DHpiXF5c.js +1 -0
  130. package/dist/web/assets/scheme-BeGwcela.js +1 -0
  131. package/dist/web/assets/scss-gp-XZpBa.js +3 -0
  132. package/dist/web/assets/shell-CC2rA5mh.js +1 -0
  133. package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
  134. package/dist/web/assets/sophia-CRfGWb83.js +1 -0
  135. package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
  136. package/dist/web/assets/sql-NEE52Syq.js +1 -0
  137. package/dist/web/assets/st-DbInun42.js +1 -0
  138. package/dist/web/assets/swift-Bxkupp3x.js +1 -0
  139. package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
  140. package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
  141. package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
  142. package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
  143. package/dist/web/assets/twig-De2hgUGE.js +1 -0
  144. package/dist/web/assets/typescript-DprEVA-S.js +1 -0
  145. package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
  146. package/dist/web/assets/vb-DV3o63ZY.js +1 -0
  147. package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
  148. package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
  149. package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
  150. package/dist/web/index.html +14 -0
  151. package/package.json +87 -4
@@ -0,0 +1,1572 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { lstat, readdir } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { extname as posixExtname } from 'node:path/posix';
5
+ import { ExportReviewResultSchema, AppendLoopnoteIgnoreResultSchema, LoopnoteIgnoreStatusSchema, FileContentResponseSchema, FileMetadataResponseSchema, PatchStatusResponseSchema, SessionResponseSchema, } from '../contracts/api.js';
6
+ import { AttachedCompletionCoordinator } from './attached-completion.js';
7
+ import { buildDurableAnchor, verifyDurableAnchor, } from '../domain/anchor.js';
8
+ import { comparisonKey, uncommittedReviewKey, standaloneReviewKey } from '../domain/comparison-key.js';
9
+ import { decodeBase64url } from '../domain/path-bytes.js';
10
+ import { MAX_INLINE_TEXT_BYTES } from '../git/availability.js';
11
+ import { createObjectReader } from '../git/objects.js';
12
+ import { createGitRunner } from '../git/runner.js';
13
+ import { IMAGE_CONTENT_TYPE_BY_EXTENSION, MAX_IMAGE_BYTES, readCommittedImageBytes, readWorktreeImageBytes, resolveRelativeImagePath, } from './image-content.js';
14
+ import { createSelectorDriftObserver, } from '../git/selector-drift.js';
15
+ import { createDraftStore, loadUncommittedDraftCandidateComments } from './draft-store.js';
16
+ import { assertAttachedStorageScope } from './draft-loader.js';
17
+ import { buildReviewExportV1, buildReviewExportV2, buildReviewExportV3, buildReviewExportV4, buildReviewExportV5, buildReviewExportV6, canonicalizeReviewExport, } from '../export/review-export.js';
18
+ import { parseCanonicalReviewExport } from '../export/review-export.js';
19
+ import { renderReviewMarkdown } from '../export/render-review-markdown.js';
20
+ import { getObservedNativeExchangeCapability } from './native-exchange-capability.js';
21
+ import { assertManagedExportsRoot, ensureManagedExportsRoot, publishReviewExport } from './export-store.js';
22
+ import { inspectLoopnoteIgnore } from '../git/ignore-status.js';
23
+ import { appendLoopnoteIgnoreRule } from './gitignore-capability.js';
24
+ import { PatchSnapshot } from './patch-snapshot.js';
25
+ import {} from './worktree-snapshot.js';
26
+ const LANGUAGE_BY_EXTENSION = {
27
+ c: 'c',
28
+ css: 'css',
29
+ go: 'go',
30
+ h: 'cpp',
31
+ htm: 'html',
32
+ html: 'html',
33
+ java: 'java',
34
+ js: 'javascript',
35
+ json: 'json',
36
+ jsx: 'javascript',
37
+ md: 'markdown',
38
+ mjs: 'javascript',
39
+ mts: 'typescript',
40
+ py: 'python',
41
+ rs: 'rust',
42
+ sh: 'shell',
43
+ sql: 'sql',
44
+ ts: 'typescript',
45
+ tsx: 'typescript',
46
+ vue: 'html',
47
+ xml: 'xml',
48
+ yml: 'yaml',
49
+ yaml: 'yaml',
50
+ };
51
+ const LANGUAGE_BY_BASENAME = {
52
+ dockerfile: 'dockerfile',
53
+ makefile: 'makefile',
54
+ };
55
+ const strictTextDecoder = new TextDecoder('utf-8', { fatal: true });
56
+ function imageContentTypeFor(relativePath) {
57
+ const extension = posixExtname(relativePath).slice(1).toLowerCase();
58
+ return extension.length === 0 ? undefined : IMAGE_CONTENT_TYPE_BY_EXTENSION[extension];
59
+ }
60
+ async function resolveImageBytes(markdownPath, relativeSrc, readBytes) {
61
+ if (markdownPath === undefined) {
62
+ return undefined;
63
+ }
64
+ const resolvedPath = resolveRelativeImagePath(markdownPath.display, relativeSrc);
65
+ if (resolvedPath === undefined) {
66
+ return undefined;
67
+ }
68
+ const contentType = imageContentTypeFor(resolvedPath);
69
+ if (contentType === undefined) {
70
+ return undefined;
71
+ }
72
+ const bytes = await readBytes(resolvedPath);
73
+ return bytes === undefined ? undefined : Object.freeze({ bytes, contentType });
74
+ }
75
+ /**
76
+ * WR-01: the base-side (pre-change) text content for a markdown file is
77
+ * always sourced from the git-tracked blob, never the live worktree — but
78
+ * an image *embedded* in that markdown is only "unchanged sibling asset"
79
+ * safe to read from disk when it is not itself one of the diff's own
80
+ * changed files. This locates that changed-file entry (if any) so callers
81
+ * can prefer its recorded base blob over a live disk read.
82
+ */
83
+ function findChangedImageEntry(files, resolvedPath) {
84
+ return files.find((candidate) => candidate.oldPath?.display === resolvedPath || candidate.newPath?.display === resolvedPath);
85
+ }
86
+ async function readObjectBackedImage(objectReader, blobOid) {
87
+ const content = await objectReader.read(blobOid, { maxBytes: MAX_IMAGE_BYTES });
88
+ return content.kind === 'available' ? Buffer.from(content.bytes) : undefined;
89
+ }
90
+ export function createSupportCapability(store, client) {
91
+ const status = async () => ({ status: (await store.state()).status });
92
+ const promote = async (remote) => {
93
+ if (remote === 'verified')
94
+ await store.markVerified(new Date().toISOString());
95
+ return await status();
96
+ };
97
+ return Object.freeze({
98
+ status,
99
+ async start(action) {
100
+ const result = await client.start(action, (await store.state()).installationId);
101
+ return result === undefined ? { kind: 'unavailable' } : { kind: 'ready', flowUrl: result.flowUrl };
102
+ },
103
+ async refresh() {
104
+ return await promote(await client.status((await store.state()).installationId));
105
+ },
106
+ close() {
107
+ client.close();
108
+ },
109
+ });
110
+ }
111
+ function toSessionEndpoint(endpoint) {
112
+ return {
113
+ label: endpoint.label,
114
+ oid: endpoint.oid,
115
+ ...(endpoint.source?.kind === 'worktree'
116
+ ? {
117
+ worktree: {
118
+ path: endpoint.source.path,
119
+ dirty: endpoint.source.dirty,
120
+ },
121
+ }
122
+ : {}),
123
+ };
124
+ }
125
+ function receiptComparisonEndpoint(endpoint) {
126
+ return {
127
+ label: endpoint.label,
128
+ oid: endpoint.oid,
129
+ ...(endpoint.source === undefined ? {} : { selectorType: endpoint.source.kind }),
130
+ };
131
+ }
132
+ function receiptDriftIdentity(endpoint, status) {
133
+ const selectorType = endpoint.source?.kind ?? 'branch';
134
+ const pinned = { label: endpoint.label, selectorType, oid: endpoint.oid };
135
+ if (status.kind === 'unavailable') {
136
+ return {
137
+ role: status.role,
138
+ pinned,
139
+ current: {
140
+ kind: 'unavailable',
141
+ label: status.label,
142
+ selectorType: status.selectorType,
143
+ reason: status.reason,
144
+ },
145
+ };
146
+ }
147
+ return {
148
+ role: status.role,
149
+ pinned,
150
+ current: {
151
+ kind: 'available',
152
+ label: status.kind === 'moved' ? status.label : endpoint.label,
153
+ selectorType: status.kind === 'moved' ? status.selectorType : selectorType,
154
+ oid: status.kind === 'moved' ? status.newOid : endpoint.oid,
155
+ },
156
+ };
157
+ }
158
+ function receiptDrift(comparison, observation) {
159
+ const drifted = observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged';
160
+ return drifted
161
+ ? {
162
+ kind: 'acknowledged',
163
+ identities: [
164
+ receiptDriftIdentity(comparison.base, observation.base),
165
+ receiptDriftIdentity(comparison.head, observation.head),
166
+ ],
167
+ }
168
+ : { kind: 'noneObserved' };
169
+ }
170
+ function languageForPath(path) {
171
+ const filename = path?.split(/[\\/]/u).at(-1)?.toLowerCase() ?? '';
172
+ if (filename.length === 0) {
173
+ return 'plaintext';
174
+ }
175
+ const namedLanguage = LANGUAGE_BY_BASENAME[filename];
176
+ if (namedLanguage !== undefined) {
177
+ return namedLanguage;
178
+ }
179
+ const extension = filename.split('.').at(-1);
180
+ return extension === undefined ? 'plaintext' : LANGUAGE_BY_EXTENSION[extension] ?? 'plaintext';
181
+ }
182
+ async function readSide(reader, path, blobOid, mode) {
183
+ if (mode === '000000' || path === undefined) {
184
+ return { exists: false };
185
+ }
186
+ const content = await reader.read(blobOid, { maxBytes: MAX_INLINE_TEXT_BYTES });
187
+ if (content.kind === 'missing') {
188
+ return undefined;
189
+ }
190
+ try {
191
+ return {
192
+ exists: true,
193
+ path,
194
+ language: languageForPath(path.utf8),
195
+ blobOid,
196
+ text: strictTextDecoder.decode(content.bytes),
197
+ };
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ }
203
+ async function isCompleteExportDirectory(path) {
204
+ try {
205
+ const directory = await lstat(path);
206
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
207
+ return false;
208
+ }
209
+ const names = await readdir(path);
210
+ if (names.length !== 2 || !names.includes('review.json') || !names.includes('review.md')) {
211
+ return false;
212
+ }
213
+ const files = await Promise.all(names.map(async (name) => lstat(join(path, name))));
214
+ return files.every((file) => file.isFile() && !file.isSymbolicLink());
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ }
220
+ export function createCapabilityRegistry(comparison, options = {}) {
221
+ const storageScope = options.attachedCompletion === undefined
222
+ ? undefined
223
+ : assertAttachedStorageScope(options.attachedCompletion.storageScope);
224
+ const filesByCapability = new Map();
225
+ const frozenFilesByCapability = new Map();
226
+ const reader = options.objectReader ?? createObjectReader(comparison.repositoryRoot);
227
+ const draftStore = options.draftStore ??
228
+ createDraftStore({
229
+ repositoryRoot: comparison.repositoryRoot,
230
+ comparison: comparison.range === undefined
231
+ ? {
232
+ baseCommitOid: comparison.base.oid,
233
+ headCommitOid: comparison.head.oid,
234
+ mergeBaseOid: comparison.mergeBaseOid,
235
+ }
236
+ : {
237
+ baseCommitOid: comparison.base.oid,
238
+ headCommitOid: comparison.head.oid,
239
+ mergeBaseOid: comparison.mergeBaseOid,
240
+ range: comparison.range,
241
+ },
242
+ ...(storageScope === undefined ? {} : { storageScope }),
243
+ });
244
+ const selectorDriftObserver = options.selectorDriftObserver ?? createSelectorDriftObserver(comparison);
245
+ const acknowledgements = new Map();
246
+ const session = SessionResponseSchema.parse({
247
+ base: toSessionEndpoint(comparison.base),
248
+ head: toSessionEndpoint(comparison.head),
249
+ mergeBaseOid: comparison.mergeBaseOid,
250
+ ...(comparison.range === undefined
251
+ ? {}
252
+ : {
253
+ range: {
254
+ kind: comparison.range.kind,
255
+ baseOid: comparison.range.baseOid,
256
+ headOid: comparison.range.headOid,
257
+ pathspecs: comparison.range.pathspecs,
258
+ },
259
+ }),
260
+ ...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
261
+ ...(options.support === undefined ? {} : { support: { enabled: true } }),
262
+ repoName: basename(comparison.repositoryRoot),
263
+ files: comparison.changedFiles.map((file) => {
264
+ const status = file.status.similarity === null
265
+ ? { kind: file.status.kind }
266
+ : { kind: file.status.kind, similarity: file.status.similarity };
267
+ const sessionFile = {
268
+ fileId: file.id,
269
+ status,
270
+ oldMode: file.oldMode,
271
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
272
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
273
+ additions: file.additions,
274
+ deletions: file.deletions,
275
+ availability: file.availability,
276
+ };
277
+ const metadata = FileMetadataResponseSchema.parse({
278
+ ...sessionFile,
279
+ oldMode: file.oldMode,
280
+ newMode: file.newMode,
281
+ });
282
+ filesByCapability.set(file.id, metadata);
283
+ frozenFilesByCapability.set(file.id, file);
284
+ return sessionFile;
285
+ }),
286
+ });
287
+ const verifyAnchor = async (anchor) => {
288
+ for (const file of frozenFilesByCapability.values()) {
289
+ const path = anchor.side === 'base' ? file.oldPath : file.newPath;
290
+ const blobOid = anchor.side === 'base' ? file.oldBlobOid : file.newBlobOid;
291
+ const mode = anchor.side === 'base' ? file.oldMode : file.newMode;
292
+ if (file.availability.kind !== 'text' || path === undefined || path.bytesBase64url !== anchor.path.bytesBase64url || blobOid !== anchor.blobOid)
293
+ continue;
294
+ const side = await readSide(reader, path, blobOid, mode);
295
+ if (side === undefined || !side.exists)
296
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
297
+ try {
298
+ return verifyDurableAnchor(anchor, buildDurableAnchor({
299
+ path: side.path,
300
+ safeDisplayPath: side.path.display,
301
+ side: anchor.side,
302
+ blobOid: side.blobOid,
303
+ line: anchor.line,
304
+ endLine: anchor.endLine,
305
+ text: side.text,
306
+ }));
307
+ }
308
+ catch {
309
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
310
+ }
311
+ }
312
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
313
+ };
314
+ const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
315
+ status: () => options.attachedCompletion.coordinator.status(),
316
+ markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
317
+ finish: async (expectedRevision) => {
318
+ return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
319
+ const settled = await draftStore.settle(expectedRevision, {
320
+ prepare: async (acceptedDraft) => {
321
+ const observation = await selectorDriftObserver.observe();
322
+ if (observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged' || comparison.range === undefined) {
323
+ return { kind: 'scopeInvalid' };
324
+ }
325
+ const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
326
+ const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
327
+ if (affectedCommentIds.length !== 0)
328
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
329
+ try {
330
+ const range = comparison.range;
331
+ const exportedAt = new Date().toISOString();
332
+ const bytes = canonicalizeReviewExport(buildReviewExportV2({
333
+ acceptedDraft,
334
+ commentVerification: verification,
335
+ comparison: {
336
+ selectedBase: { label: range.requestedBase, launchOid: range.baseOid },
337
+ selectedHead: { label: range.requestedHead, launchOid: range.headOid },
338
+ mergeBaseOid: range.baseOid,
339
+ comparisonKey: range.reviewKey,
340
+ },
341
+ drift: {
342
+ observedAt: exportedAt,
343
+ acknowledged: false,
344
+ base: { launchOid: comparison.base.oid, currentOid: comparison.base.oid, status: 'unchanged' },
345
+ head: { launchOid: comparison.head.oid, currentOid: comparison.head.oid, status: 'unchanged' },
346
+ },
347
+ }, range, exportedAt));
348
+ return { kind: 'ready', bytes, revision: acceptedDraft.revision };
349
+ }
350
+ catch {
351
+ return { kind: 'canonicalizationFailure' };
352
+ }
353
+ },
354
+ finalize: async ({ draft, prepared }) => {
355
+ if (prepared.kind !== 'ready')
356
+ return prepared;
357
+ const observation = await selectorDriftObserver.observe();
358
+ if (observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged')
359
+ return { kind: 'scopeInvalid' };
360
+ const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
361
+ if (affectedCommentIds.length !== 0)
362
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
363
+ try {
364
+ const document = parseCanonicalReviewExport(prepared.bytes);
365
+ if (document.schemaVersion !== 2 || document.acceptedDraftRevision !== draft.revision)
366
+ return { kind: 'canonicalizationFailure' };
367
+ }
368
+ catch {
369
+ return { kind: 'canonicalizationFailure' };
370
+ }
371
+ try {
372
+ if (!await options.attachedCompletion.deliver(prepared.bytes)) {
373
+ return { kind: 'deliveryFailed' };
374
+ }
375
+ return { kind: 'completed', revision: prepared.revision };
376
+ }
377
+ catch {
378
+ return { kind: 'deliveryFailed' };
379
+ }
380
+ },
381
+ });
382
+ if (settled.kind === 'accepted')
383
+ return settled.value;
384
+ if (settled.kind === 'revisionConflict')
385
+ return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
386
+ if (settled.kind === 'draftChanged')
387
+ return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
388
+ return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
389
+ });
390
+ },
391
+ };
392
+ return Object.freeze({
393
+ session: async () => session,
394
+ attachedCompletion,
395
+ onAnchorAdd: options.onAnchorAdd,
396
+ draftStore,
397
+ selectorDriftObserver,
398
+ support: options.support,
399
+ async revealDraftFile() {
400
+ if (options.revealDraftFile === undefined) {
401
+ throw new Error('Draft reveal adapter is unavailable.');
402
+ }
403
+ await options.revealDraftFile(draftStore.canonicalPath);
404
+ },
405
+ async lookup(fileId) {
406
+ options.onCapabilityLookup?.(fileId);
407
+ return filesByCapability.get(fileId);
408
+ },
409
+ async revealExportDirectory() {
410
+ const managedRoot = await ensureManagedExportsRoot(comparison.repositoryRoot, false);
411
+ const exportName = storageScope
412
+ ?? comparison.range?.reviewKey
413
+ ?? `${comparison.base.oid}..${comparison.head.oid}`;
414
+ const exportDirectory = managedRoot === undefined
415
+ ? undefined
416
+ : join(managedRoot.exportsRoot, exportName);
417
+ if (options.revealDraftFile === undefined
418
+ || managedRoot === undefined
419
+ || exportDirectory === undefined) {
420
+ throw new Error('Export reveal adapter is unavailable.');
421
+ }
422
+ await assertManagedExportsRoot(managedRoot);
423
+ if (!(await isCompleteExportDirectory(exportDirectory))) {
424
+ throw new Error('Export reveal adapter is unavailable.');
425
+ }
426
+ await assertManagedExportsRoot(managedRoot);
427
+ await options.revealDraftFile(exportDirectory);
428
+ },
429
+ async inspectLoopnoteIgnore() {
430
+ const status = await inspectLoopnoteIgnore({
431
+ repositoryRoot: comparison.repositoryRoot,
432
+ });
433
+ return LoopnoteIgnoreStatusSchema.parse({
434
+ kind: status.kind === 'not-ignored' ? 'notIgnored' : status.kind,
435
+ });
436
+ },
437
+ async appendLoopnoteIgnoreRule() {
438
+ return AppendLoopnoteIgnoreResultSchema.parse(await appendLoopnoteIgnoreRule({
439
+ repositoryRoot: comparison.repositoryRoot,
440
+ }));
441
+ },
442
+ async exportReview(input) {
443
+ const initial = await draftStore.loadState();
444
+ if (initial.kind !== 'current') {
445
+ return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
446
+ }
447
+ if (initial.draft.revision !== input.expectedRevision) {
448
+ return ExportReviewResultSchema.parse({
449
+ kind: 'revisionConflict',
450
+ expectedRevision: input.expectedRevision,
451
+ actualRevision: initial.draft.revision,
452
+ });
453
+ }
454
+ const observation = await selectorDriftObserver.observe();
455
+ const observationFingerprint = createHash('sha256').update(JSON.stringify(observation)).digest('base64url');
456
+ const drifted = observation.base.kind !== 'unchanged' || observation.head.kind !== 'unchanged';
457
+ if (drifted && acknowledgements.get(input.driftAcknowledgementToken ?? '') !== observationFingerprint) {
458
+ const acknowledgementToken = randomBytes(32).toString('base64url');
459
+ acknowledgements.set(acknowledgementToken, observationFingerprint);
460
+ return ExportReviewResultSchema.parse({
461
+ kind: input.driftAcknowledgementToken === undefined ? 'driftAcknowledgementRequired' : 'driftAcknowledgementStale',
462
+ acknowledgementToken,
463
+ observation,
464
+ });
465
+ }
466
+ const acceptedDraft = structuredClone(initial.draft);
467
+ const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
468
+ const exportedAt = new Date().toISOString();
469
+ const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await this.verifyAnchor(comment.anchor)])));
470
+ const range = comparison.range;
471
+ if (range !== undefined && (range.baseOid !== comparison.base.oid || range.headOid !== comparison.head.oid)) {
472
+ throw new Error('Frozen comparison does not match its range provenance.');
473
+ }
474
+ const exportSnapshot = {
475
+ acceptedDraft,
476
+ commentVerification,
477
+ comparison: range === undefined
478
+ ? {
479
+ selectedBase: { label: comparison.base.label, launchOid: comparison.base.oid },
480
+ selectedHead: { label: comparison.head.label, launchOid: comparison.head.oid },
481
+ mergeBaseOid: comparison.mergeBaseOid,
482
+ comparisonKey: comparisonKey(comparison.base.oid, comparison.head.oid),
483
+ }
484
+ : {
485
+ selectedBase: { label: range.requestedBase, launchOid: range.baseOid },
486
+ selectedHead: { label: range.requestedHead, launchOid: range.headOid },
487
+ mergeBaseOid: range.baseOid,
488
+ comparisonKey: range.reviewKey,
489
+ },
490
+ drift: {
491
+ observedAt: exportedAt,
492
+ acknowledged: drifted,
493
+ base: {
494
+ launchOid: comparison.base.oid,
495
+ currentOid: observation.base.kind === 'moved' ? observation.base.newOid : observation.base.kind === 'unchanged' ? comparison.base.oid : null,
496
+ status: observation.base.kind,
497
+ },
498
+ head: {
499
+ launchOid: comparison.head.oid,
500
+ currentOid: observation.head.kind === 'moved' ? observation.head.newOid : observation.head.kind === 'unchanged' ? comparison.head.oid : null,
501
+ status: observation.head.kind,
502
+ },
503
+ },
504
+ };
505
+ const document = range === undefined
506
+ ? buildReviewExportV1(exportSnapshot, exportedAt)
507
+ : buildReviewExportV2(exportSnapshot, range, exportedAt);
508
+ const json = canonicalizeReviewExport(document);
509
+ const markdown = Buffer.from(renderReviewMarkdown(json), 'utf8');
510
+ const published = await publishReviewExport({
511
+ repositoryRoot: comparison.repositoryRoot,
512
+ identity: range === undefined
513
+ ? { kind: 'interactive', baseOid: comparison.base.oid, headOid: comparison.head.oid }
514
+ : { kind: 'range', reviewKey: range.reviewKey },
515
+ ...(storageScope === undefined ? {} : { storageScope }),
516
+ json,
517
+ markdown,
518
+ reExportCapability: await getObservedNativeExchangeCapability(),
519
+ revalidate: async () => {
520
+ const current = await draftStore.loadState();
521
+ if (current.kind !== 'current' ||
522
+ current.draft.revision !== acceptedDraft.revision ||
523
+ createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
524
+ return false;
525
+ }
526
+ return createHash('sha256').update(JSON.stringify(await selectorDriftObserver.observe())).digest('base64url') === observationFingerprint;
527
+ },
528
+ });
529
+ if (published.kind !== 'exported') {
530
+ return ExportReviewResultSchema.parse({ kind: published.kind });
531
+ }
532
+ return ExportReviewResultSchema.parse({
533
+ kind: 'exported',
534
+ draftRevision: acceptedDraft.revision,
535
+ exportedAt,
536
+ drift: receiptDrift(comparison, observation),
537
+ comparison: {
538
+ base: receiptComparisonEndpoint(comparison.base),
539
+ head: receiptComparisonEndpoint(comparison.head),
540
+ },
541
+ files: published.receipt.files,
542
+ });
543
+ },
544
+ async readContent(fileId) {
545
+ const file = frozenFilesByCapability.get(fileId);
546
+ if (file === undefined || file.availability.kind !== 'text') {
547
+ return undefined;
548
+ }
549
+ const [base, head] = await Promise.all([
550
+ readSide(reader, file.oldPath, file.oldBlobOid, file.oldMode),
551
+ readSide(reader, file.newPath, file.newBlobOid, file.newMode),
552
+ ]);
553
+ if (base === undefined || head === undefined) {
554
+ return undefined;
555
+ }
556
+ return FileContentResponseSchema.parse({ fileId, base, head });
557
+ },
558
+ async readRelativeImage(fileId, side, relativeSrc) {
559
+ const file = frozenFilesByCapability.get(fileId);
560
+ const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
561
+ return resolveImageBytes(markdownPath, relativeSrc, (resolvedPath) => readCommittedImageBytes(comparison.repositoryRoot, side === 'base' ? comparison.base.oid : comparison.head.oid, resolvedPath, createGitRunner(), reader));
562
+ },
563
+ verifyAnchor,
564
+ });
565
+ }
566
+ export async function createExactPatchCapabilityRegistry(grounded, snapshot, options = {}) {
567
+ const storageScope = options.attachedCompletion === undefined
568
+ ? undefined
569
+ : assertAttachedStorageScope(options.attachedCompletion.storageScope);
570
+ const sessionResponse = async () => SessionResponseSchema.parse({
571
+ ...(await snapshot.session()),
572
+ ...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
573
+ ...(options.support === undefined ? {} : { support: { enabled: true } }),
574
+ repoName: basename(grounded.repositoryRoot),
575
+ });
576
+ const session = await sessionResponse();
577
+ if (!('patch' in session))
578
+ throw new Error('Exact patch snapshot did not provide patch provenance.');
579
+ const patchSession = session.patch;
580
+ const draftStore = options.draftStore ?? createDraftStore({
581
+ repositoryRoot: grounded.repositoryRoot,
582
+ comparison: {
583
+ kind: 'exact-patch',
584
+ digest: patchSession.digest,
585
+ validationTarget: patchSession.validationTarget,
586
+ reviewKey: patchSession.reviewKey,
587
+ },
588
+ ...(storageScope === undefined ? {} : { storageScope }),
589
+ });
590
+ const verifyAnchor = async (anchor) => {
591
+ const file = (await snapshot.files()).find((candidate) => {
592
+ const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
593
+ const blobOid = anchor.side === 'base' ? candidate.oldBlobOid : candidate.newBlobOid;
594
+ return path?.bytesBase64url === anchor.path.bytesBase64url && blobOid === anchor.blobOid;
595
+ });
596
+ if (file === undefined)
597
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
598
+ const content = await snapshot.readContent(file.id);
599
+ const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
600
+ const path = anchor.side === 'base' ? file.oldPath : file.newPath;
601
+ if (bytes === undefined || path === undefined)
602
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
603
+ return verifyDurableAnchor(anchor, buildDurableAnchor({
604
+ path,
605
+ safeDisplayPath: path.display,
606
+ side: anchor.side,
607
+ blobOid: anchor.blobOid,
608
+ line: anchor.line,
609
+ endLine: anchor.endLine,
610
+ text: strictTextDecoder.decode(bytes),
611
+ }));
612
+ };
613
+ const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
614
+ status: () => options.attachedCompletion.coordinator.status(),
615
+ markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
616
+ finish: async (expectedRevision) => {
617
+ return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
618
+ const settled = await draftStore.settle(expectedRevision, {
619
+ prepare: async (acceptedDraft) => {
620
+ const patch = await snapshot.exportScope();
621
+ if (patch.snapshot.status !== 'unchanged')
622
+ return { kind: 'scopeInvalid' };
623
+ const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
624
+ const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
625
+ if (affectedCommentIds.length !== 0)
626
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
627
+ try {
628
+ const bytes = canonicalizeReviewExport(buildReviewExportV3({ acceptedDraft, commentVerification: verification }, patch, new Date().toISOString()));
629
+ return { kind: 'ready', bytes, revision: acceptedDraft.revision };
630
+ }
631
+ catch {
632
+ return { kind: 'canonicalizationFailure' };
633
+ }
634
+ },
635
+ finalize: async ({ draft, prepared }) => {
636
+ if (prepared.kind !== 'ready')
637
+ return prepared;
638
+ if ((await snapshot.exportScope()).snapshot.status !== 'unchanged')
639
+ return { kind: 'scopeInvalid' };
640
+ const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
641
+ if (affectedCommentIds.length !== 0)
642
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
643
+ try {
644
+ const document = parseCanonicalReviewExport(prepared.bytes);
645
+ if (document.schemaVersion !== 3 || document.acceptedDraftRevision !== draft.revision)
646
+ return { kind: 'canonicalizationFailure' };
647
+ }
648
+ catch {
649
+ return { kind: 'canonicalizationFailure' };
650
+ }
651
+ try {
652
+ if (!await options.attachedCompletion.deliver(prepared.bytes)) {
653
+ return { kind: 'deliveryFailed' };
654
+ }
655
+ return { kind: 'completed', revision: prepared.revision };
656
+ }
657
+ catch {
658
+ return { kind: 'deliveryFailed' };
659
+ }
660
+ },
661
+ });
662
+ if (settled.kind === 'accepted')
663
+ return settled.value;
664
+ if (settled.kind === 'revisionConflict')
665
+ return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
666
+ if (settled.kind === 'draftChanged')
667
+ return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
668
+ return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
669
+ });
670
+ },
671
+ };
672
+ return Object.freeze({
673
+ isExactPatch: true,
674
+ session: sessionResponse,
675
+ attachedCompletion,
676
+ onAnchorAdd: options.onAnchorAdd,
677
+ draftStore,
678
+ support: options.support,
679
+ patchStatus: async () => {
680
+ const status = await snapshot.observe();
681
+ return PatchStatusResponseSchema.parse(status === 'snapshotUnavailable'
682
+ ? { kind: 'snapshotUnavailable' }
683
+ : { kind: status, validationTargetLabel: snapshot.validationTargetLabel });
684
+ },
685
+ async lookup(fileId) {
686
+ options.onCapabilityLookup?.(fileId);
687
+ const file = await snapshot.lookup(fileId);
688
+ if (file === undefined)
689
+ return undefined;
690
+ return FileMetadataResponseSchema.parse({
691
+ fileId: file.id,
692
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
693
+ oldMode: file.oldMode,
694
+ newMode: file.newMode,
695
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
696
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
697
+ additions: file.additions,
698
+ deletions: file.deletions,
699
+ availability: file.availability,
700
+ });
701
+ },
702
+ async readContent(fileId) {
703
+ const content = await snapshot.readContent(fileId);
704
+ if (content === undefined)
705
+ return undefined;
706
+ const { file, preimage, postimage } = content;
707
+ const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
708
+ ? { exists: false }
709
+ : {
710
+ exists: true,
711
+ path,
712
+ language: languageForPath(path.utf8),
713
+ blobOid,
714
+ text: strictTextDecoder.decode(bytes),
715
+ };
716
+ return FileContentResponseSchema.parse({
717
+ fileId,
718
+ base: makeSide(preimage, file.oldPath, file.oldMode, file.oldBlobOid),
719
+ head: makeSide(postimage, file.newPath, file.newMode, file.newBlobOid),
720
+ });
721
+ },
722
+ async readRelativeImage(fileId, side, relativeSrc) {
723
+ const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
724
+ const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
725
+ return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
726
+ if (side === 'base') {
727
+ const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
728
+ if (changedEntry !== undefined) {
729
+ // Image is itself part of the diff: serve the materialized, digest-verified
730
+ // preimage (or nothing, if it did not exist at base) instead of live disk bytes.
731
+ return changedEntry.oldMode === '000000' ? undefined : snapshot.readPreimageBytes(changedEntry.id);
732
+ }
733
+ }
734
+ return readWorktreeImageBytes(grounded.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
735
+ });
736
+ },
737
+ verifyAnchor,
738
+ async revealDraftFile() {
739
+ if (options.revealDraftFile === undefined)
740
+ throw new Error('Draft reveal adapter is unavailable.');
741
+ await options.revealDraftFile(draftStore.canonicalPath);
742
+ },
743
+ async revealExportDirectory() {
744
+ if (options.revealDraftFile === undefined)
745
+ throw new Error('Export reveal adapter is unavailable.');
746
+ const managedRoot = await ensureManagedExportsRoot(grounded.repositoryRoot, false);
747
+ if (managedRoot === undefined)
748
+ throw new Error('Export reveal adapter is unavailable.');
749
+ const exportDirectory = join(managedRoot.exportsRoot, storageScope ?? patchSession.reviewKey);
750
+ await assertManagedExportsRoot(managedRoot);
751
+ if (!(await isCompleteExportDirectory(exportDirectory)))
752
+ throw new Error('Export reveal adapter is unavailable.');
753
+ await assertManagedExportsRoot(managedRoot);
754
+ await options.revealDraftFile(exportDirectory);
755
+ },
756
+ async exportReview(input) {
757
+ const initial = await draftStore.loadState();
758
+ if (initial.kind !== 'current')
759
+ return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
760
+ if (initial.draft.revision !== input.expectedRevision) {
761
+ return ExportReviewResultSchema.parse({
762
+ kind: 'revisionConflict',
763
+ expectedRevision: input.expectedRevision,
764
+ actualRevision: initial.draft.revision,
765
+ });
766
+ }
767
+ const acceptedDraft = structuredClone(initial.draft);
768
+ const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
769
+ const patch = await snapshot.exportScope();
770
+ const exportedAt = new Date().toISOString();
771
+ const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
772
+ const json = canonicalizeReviewExport(buildReviewExportV3({ acceptedDraft, commentVerification }, patch, exportedAt));
773
+ const published = await publishReviewExport({
774
+ repositoryRoot: grounded.repositoryRoot,
775
+ identity: { kind: 'exact-patch', reviewKey: patch.reviewKey },
776
+ ...(storageScope === undefined ? {} : { storageScope }),
777
+ json,
778
+ markdown: Buffer.from(renderReviewMarkdown(json), 'utf8'),
779
+ reExportCapability: await getObservedNativeExchangeCapability(),
780
+ revalidate: async () => {
781
+ const current = await draftStore.loadState();
782
+ if (current.kind !== 'current'
783
+ || current.draft.revision !== acceptedDraft.revision
784
+ || createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
785
+ return false;
786
+ }
787
+ return (await snapshot.exportScope()).snapshot.status === patch.snapshot.status;
788
+ },
789
+ });
790
+ if (published.kind !== 'exported')
791
+ return ExportReviewResultSchema.parse({ kind: published.kind });
792
+ return ExportReviewResultSchema.parse({
793
+ kind: 'exported',
794
+ draftRevision: acceptedDraft.revision,
795
+ exportedAt,
796
+ patch: {
797
+ digest: patch.digest,
798
+ validationTarget: patch.validationTarget,
799
+ reviewKey: patch.reviewKey,
800
+ snapshot: { status: patch.snapshot.status },
801
+ },
802
+ files: published.receipt.files,
803
+ });
804
+ },
805
+ async inspectLoopnoteIgnore() {
806
+ return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
807
+ },
808
+ async appendLoopnoteIgnoreRule() {
809
+ return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
810
+ },
811
+ });
812
+ }
813
+ export async function createUncommittedCapabilityRegistry(scope, snapshot, options = {}) {
814
+ if (scope.sourceKind === 'standalone') {
815
+ throw new RangeError('Unreachable: createUncommittedCapabilityRegistry does not support standalone scopes; standalone sessions use a dedicated capability registry.');
816
+ }
817
+ const sourceKind = scope.sourceKind;
818
+ const storageScope = options.attachedCompletion === undefined
819
+ ? undefined
820
+ : assertAttachedStorageScope(options.attachedCompletion.storageScope);
821
+ const snapshotSession = await snapshot.session();
822
+ const reviewKey = uncommittedReviewKey(scope.sourceKind, scope.baseCommitOid, snapshotSession.chain[0]);
823
+ // WR-01: WorktreeSnapshot never captures image bytes (it only captures
824
+ // text-availability sides), so a dedicated ObjectReader is needed to
825
+ // serve the base-side blob of an image that is itself part of the diff.
826
+ const reader = options.objectReader ?? createObjectReader(scope.repositoryRoot);
827
+ const sessionResponse = async () => {
828
+ const files = await snapshot.files();
829
+ return SessionResponseSchema.parse({
830
+ uncommitted: {
831
+ kind: 'uncommitted',
832
+ sourceKind: scope.sourceKind,
833
+ ...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
834
+ reviewKey,
835
+ changedFileCount: files.length,
836
+ ...(options.uncommittedRecoveryCandidate === undefined ? {} : { recoveryAvailable: true }),
837
+ },
838
+ files: files.map((file) => ({
839
+ fileId: file.id,
840
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
841
+ oldMode: file.oldMode,
842
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
843
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
844
+ additions: file.additions,
845
+ deletions: file.deletions,
846
+ availability: file.availability,
847
+ })),
848
+ ...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
849
+ ...(options.support === undefined ? {} : { support: { enabled: true } }),
850
+ repoName: basename(scope.repositoryRoot),
851
+ });
852
+ };
853
+ const draftStore = options.draftStore ?? createDraftStore({
854
+ repositoryRoot: scope.repositoryRoot,
855
+ comparison: {
856
+ kind: 'uncommitted',
857
+ sourceKind: scope.sourceKind,
858
+ ...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
859
+ reviewKey,
860
+ snapshotChain: snapshotSession.chain,
861
+ },
862
+ ...(storageScope === undefined ? {} : { storageScope }),
863
+ });
864
+ const verifyAnchor = async (anchor) => {
865
+ const file = (await snapshot.files()).find((candidate) => {
866
+ const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
867
+ return path?.bytesBase64url === anchor.path.bytesBase64url;
868
+ });
869
+ if (file === undefined)
870
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
871
+ const content = await snapshot.readContent(file.id);
872
+ const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
873
+ const blobOid = anchor.side === 'base' ? content?.preimageBlobOid : content?.postimageBlobOid;
874
+ const path = anchor.side === 'base' ? file.oldPath : file.newPath;
875
+ if (bytes === undefined || path === undefined || blobOid === undefined || blobOid !== anchor.blobOid) {
876
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
877
+ }
878
+ return verifyDurableAnchor(anchor, buildDurableAnchor({
879
+ path,
880
+ safeDisplayPath: path.display,
881
+ side: anchor.side,
882
+ blobOid: anchor.blobOid,
883
+ line: anchor.line,
884
+ endLine: anchor.endLine,
885
+ text: strictTextDecoder.decode(bytes),
886
+ }));
887
+ };
888
+ const diskPathFor = (file) => {
889
+ const path = file.newPath ?? file.oldPath;
890
+ if (path === undefined)
891
+ return undefined;
892
+ return join(scope.repositoryRoot, Buffer.from(decodeBase64url(path.bytesBase64url)).toString('utf8'));
893
+ };
894
+ const indexStatIdentity = async () => {
895
+ const runner = createGitRunner();
896
+ const result = await runner.run(['rev-parse', '--git-path', 'index'], { cwd: scope.repositoryRoot });
897
+ const indexPathRaw = result.stdout.toString('utf8').trim();
898
+ const resolvedIndexPath = indexPathRaw.startsWith('/') ? indexPathRaw : join(scope.repositoryRoot, indexPathRaw);
899
+ const info = await lstat(resolvedIndexPath);
900
+ return { size: info.size, mtimeMs: info.mtimeMs };
901
+ };
902
+ const captureChangeBaseline = async () => {
903
+ const index = await indexStatIdentity();
904
+ const files = await snapshot.files();
905
+ const fileStats = new Map();
906
+ for (const file of files) {
907
+ const diskPath = diskPathFor(file);
908
+ if (diskPath === undefined)
909
+ continue;
910
+ try {
911
+ const info = await lstat(diskPath);
912
+ fileStats.set(file.id, { size: info.size, mtimeMs: info.mtimeMs });
913
+ }
914
+ catch {
915
+ fileStats.set(file.id, undefined);
916
+ }
917
+ }
918
+ return Object.freeze({ index, files: fileStats });
919
+ };
920
+ let changeBaseline = await captureChangeBaseline();
921
+ const collectWorkingTreeDrift = async () => {
922
+ const changedPaths = [];
923
+ const files = await snapshot.files();
924
+ for (const file of files) {
925
+ const diskPath = diskPathFor(file);
926
+ if (diskPath === undefined)
927
+ continue;
928
+ const expected = changeBaseline.files.get(file.id);
929
+ let actual;
930
+ try {
931
+ const info = await lstat(diskPath);
932
+ actual = { size: info.size, mtimeMs: info.mtimeMs };
933
+ }
934
+ catch {
935
+ actual = undefined;
936
+ }
937
+ const changed = expected === undefined
938
+ ? actual !== undefined
939
+ : actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
940
+ if (!changed)
941
+ continue;
942
+ const path = file.newPath ?? file.oldPath;
943
+ if (path !== undefined)
944
+ changedPaths.push(path);
945
+ }
946
+ return { changed: changedPaths.length > 0, paths: changedPaths };
947
+ };
948
+ const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
949
+ status: () => options.attachedCompletion.coordinator.status(),
950
+ markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
951
+ finish: async (expectedRevision) => {
952
+ return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
953
+ const settled = await draftStore.settle(expectedRevision, {
954
+ prepare: async (acceptedDraft) => {
955
+ const drift = await collectWorkingTreeDrift();
956
+ const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
957
+ const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
958
+ if (affectedCommentIds.length !== 0)
959
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
960
+ try {
961
+ const exportedAt = new Date().toISOString();
962
+ const bytes = canonicalizeReviewExport(buildReviewExportV5({ acceptedDraft, commentVerification: verification }, {
963
+ sourceKind,
964
+ baseCommitOid: scope.baseCommitOid,
965
+ pathspecs: options.requestPathspecs ?? [],
966
+ reviewKey,
967
+ snapshotChain: snapshotSession.chain,
968
+ }, drift, exportedAt));
969
+ return { kind: 'ready', bytes, revision: acceptedDraft.revision };
970
+ }
971
+ catch {
972
+ return { kind: 'canonicalizationFailure' };
973
+ }
974
+ },
975
+ finalize: async ({ draft, prepared }) => {
976
+ if (prepared.kind !== 'ready')
977
+ return prepared;
978
+ const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
979
+ if (affectedCommentIds.length !== 0)
980
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
981
+ try {
982
+ const document = parseCanonicalReviewExport(prepared.bytes);
983
+ if (document.schemaVersion !== 5 || document.acceptedDraftRevision !== draft.revision)
984
+ return { kind: 'canonicalizationFailure' };
985
+ }
986
+ catch {
987
+ return { kind: 'canonicalizationFailure' };
988
+ }
989
+ try {
990
+ if (!await options.attachedCompletion.deliver(prepared.bytes)) {
991
+ return { kind: 'deliveryFailed' };
992
+ }
993
+ return { kind: 'completed', revision: prepared.revision };
994
+ }
995
+ catch {
996
+ return { kind: 'deliveryFailed' };
997
+ }
998
+ },
999
+ });
1000
+ if (settled.kind === 'accepted')
1001
+ return settled.value;
1002
+ if (settled.kind === 'revisionConflict')
1003
+ return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
1004
+ if (settled.kind === 'draftChanged')
1005
+ return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
1006
+ return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
1007
+ });
1008
+ },
1009
+ };
1010
+ return Object.freeze({
1011
+ session: sessionResponse,
1012
+ attachedCompletion,
1013
+ onAnchorAdd: options.onAnchorAdd,
1014
+ draftStore,
1015
+ support: options.support,
1016
+ async refresh() {
1017
+ const stateBefore = await draftStore.loadState();
1018
+ const commentsBefore = stateBefore.kind === 'current' ? stateBefore.draft.comments : [];
1019
+ const verificationBefore = new Map();
1020
+ for (const comment of commentsBefore) {
1021
+ verificationBefore.set(comment.id, await verifyAnchor(comment.anchor));
1022
+ }
1023
+ const delta = await snapshot.refresh();
1024
+ changeBaseline = await captureChangeBaseline();
1025
+ let staleCount = 0;
1026
+ let orphanedCount = 0;
1027
+ for (const comment of commentsBefore) {
1028
+ if (verificationBefore.get(comment.id)?.state !== 'verified')
1029
+ continue;
1030
+ const after = await verifyAnchor(comment.anchor);
1031
+ if (after.state === 'stale')
1032
+ staleCount += 1;
1033
+ else if (after.state === 'orphaned')
1034
+ orphanedCount += 1;
1035
+ }
1036
+ return {
1037
+ added: delta.added,
1038
+ removed: delta.removed,
1039
+ modified: delta.modified,
1040
+ staleCount,
1041
+ orphanedCount,
1042
+ };
1043
+ },
1044
+ async worktreeChangeIndicator() {
1045
+ const index = await indexStatIdentity();
1046
+ if (index.size !== changeBaseline.index.size || index.mtimeMs !== changeBaseline.index.mtimeMs) {
1047
+ return { changed: true };
1048
+ }
1049
+ const files = await snapshot.files();
1050
+ for (const file of files) {
1051
+ const diskPath = diskPathFor(file);
1052
+ if (diskPath === undefined)
1053
+ continue;
1054
+ const expected = changeBaseline.files.get(file.id);
1055
+ let actual;
1056
+ try {
1057
+ const info = await lstat(diskPath);
1058
+ actual = { size: info.size, mtimeMs: info.mtimeMs };
1059
+ }
1060
+ catch {
1061
+ actual = undefined;
1062
+ }
1063
+ const changed = expected === undefined
1064
+ ? actual !== undefined
1065
+ : actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
1066
+ if (changed)
1067
+ return { changed: true };
1068
+ }
1069
+ return { changed: false };
1070
+ },
1071
+ ...(options.uncommittedRecoveryCandidate === undefined ? {} : {
1072
+ async recoverUncommittedDraft() {
1073
+ const comments = await loadUncommittedDraftCandidateComments(options.uncommittedRecoveryCandidate.path);
1074
+ if (comments === undefined)
1075
+ return { kind: 'unavailable' };
1076
+ const state = await draftStore.loadState();
1077
+ let revision = state.kind === 'current' ? state.draft.revision : 0;
1078
+ for (const comment of comments) {
1079
+ const added = await draftStore.mutate({
1080
+ expectedRevision: revision,
1081
+ mutation: { type: 'addComment', body: comment.body, anchor: comment.anchor },
1082
+ });
1083
+ if (added.kind === 'invalidTarget')
1084
+ continue;
1085
+ if (added.kind !== 'accepted')
1086
+ return { kind: 'unavailable' };
1087
+ revision = added.draft.revision;
1088
+ if (comment.state !== 'resolved')
1089
+ continue;
1090
+ const newId = added.draft.comments[added.draft.comments.length - 1].id;
1091
+ const resolved = await draftStore.mutate({
1092
+ expectedRevision: revision,
1093
+ mutation: { type: 'resolveComment', commentId: newId },
1094
+ });
1095
+ if (resolved.kind !== 'accepted')
1096
+ return { kind: 'unavailable' };
1097
+ revision = resolved.draft.revision;
1098
+ }
1099
+ return { kind: 'recovered' };
1100
+ },
1101
+ }),
1102
+ async lookup(fileId) {
1103
+ options.onCapabilityLookup?.(fileId);
1104
+ const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
1105
+ if (file === undefined)
1106
+ return undefined;
1107
+ return FileMetadataResponseSchema.parse({
1108
+ fileId: file.id,
1109
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
1110
+ oldMode: file.oldMode,
1111
+ newMode: file.newMode,
1112
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
1113
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
1114
+ additions: file.additions,
1115
+ deletions: file.deletions,
1116
+ availability: file.availability,
1117
+ });
1118
+ },
1119
+ async readContent(fileId) {
1120
+ const content = await snapshot.readContent(fileId);
1121
+ if (content === undefined)
1122
+ return undefined;
1123
+ const { file, preimage, preimageBlobOid, postimage, postimageBlobOid } = content;
1124
+ const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
1125
+ ? { exists: false }
1126
+ : {
1127
+ exists: true,
1128
+ path,
1129
+ language: languageForPath(path.utf8),
1130
+ blobOid,
1131
+ text: strictTextDecoder.decode(bytes),
1132
+ };
1133
+ return FileContentResponseSchema.parse({
1134
+ fileId,
1135
+ base: makeSide(preimage, file.oldPath, file.oldMode, preimageBlobOid),
1136
+ head: makeSide(postimage, file.newPath, file.newMode, postimageBlobOid),
1137
+ });
1138
+ },
1139
+ async readRelativeImage(fileId, side, relativeSrc) {
1140
+ const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
1141
+ const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
1142
+ return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
1143
+ if (side === 'base') {
1144
+ const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
1145
+ if (changedEntry !== undefined) {
1146
+ // Image is itself part of the diff: serve its base blob via the
1147
+ // object store instead of the live disk (which is the head side).
1148
+ return changedEntry.oldMode === '000000' ? undefined : readObjectBackedImage(reader, changedEntry.oldBlobOid);
1149
+ }
1150
+ }
1151
+ return readWorktreeImageBytes(scope.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
1152
+ });
1153
+ },
1154
+ verifyAnchor,
1155
+ async revealDraftFile() {
1156
+ if (options.revealDraftFile === undefined)
1157
+ throw new Error('Draft reveal adapter is unavailable.');
1158
+ await options.revealDraftFile(draftStore.canonicalPath);
1159
+ },
1160
+ async inspectLoopnoteIgnore() {
1161
+ return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
1162
+ },
1163
+ async appendLoopnoteIgnoreRule() {
1164
+ return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
1165
+ },
1166
+ });
1167
+ }
1168
+ export async function createStandaloneCapabilityRegistry(scope, snapshot, options = {}) {
1169
+ if (scope.sourceKind !== 'standalone') {
1170
+ throw new RangeError('Unreachable: createStandaloneCapabilityRegistry only supports standalone scopes; non-standalone scopes use createUncommittedCapabilityRegistry.');
1171
+ }
1172
+ const storageScope = options.attachedCompletion === undefined
1173
+ ? undefined
1174
+ : assertAttachedStorageScope(options.attachedCompletion.storageScope);
1175
+ const snapshotSession = await snapshot.session();
1176
+ const reviewKey = standaloneReviewKey([...scope.standaloneArguments].sort(), snapshotSession.chain[0]);
1177
+ // WR-01: WorktreeSnapshot never captures image bytes (it only captures
1178
+ // text-availability sides), so a dedicated ObjectReader is needed to
1179
+ // serve the base-side blob of an image that is itself part of the diff.
1180
+ const reader = options.objectReader ?? createObjectReader(scope.repositoryRoot);
1181
+ const sessionResponse = async () => {
1182
+ const files = await snapshot.files();
1183
+ return SessionResponseSchema.parse({
1184
+ uncommitted: {
1185
+ kind: 'uncommitted',
1186
+ sourceKind: scope.sourceKind,
1187
+ ...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
1188
+ reviewKey,
1189
+ changedFileCount: files.length,
1190
+ },
1191
+ files: files.map((file) => ({
1192
+ fileId: file.id,
1193
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
1194
+ oldMode: file.oldMode,
1195
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
1196
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
1197
+ additions: file.additions,
1198
+ deletions: file.deletions,
1199
+ availability: file.availability,
1200
+ ...(file.standaloneGitState === undefined ? {} : { standaloneGitState: file.standaloneGitState }),
1201
+ })),
1202
+ ...(options.attachedCompletion === undefined ? {} : { attached: { kind: 'agent-review' } }),
1203
+ ...(options.support === undefined ? {} : { support: { enabled: true } }),
1204
+ repoName: basename(scope.repositoryRoot),
1205
+ });
1206
+ };
1207
+ const draftStore = options.draftStore ?? createDraftStore({
1208
+ repositoryRoot: scope.repositoryRoot,
1209
+ comparison: {
1210
+ kind: 'uncommitted',
1211
+ sourceKind: scope.sourceKind,
1212
+ ...(scope.baseCommitOid === undefined ? {} : { baseCommitOid: scope.baseCommitOid }),
1213
+ reviewKey,
1214
+ snapshotChain: snapshotSession.chain,
1215
+ },
1216
+ ...(storageScope === undefined ? {} : { storageScope }),
1217
+ });
1218
+ const verifyAnchor = async (anchor) => {
1219
+ const file = (await snapshot.files()).find((candidate) => {
1220
+ const path = anchor.side === 'base' ? candidate.oldPath : candidate.newPath;
1221
+ return path?.bytesBase64url === anchor.path.bytesBase64url;
1222
+ });
1223
+ if (file === undefined)
1224
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
1225
+ const content = await snapshot.readContent(file.id);
1226
+ const bytes = anchor.side === 'base' ? content?.preimage : content?.postimage;
1227
+ const blobOid = anchor.side === 'base' ? content?.preimageBlobOid : content?.postimageBlobOid;
1228
+ const path = anchor.side === 'base' ? file.oldPath : file.newPath;
1229
+ if (bytes === undefined || path === undefined || blobOid === undefined || blobOid !== anchor.blobOid) {
1230
+ return { state: 'orphaned', reason: 'anchor-unavailable' };
1231
+ }
1232
+ return verifyDurableAnchor(anchor, buildDurableAnchor({
1233
+ path,
1234
+ safeDisplayPath: path.display,
1235
+ side: anchor.side,
1236
+ blobOid: anchor.blobOid,
1237
+ line: anchor.line,
1238
+ endLine: anchor.endLine,
1239
+ text: strictTextDecoder.decode(bytes),
1240
+ }));
1241
+ };
1242
+ const diskPathFor = (file) => {
1243
+ const path = file.newPath ?? file.oldPath;
1244
+ if (path === undefined)
1245
+ return undefined;
1246
+ return join(scope.repositoryRoot, Buffer.from(decodeBase64url(path.bytesBase64url)).toString('utf8'));
1247
+ };
1248
+ const indexStatIdentity = async () => {
1249
+ const runner = createGitRunner();
1250
+ const result = await runner.run(['rev-parse', '--git-path', 'index'], { cwd: scope.repositoryRoot });
1251
+ const indexPathRaw = result.stdout.toString('utf8').trim();
1252
+ const resolvedIndexPath = indexPathRaw.startsWith('/') ? indexPathRaw : join(scope.repositoryRoot, indexPathRaw);
1253
+ const info = await lstat(resolvedIndexPath);
1254
+ return { size: info.size, mtimeMs: info.mtimeMs };
1255
+ };
1256
+ const captureChangeBaseline = async () => {
1257
+ const index = await indexStatIdentity();
1258
+ const files = await snapshot.files();
1259
+ const fileStats = new Map();
1260
+ for (const file of files) {
1261
+ const diskPath = diskPathFor(file);
1262
+ if (diskPath === undefined)
1263
+ continue;
1264
+ try {
1265
+ const info = await lstat(diskPath);
1266
+ fileStats.set(file.id, { size: info.size, mtimeMs: info.mtimeMs });
1267
+ }
1268
+ catch {
1269
+ fileStats.set(file.id, undefined);
1270
+ }
1271
+ }
1272
+ return Object.freeze({ index, files: fileStats });
1273
+ };
1274
+ let changeBaseline = await captureChangeBaseline();
1275
+ const collectWorkingTreeDrift = async () => {
1276
+ const changedPaths = [];
1277
+ const files = await snapshot.files();
1278
+ for (const file of files) {
1279
+ const diskPath = diskPathFor(file);
1280
+ if (diskPath === undefined)
1281
+ continue;
1282
+ const expected = changeBaseline.files.get(file.id);
1283
+ let actual;
1284
+ try {
1285
+ const info = await lstat(diskPath);
1286
+ actual = { size: info.size, mtimeMs: info.mtimeMs };
1287
+ }
1288
+ catch {
1289
+ actual = undefined;
1290
+ }
1291
+ const changed = expected === undefined
1292
+ ? actual !== undefined
1293
+ : actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
1294
+ if (!changed)
1295
+ continue;
1296
+ const path = file.newPath ?? file.oldPath;
1297
+ if (path !== undefined)
1298
+ changedPaths.push(path);
1299
+ }
1300
+ return { changed: changedPaths.length > 0, paths: changedPaths };
1301
+ };
1302
+ const attachedCompletion = options.attachedCompletion === undefined ? undefined : {
1303
+ status: () => options.attachedCompletion.coordinator.status(),
1304
+ markResponseSettled: () => options.attachedCompletion.coordinator.markResponseSettled(),
1305
+ finish: async (expectedRevision) => {
1306
+ return options.attachedCompletion.coordinator.finish(expectedRevision, async () => {
1307
+ const settled = await draftStore.settle(expectedRevision, {
1308
+ prepare: async (acceptedDraft) => {
1309
+ const drift = await collectWorkingTreeDrift();
1310
+ const verification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
1311
+ const affectedCommentIds = acceptedDraft.comments.filter((comment) => verification[comment.id]?.state !== 'verified').map((comment) => comment.id);
1312
+ if (affectedCommentIds.length !== 0)
1313
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
1314
+ const exportedAt = new Date().toISOString();
1315
+ const files = await snapshot.files();
1316
+ const resolvedPaths = files.map((file) => file.newPath);
1317
+ const fileDigests = (await Promise.all(files.map(async (file) => {
1318
+ const content = await snapshot.readContent(file.id);
1319
+ if (content?.postimage === undefined)
1320
+ return undefined;
1321
+ return { path: file.newPath, sha256: createHash('sha256').update(content.postimage).digest('hex') };
1322
+ }))).filter((entry) => entry !== undefined);
1323
+ const standaloneScope = {
1324
+ originalArguments: [...scope.standaloneArguments].sort(),
1325
+ resolvedPaths,
1326
+ fileDigests,
1327
+ snapshotChain: snapshotSession.chain,
1328
+ reviewKey,
1329
+ };
1330
+ try {
1331
+ const bytes = canonicalizeReviewExport(buildReviewExportV6({ acceptedDraft, commentVerification: verification }, standaloneScope, drift, exportedAt));
1332
+ return { kind: 'ready', bytes, revision: acceptedDraft.revision };
1333
+ }
1334
+ catch {
1335
+ return { kind: 'canonicalizationFailure' };
1336
+ }
1337
+ },
1338
+ finalize: async ({ draft, prepared }) => {
1339
+ if (prepared.kind !== 'ready')
1340
+ return prepared;
1341
+ const affectedCommentIds = (await Promise.all(draft.comments.map(async (comment) => ({ id: comment.id, verification: await verifyAnchor(comment.anchor) })))).filter(({ verification }) => verification.state !== 'verified').map(({ id }) => id);
1342
+ if (affectedCommentIds.length !== 0)
1343
+ return { kind: 'staleAnchors', affectedCommentIds, affectedCount: affectedCommentIds.length };
1344
+ try {
1345
+ const document = parseCanonicalReviewExport(prepared.bytes);
1346
+ if (document.schemaVersion !== 6 || document.acceptedDraftRevision !== draft.revision)
1347
+ return { kind: 'canonicalizationFailure' };
1348
+ }
1349
+ catch {
1350
+ return { kind: 'canonicalizationFailure' };
1351
+ }
1352
+ try {
1353
+ if (!await options.attachedCompletion.deliver(prepared.bytes)) {
1354
+ return { kind: 'deliveryFailed' };
1355
+ }
1356
+ return { kind: 'completed', revision: prepared.revision };
1357
+ }
1358
+ catch {
1359
+ return { kind: 'deliveryFailed' };
1360
+ }
1361
+ },
1362
+ });
1363
+ if (settled.kind === 'accepted')
1364
+ return settled.value;
1365
+ if (settled.kind === 'revisionConflict')
1366
+ return { kind: 'revisionConflict', expectedRevision: settled.expectedRevision, actualRevision: settled.actualRevision };
1367
+ if (settled.kind === 'draftChanged')
1368
+ return { kind: 'revisionConflict', expectedRevision, actualRevision: settled.actualRevision };
1369
+ return settled.kind === 'readOnly' ? { kind: 'draftReadOnly' } : { kind: 'persistenceFailure' };
1370
+ });
1371
+ },
1372
+ };
1373
+ return Object.freeze({
1374
+ session: sessionResponse,
1375
+ attachedCompletion,
1376
+ onAnchorAdd: options.onAnchorAdd,
1377
+ draftStore,
1378
+ support: options.support,
1379
+ async refresh() {
1380
+ const stateBefore = await draftStore.loadState();
1381
+ const commentsBefore = stateBefore.kind === 'current' ? stateBefore.draft.comments : [];
1382
+ const verificationBefore = new Map();
1383
+ for (const comment of commentsBefore) {
1384
+ verificationBefore.set(comment.id, await verifyAnchor(comment.anchor));
1385
+ }
1386
+ const delta = await snapshot.refresh();
1387
+ changeBaseline = await captureChangeBaseline();
1388
+ let staleCount = 0;
1389
+ let orphanedCount = 0;
1390
+ for (const comment of commentsBefore) {
1391
+ if (verificationBefore.get(comment.id)?.state !== 'verified')
1392
+ continue;
1393
+ const after = await verifyAnchor(comment.anchor);
1394
+ if (after.state === 'stale')
1395
+ staleCount += 1;
1396
+ else if (after.state === 'orphaned')
1397
+ orphanedCount += 1;
1398
+ }
1399
+ return {
1400
+ added: delta.added,
1401
+ removed: delta.removed,
1402
+ modified: delta.modified,
1403
+ staleCount,
1404
+ orphanedCount,
1405
+ };
1406
+ },
1407
+ async worktreeChangeIndicator() {
1408
+ const index = await indexStatIdentity();
1409
+ if (index.size !== changeBaseline.index.size || index.mtimeMs !== changeBaseline.index.mtimeMs) {
1410
+ return { changed: true };
1411
+ }
1412
+ const files = await snapshot.files();
1413
+ for (const file of files) {
1414
+ const diskPath = diskPathFor(file);
1415
+ if (diskPath === undefined)
1416
+ continue;
1417
+ const expected = changeBaseline.files.get(file.id);
1418
+ let actual;
1419
+ try {
1420
+ const info = await lstat(diskPath);
1421
+ actual = { size: info.size, mtimeMs: info.mtimeMs };
1422
+ }
1423
+ catch {
1424
+ actual = undefined;
1425
+ }
1426
+ const changed = expected === undefined
1427
+ ? actual !== undefined
1428
+ : actual === undefined || actual.size !== expected.size || actual.mtimeMs !== expected.mtimeMs;
1429
+ if (changed)
1430
+ return { changed: true };
1431
+ }
1432
+ return { changed: false };
1433
+ },
1434
+ async lookup(fileId) {
1435
+ options.onCapabilityLookup?.(fileId);
1436
+ const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
1437
+ if (file === undefined)
1438
+ return undefined;
1439
+ return FileMetadataResponseSchema.parse({
1440
+ fileId: file.id,
1441
+ status: file.status.similarity === null ? { kind: file.status.kind } : { kind: file.status.kind, similarity: file.status.similarity },
1442
+ oldMode: file.oldMode,
1443
+ newMode: file.newMode,
1444
+ ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
1445
+ ...(file.newPath === undefined ? {} : { newPath: file.newPath }),
1446
+ additions: file.additions,
1447
+ deletions: file.deletions,
1448
+ availability: file.availability,
1449
+ });
1450
+ },
1451
+ async readContent(fileId) {
1452
+ const content = await snapshot.readContent(fileId);
1453
+ if (content === undefined)
1454
+ return undefined;
1455
+ const { file, preimage, preimageBlobOid, postimage, postimageBlobOid } = content;
1456
+ const makeSide = (bytes, path, mode, blobOid) => bytes === undefined || path === undefined || mode === '000000'
1457
+ ? { exists: false }
1458
+ : {
1459
+ exists: true,
1460
+ path,
1461
+ language: languageForPath(path.utf8),
1462
+ blobOid,
1463
+ text: strictTextDecoder.decode(bytes),
1464
+ };
1465
+ return FileContentResponseSchema.parse({
1466
+ fileId,
1467
+ base: makeSide(preimage, file.oldPath, file.oldMode, preimageBlobOid),
1468
+ head: makeSide(postimage, file.newPath, file.newMode, postimageBlobOid),
1469
+ });
1470
+ },
1471
+ async readRelativeImage(fileId, side, relativeSrc) {
1472
+ const file = (await snapshot.files()).find((candidate) => candidate.id === fileId);
1473
+ const markdownPath = file === undefined ? undefined : side === 'base' ? file.oldPath : file.newPath;
1474
+ return resolveImageBytes(markdownPath, relativeSrc, async (resolvedPath) => {
1475
+ if (side === 'base') {
1476
+ const changedEntry = findChangedImageEntry(await snapshot.files(), resolvedPath);
1477
+ if (changedEntry !== undefined) {
1478
+ // Image is itself part of the diff: serve its base blob via the
1479
+ // object store instead of the live disk (which is the head side).
1480
+ return changedEntry.oldMode === '000000' ? undefined : readObjectBackedImage(reader, changedEntry.oldBlobOid);
1481
+ }
1482
+ }
1483
+ return readWorktreeImageBytes(scope.repositoryRoot, resolvedPath); // unchanged sibling asset, or head side
1484
+ });
1485
+ },
1486
+ verifyAnchor,
1487
+ async revealDraftFile() {
1488
+ if (options.revealDraftFile === undefined)
1489
+ throw new Error('Draft reveal adapter is unavailable.');
1490
+ await options.revealDraftFile(draftStore.canonicalPath);
1491
+ },
1492
+ async revealExportDirectory() {
1493
+ if (options.revealDraftFile === undefined)
1494
+ throw new Error('Export reveal adapter is unavailable.');
1495
+ const managedRoot = await ensureManagedExportsRoot(scope.repositoryRoot, false);
1496
+ if (managedRoot === undefined)
1497
+ throw new Error('Export reveal adapter is unavailable.');
1498
+ const exportDirectory = join(managedRoot.exportsRoot, storageScope ?? reviewKey);
1499
+ await assertManagedExportsRoot(managedRoot);
1500
+ if (!(await isCompleteExportDirectory(exportDirectory)))
1501
+ throw new Error('Export reveal adapter is unavailable.');
1502
+ await assertManagedExportsRoot(managedRoot);
1503
+ await options.revealDraftFile(exportDirectory);
1504
+ },
1505
+ async exportReview(input) {
1506
+ const initial = await draftStore.loadState();
1507
+ if (initial.kind !== 'current')
1508
+ return ExportReviewResultSchema.parse({ kind: 'draftReadOnly' });
1509
+ if (initial.draft.revision !== input.expectedRevision) {
1510
+ return ExportReviewResultSchema.parse({
1511
+ kind: 'revisionConflict',
1512
+ expectedRevision: input.expectedRevision,
1513
+ actualRevision: initial.draft.revision,
1514
+ });
1515
+ }
1516
+ const acceptedDraft = structuredClone(initial.draft);
1517
+ const draftFingerprint = createHash('sha256').update(initial.raw).digest('hex');
1518
+ const exportedAt = new Date().toISOString();
1519
+ const commentVerification = Object.fromEntries(await Promise.all(acceptedDraft.comments.map(async (comment) => [comment.id, await verifyAnchor(comment.anchor)])));
1520
+ const files = await snapshot.files();
1521
+ const resolvedPaths = files.map((file) => file.newPath);
1522
+ const fileDigests = (await Promise.all(files.map(async (file) => {
1523
+ const content = await snapshot.readContent(file.id);
1524
+ if (content?.postimage === undefined)
1525
+ return undefined;
1526
+ return { path: file.newPath, sha256: createHash('sha256').update(content.postimage).digest('hex') };
1527
+ }))).filter((entry) => entry !== undefined);
1528
+ const standaloneScope = {
1529
+ originalArguments: [...scope.standaloneArguments].sort(),
1530
+ resolvedPaths,
1531
+ fileDigests,
1532
+ snapshotChain: snapshotSession.chain,
1533
+ reviewKey,
1534
+ };
1535
+ const json = canonicalizeReviewExport(buildReviewExportV4({ acceptedDraft, commentVerification }, standaloneScope, exportedAt));
1536
+ const published = await publishReviewExport({
1537
+ repositoryRoot: scope.repositoryRoot,
1538
+ identity: { kind: 'standalone', reviewKey },
1539
+ ...(storageScope === undefined ? {} : { storageScope }),
1540
+ json,
1541
+ markdown: Buffer.from(renderReviewMarkdown(json), 'utf8'),
1542
+ reExportCapability: await getObservedNativeExchangeCapability(),
1543
+ revalidate: async () => {
1544
+ const current = await draftStore.loadState();
1545
+ if (current.kind !== 'current'
1546
+ || current.draft.revision !== acceptedDraft.revision
1547
+ || createHash('sha256').update(current.raw).digest('hex') !== draftFingerprint) {
1548
+ return false;
1549
+ }
1550
+ const currentSession = await snapshot.session();
1551
+ return currentSession.chain.length === snapshotSession.chain.length
1552
+ && currentSession.chain.every((digest, index) => digest === snapshotSession.chain[index]);
1553
+ },
1554
+ });
1555
+ if (published.kind !== 'exported')
1556
+ return ExportReviewResultSchema.parse({ kind: published.kind });
1557
+ return ExportReviewResultSchema.parse({
1558
+ kind: 'exported',
1559
+ draftRevision: acceptedDraft.revision,
1560
+ exportedAt,
1561
+ standalone: { reviewKey },
1562
+ files: published.receipt.files,
1563
+ });
1564
+ },
1565
+ async inspectLoopnoteIgnore() {
1566
+ return LoopnoteIgnoreStatusSchema.parse({ kind: 'unavailable' });
1567
+ },
1568
+ async appendLoopnoteIgnoreRule() {
1569
+ return AppendLoopnoteIgnoreResultSchema.parse({ kind: 'unconfirmed' });
1570
+ },
1571
+ });
1572
+ }