@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,5 @@
1
+ #!/usr/bin/env node
2
+ if (process.env.LOOPNOTE_SUPPORT_SERVICE_URL === undefined) process.env.LOOPNOTE_SUPPORT_SERVICE_URL = 'https://boruowxemintmdojkibd.supabase.co';
3
+ const { run } = await import('../cli/run.js');
4
+
5
+ await run();
@@ -0,0 +1,38 @@
1
+ import search from '@inquirer/search';
2
+ import { DIRTY_EXPLANATION, DIRTY_ROW_LABEL, } from '../domain/source.js';
3
+ import { escapeTerminalText } from './picker.js';
4
+ const confirmationChoices = [
5
+ { name: 'Launch pinned comparison', value: 'launch' },
6
+ { name: 'Back', value: 'back' },
7
+ ];
8
+ async function defaultPrompt(config) {
9
+ return await search({
10
+ message: config.message,
11
+ source: async () => [...config.choices],
12
+ });
13
+ }
14
+ export async function confirmPinnedComparison(comparison, dependencies = {}) {
15
+ const output = dependencies.output ?? console.log;
16
+ const prompt = dependencies.prompt ?? defaultPrompt;
17
+ output('Confirm pinned comparison');
18
+ output(`Base: ${escapeTerminalText(comparison.base.label)}\n${comparison.base.oid}`);
19
+ output(`Head: ${escapeTerminalText(comparison.head.label)}\n${comparison.head.oid}`);
20
+ output(`Merge base:\n${comparison.mergeBaseOid}`);
21
+ if (comparison.base.source?.kind === 'worktree') {
22
+ output(`Base worktree: ${escapeTerminalText(comparison.base.source.path)}`);
23
+ }
24
+ if (comparison.head.source?.kind === 'worktree') {
25
+ output(`Head worktree: ${escapeTerminalText(comparison.head.source.path)}`);
26
+ }
27
+ if ((comparison.base.source?.kind === 'worktree' &&
28
+ comparison.base.source.dirty) ||
29
+ (comparison.head.source?.kind === 'worktree' &&
30
+ comparison.head.source.dirty)) {
31
+ output(`${DIRTY_ROW_LABEL}\n${DIRTY_EXPLANATION}`);
32
+ }
33
+ output('This session is pinned to the commits shown below and does not follow moving refs.');
34
+ return await prompt({
35
+ message: 'Launch this pinned comparison?',
36
+ choices: confirmationChoices,
37
+ });
38
+ }
@@ -0,0 +1,342 @@
1
+ import search, { Separator } from '@inquirer/search';
2
+ import { RENAME_MAP } from '../domain/product-identity.js';
3
+ import { DIRTY_ROW_LABEL, PENDING_ROW_LABEL, UNAVAILABLE_WORKTREE_REASON, } from '../domain/source.js';
4
+ const BASE_PROMPT = 'Choose base — changes will be compared from its merge base with head';
5
+ const HEAD_PROMPT = 'Choose head — this committed state will be reviewed';
6
+ const BACK_TO_BASE = `${RENAME_MAP.slug}:back-to-base`;
7
+ const UNCOMMITTED_ROW_IDS = {
8
+ staged: `${RENAME_MAP.slug}:uncommitted:staged`,
9
+ unstaged: `${RENAME_MAP.slug}:uncommitted:unstaged`,
10
+ worktree: `${RENAME_MAP.slug}:uncommitted:worktree`,
11
+ };
12
+ const UNCOMMITTED_ROW_KIND_BY_ID = new Map([
13
+ [UNCOMMITTED_ROW_IDS.staged, 'staged'],
14
+ [UNCOMMITTED_ROW_IDS.unstaged, 'unstaged'],
15
+ [UNCOMMITTED_ROW_IDS.worktree, 'worktree'],
16
+ ]);
17
+ const UNCOMMITTED_BASE_PROMPT = 'Choose base — changes will be compared from its merge base with HEAD';
18
+ export function escapeTerminalText(value) {
19
+ let result = '';
20
+ for (const character of value) {
21
+ const codePoint = character.codePointAt(0);
22
+ switch (codePoint) {
23
+ case 0x09:
24
+ result += '\\t';
25
+ break;
26
+ case 0x0a:
27
+ result += '\\n';
28
+ break;
29
+ case 0x0d:
30
+ result += '\\r';
31
+ break;
32
+ case 0x5c:
33
+ result += '\\\\';
34
+ break;
35
+ default:
36
+ result +=
37
+ codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)
38
+ ? `\\x${codePoint.toString(16).toUpperCase().padStart(2, '0')}`
39
+ : character;
40
+ }
41
+ }
42
+ return result;
43
+ }
44
+ function worktreeState(candidate) {
45
+ if (candidate.availability === 'dirty') {
46
+ return DIRTY_ROW_LABEL;
47
+ }
48
+ if (candidate.availability === 'pending') {
49
+ return PENDING_ROW_LABEL;
50
+ }
51
+ if (candidate.availability === 'unavailable') {
52
+ return 'Unavailable';
53
+ }
54
+ return 'Clean';
55
+ }
56
+ function candidateMatches(candidate, query) {
57
+ if (query.length === 0) {
58
+ return true;
59
+ }
60
+ const identifyingText = candidate.kind === 'branch'
61
+ ? [
62
+ 'branch',
63
+ 'local branch',
64
+ candidate.label,
65
+ candidate.refName,
66
+ candidate.commitOid,
67
+ candidate.shortOid,
68
+ ].join(' ')
69
+ : [
70
+ 'worktree',
71
+ candidate.label,
72
+ candidate.path,
73
+ candidate.commitOid ?? '',
74
+ candidate.shortOid ?? '',
75
+ candidate.detached ? 'detached' : 'attached',
76
+ worktreeState(candidate),
77
+ candidate.unavailableReason ?? '',
78
+ ].join(' ');
79
+ return identifyingText.toLowerCase().includes(query);
80
+ }
81
+ function candidateItem(candidate, options) {
82
+ const suggested = options.role === 'head' && candidate.id === options.suggestedHeadId
83
+ ? ' · Suggested: current checkout'
84
+ : '';
85
+ if (candidate.kind === 'branch') {
86
+ return {
87
+ kind: 'candidate',
88
+ value: candidate.id,
89
+ name: `[Branch] ${escapeTerminalText(candidate.label)} · ${candidate.shortOid}${suggested}`,
90
+ };
91
+ }
92
+ const detached = candidate.detached ? ' · Detached' : '';
93
+ const shortOid = candidate.shortOid ?? 'no committed HEAD';
94
+ return {
95
+ kind: 'candidate',
96
+ value: candidate.id,
97
+ name: `[Worktree] ${escapeTerminalText(candidate.label)} · ${shortOid}` +
98
+ `${detached} · ${worktreeState(candidate)}${suggested}\n` +
99
+ ` ${escapeTerminalText(candidate.path)}`,
100
+ disabled: candidate.availability === 'unavailable'
101
+ ? candidate.unavailableReason ?? UNAVAILABLE_WORKTREE_REASON
102
+ : undefined,
103
+ };
104
+ }
105
+ export function buildSourceSearchItems(candidates, term, options) {
106
+ const query = (term ?? '').toLowerCase();
107
+ const branchItems = candidates
108
+ .filter((candidate) => candidate.kind === 'branch' && candidateMatches(candidate, query))
109
+ .map((candidate) => candidateItem(candidate, options));
110
+ const worktreeItems = candidates
111
+ .filter((candidate) => candidate.kind === 'worktree' && candidateMatches(candidate, query))
112
+ .map((candidate) => candidateItem(candidate, options));
113
+ const items = [];
114
+ if (options.role === 'base' && options.includeUncommittedGroup === true) {
115
+ items.push({ kind: 'separator', label: 'Uncommitted' }, { kind: 'candidate', value: UNCOMMITTED_ROW_IDS.staged, name: 'Staged' }, {
116
+ kind: 'candidate',
117
+ value: UNCOMMITTED_ROW_IDS.unstaged,
118
+ name: 'Unstaged',
119
+ }, {
120
+ kind: 'candidate',
121
+ value: UNCOMMITTED_ROW_IDS.worktree,
122
+ name: 'Working tree',
123
+ });
124
+ }
125
+ items.push({ kind: 'separator', label: 'Local branches' }, ...branchItems, { kind: 'separator', label: 'Worktrees' }, ...worktreeItems);
126
+ if (branchItems.length === 0 && worktreeItems.length === 0) {
127
+ items.push({
128
+ kind: 'separator',
129
+ label: 'No branches or worktrees match this search.',
130
+ });
131
+ }
132
+ if (options.role === 'head') {
133
+ items.push({ kind: 'action', value: BACK_TO_BASE, name: 'Back' });
134
+ }
135
+ return items;
136
+ }
137
+ function promptItems(items) {
138
+ return items.map((item) => item.kind === 'separator'
139
+ ? new Separator(item.label)
140
+ : {
141
+ value: item.value,
142
+ name: item.name,
143
+ ...(item.kind === 'candidate' && item.disabled !== undefined
144
+ ? { disabled: item.disabled }
145
+ : {}),
146
+ });
147
+ }
148
+ function mergedCandidates(candidates, branches) {
149
+ const branchIds = new Set();
150
+ const mergedBranches = branches.filter((candidate) => {
151
+ if (branchIds.has(candidate.id)) {
152
+ return false;
153
+ }
154
+ branchIds.add(candidate.id);
155
+ return true;
156
+ });
157
+ return [
158
+ ...mergedBranches,
159
+ ...candidates.filter((candidate) => candidate.kind === 'worktree'),
160
+ ];
161
+ }
162
+ function sourceForPrompt(candidates, searchBranches, candidateById, startCandidateEnrichment, options, initialSearchTerm = '') {
163
+ let firstRequest = true;
164
+ return async (term, { signal }) => {
165
+ signal.throwIfAborted();
166
+ const startEnrichment = firstRequest;
167
+ const effectiveTerm = firstRequest && term === undefined ? initialSearchTerm : term;
168
+ firstRequest = false;
169
+ if ((effectiveTerm ?? '').length === 0) {
170
+ const items = promptItems(buildSourceSearchItems(candidates(), effectiveTerm, options));
171
+ if (startEnrichment) {
172
+ startCandidateEnrichment?.();
173
+ }
174
+ return items;
175
+ }
176
+ const branches = await searchBranches(effectiveTerm, signal);
177
+ signal.throwIfAborted();
178
+ for (const candidate of branches) {
179
+ candidateById.set(candidate.id, candidate);
180
+ }
181
+ const items = promptItems(buildSourceSearchItems(mergedCandidates(candidates(), branches), undefined, options));
182
+ if (startEnrichment) {
183
+ startCandidateEnrichment?.();
184
+ }
185
+ return items;
186
+ };
187
+ }
188
+ export async function pickOrderedSources(options, dependencies = {}) {
189
+ let currentCandidates = options.candidates;
190
+ const candidateById = new Map(currentCandidates.map((candidate) => [candidate.id, candidate]));
191
+ const candidateEnrichment = options.candidateEnrichment?.then((enrichedCandidates) => {
192
+ currentCandidates = enrichedCandidates;
193
+ for (const candidate of enrichedCandidates) {
194
+ candidateById.set(candidate.id, candidate);
195
+ }
196
+ return enrichedCandidates;
197
+ });
198
+ void candidateEnrichment?.catch(() => undefined);
199
+ const prompt = dependencies.prompt ??
200
+ (async (config) => await search({
201
+ message: config.message,
202
+ source: config.source,
203
+ ...(config.default === undefined ? {} : { default: config.default }),
204
+ }));
205
+ let base = options.initialBase;
206
+ let head = options.initialHead;
207
+ while (true) {
208
+ if (base === undefined) {
209
+ const selectedBaseId = await prompt({
210
+ message: head === undefined
211
+ ? BASE_PROMPT
212
+ : `${BASE_PROMPT}\nHead retained: ${escapeTerminalText(head.label)} · ${head.shortOid ?? 'unavailable'}`,
213
+ source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base' }, options.recovery?.role === 'base'
214
+ ? options.recovery.searchTerm
215
+ : ''),
216
+ ...(options.recovery?.role === 'base' &&
217
+ options.recovery.focusedCandidateId !== undefined
218
+ ? { default: options.recovery.focusedCandidateId }
219
+ : {}),
220
+ });
221
+ base =
222
+ selectedBaseId === undefined
223
+ ? undefined
224
+ : candidateById.get(selectedBaseId);
225
+ if (base === undefined) {
226
+ throw new Error('Base selection did not identify an available source');
227
+ }
228
+ }
229
+ if (head === undefined) {
230
+ const selectedHeadId = await prompt({
231
+ message: options.recovery?.role === 'head'
232
+ ? `${HEAD_PROMPT}\nBase retained: ${escapeTerminalText(base.label)} · ${base.shortOid ?? 'unavailable'}`
233
+ : HEAD_PROMPT,
234
+ source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, {
235
+ role: 'head',
236
+ suggestedHeadId: options.suggestedHeadId,
237
+ }, options.recovery?.role === 'head' ? options.recovery.searchTerm : ''),
238
+ default: options.recovery?.role === 'head'
239
+ ? options.recovery.focusedCandidateId
240
+ : options.suggestedHeadId,
241
+ backValue: BACK_TO_BASE,
242
+ });
243
+ if (selectedHeadId === BACK_TO_BASE) {
244
+ base = undefined;
245
+ continue;
246
+ }
247
+ head =
248
+ selectedHeadId === undefined
249
+ ? undefined
250
+ : candidateById.get(selectedHeadId);
251
+ if (head === undefined) {
252
+ throw new Error('Head selection did not identify an available source');
253
+ }
254
+ }
255
+ if (candidateEnrichment !== undefined &&
256
+ (base.kind === 'worktree' || head.kind === 'worktree')) {
257
+ options.startCandidateEnrichment?.();
258
+ await candidateEnrichment;
259
+ base = candidateById.get(base.id);
260
+ head = candidateById.get(head.id);
261
+ if (base === undefined) {
262
+ throw new Error('Base selection did not identify an available source');
263
+ }
264
+ if (head === undefined) {
265
+ throw new Error('Head selection did not identify an available source');
266
+ }
267
+ }
268
+ if (base.kind === 'worktree' && base.availability === 'unavailable') {
269
+ base = undefined;
270
+ continue;
271
+ }
272
+ if (head.kind === 'worktree' && head.availability === 'unavailable') {
273
+ head = undefined;
274
+ continue;
275
+ }
276
+ return Object.freeze({ base, head });
277
+ }
278
+ }
279
+ export async function pickSource(options, dependencies = {}) {
280
+ if (options.initialBase !== undefined || options.initialHead !== undefined) {
281
+ const result = await pickOrderedSources(options, dependencies);
282
+ return Object.freeze({ kind: 'committed', ...result });
283
+ }
284
+ let currentCandidates = options.candidates;
285
+ const candidateById = new Map(currentCandidates.map((candidate) => [candidate.id, candidate]));
286
+ const candidateEnrichment = options.candidateEnrichment?.then((enrichedCandidates) => {
287
+ currentCandidates = enrichedCandidates;
288
+ for (const candidate of enrichedCandidates) {
289
+ candidateById.set(candidate.id, candidate);
290
+ }
291
+ return enrichedCandidates;
292
+ });
293
+ void candidateEnrichment?.catch(() => undefined);
294
+ const prompt = dependencies.prompt ??
295
+ (async (config) => await search({
296
+ message: config.message,
297
+ source: config.source,
298
+ ...(config.default === undefined ? {} : { default: config.default }),
299
+ }));
300
+ const selectedBaseId = await prompt({
301
+ message: BASE_PROMPT,
302
+ source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base', includeUncommittedGroup: true }, options.recovery?.role === 'base' ? options.recovery.searchTerm : ''),
303
+ ...(options.recovery?.role === 'base' &&
304
+ options.recovery.focusedCandidateId !== undefined
305
+ ? { default: options.recovery.focusedCandidateId }
306
+ : {}),
307
+ });
308
+ const uncommittedKind = selectedBaseId === undefined
309
+ ? undefined
310
+ : UNCOMMITTED_ROW_KIND_BY_ID.get(selectedBaseId);
311
+ if (uncommittedKind !== undefined) {
312
+ if (uncommittedKind === 'unstaged') {
313
+ return Object.freeze({ kind: 'uncommitted', sourceKind: 'unstaged' });
314
+ }
315
+ const selectedSecondBaseId = await prompt({
316
+ message: UNCOMMITTED_BASE_PROMPT,
317
+ source: sourceForPrompt(() => currentCandidates, options.searchBranches, candidateById, options.startCandidateEnrichment, { role: 'base' }),
318
+ ...(options.preselectedBaseId === undefined
319
+ ? {}
320
+ : { default: options.preselectedBaseId }),
321
+ });
322
+ const resolvedBase = selectedSecondBaseId === undefined
323
+ ? undefined
324
+ : candidateById.get(selectedSecondBaseId);
325
+ if (resolvedBase === undefined) {
326
+ throw new Error('Base selection did not identify an available source');
327
+ }
328
+ return Object.freeze({
329
+ kind: 'uncommitted',
330
+ sourceKind: uncommittedKind,
331
+ base: resolvedBase,
332
+ });
333
+ }
334
+ const initialBase = selectedBaseId === undefined
335
+ ? undefined
336
+ : candidateById.get(selectedBaseId);
337
+ if (initialBase === undefined) {
338
+ throw new Error('Base selection did not identify an available source');
339
+ }
340
+ const result = await pickOrderedSources({ ...options, initialBase }, dependencies);
341
+ return Object.freeze({ kind: 'committed', ...result });
342
+ }
@@ -0,0 +1,120 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { AgentReviewRequestSchema, MAX_AGENT_REQUEST_BYTES, } from '../contracts/request.js';
3
+ import { RENAME_MAP } from '../domain/product-identity.js';
4
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
5
+ const requestErrorMessages = {
6
+ 'empty-request': `Request input is empty. Pipe one ${RENAME_MAP.kindPrefix}.review-request JSON document.`,
7
+ 'request-too-large': 'Request input exceeds 1048576 bytes. Reduce the request and try again.',
8
+ 'invalid-utf8': 'Request input must be valid UTF-8 JSON.',
9
+ 'malformed-json': 'Request input must contain one JSON document.',
10
+ 'unsupported-version': 'Request schema version is unsupported. Use schemaVersion 1.',
11
+ 'invalid-request': `Request is invalid. Use kind "${RENAME_MAP.kindPrefix}.review-request", schemaVersion 1, and one supported source mode.`,
12
+ 'legacy-kind': `Request kind is outdated. Use kind "${RENAME_MAP.kindPrefix}.review-request" instead.`,
13
+ 'unknown-mode': 'Request mode is not supported. Use revisions, patch, uncommitted, or files.',
14
+ 'base-with-unstaged': '"base" cannot be combined with source "unstaged".',
15
+ 'path-outside-repository': 'Request references a path outside the repository.',
16
+ 'pathspec-no-match': 'Request pathspec does not match any file.',
17
+ 'pathspec-ignored': 'Request pathspec matches only ignored files.',
18
+ };
19
+ export class AgentRequestError extends Error {
20
+ kind;
21
+ constructor(kind) {
22
+ super(requestErrorMessages[kind]);
23
+ this.name = 'AgentRequestError';
24
+ this.kind = kind;
25
+ }
26
+ }
27
+ // The literal old-kind string is an intentional, documented grep-audit exception
28
+ // (17.1-PATTERNS.md Pattern 1): it must match the exact pre-rename kind so requests
29
+ // still carrying it are recognized and rejected with a specific, named error.
30
+ function isLegacyKind(value) {
31
+ return (typeof value === 'object' &&
32
+ value !== null &&
33
+ !Array.isArray(value) &&
34
+ 'kind' in value &&
35
+ value.kind === 'cumpa.review-request');
36
+ }
37
+ function isUnsupportedVersion(value) {
38
+ return (typeof value === 'object' &&
39
+ value !== null &&
40
+ !Array.isArray(value) &&
41
+ 'schemaVersion' in value &&
42
+ value.schemaVersion !== 1);
43
+ }
44
+ function isUnknownMode(value) {
45
+ return (typeof value === 'object' &&
46
+ value !== null &&
47
+ !Array.isArray(value) &&
48
+ 'mode' in value &&
49
+ typeof value.mode === 'string' &&
50
+ !['revisions', 'patch', 'uncommitted', 'files'].includes(value.mode));
51
+ }
52
+ function isBaseWithUnstaged(value) {
53
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !('uncommitted' in value)) {
54
+ return false;
55
+ }
56
+ const uncommitted = value.uncommitted;
57
+ return (typeof uncommitted === 'object' &&
58
+ uncommitted !== null &&
59
+ !Array.isArray(uncommitted) &&
60
+ 'source' in uncommitted &&
61
+ uncommitted.source === 'unstaged' &&
62
+ 'base' in uncommitted &&
63
+ uncommitted.base !== undefined);
64
+ }
65
+ function freezeRequest(request) {
66
+ if (request.mode === 'revisions') {
67
+ Object.freeze(request.revisions.pathspecs);
68
+ Object.freeze(request.revisions);
69
+ }
70
+ else if (request.mode === 'patch') {
71
+ Object.freeze(request.patch.target);
72
+ Object.freeze(request.patch);
73
+ }
74
+ else if (request.mode === 'uncommitted') {
75
+ Object.freeze(request.uncommitted.pathspecs);
76
+ Object.freeze(request.uncommitted);
77
+ }
78
+ else {
79
+ Object.freeze(request.files.paths);
80
+ Object.freeze(request.files);
81
+ }
82
+ return Object.freeze(request);
83
+ }
84
+ export async function readAgentReviewRequest(input) {
85
+ const chunks = [];
86
+ let byteLength = 0;
87
+ for await (const chunk of input) {
88
+ byteLength += chunk.byteLength;
89
+ if (byteLength > MAX_AGENT_REQUEST_BYTES) {
90
+ throw new AgentRequestError('request-too-large');
91
+ }
92
+ chunks.push(chunk);
93
+ }
94
+ if (byteLength === 0) {
95
+ throw new AgentRequestError('empty-request');
96
+ }
97
+ let value;
98
+ try {
99
+ value = JSON.parse(strictUtf8.decode(Buffer.concat(chunks, byteLength)));
100
+ }
101
+ catch (error) {
102
+ if (error instanceof TypeError) {
103
+ throw new AgentRequestError('invalid-utf8');
104
+ }
105
+ throw new AgentRequestError('malformed-json');
106
+ }
107
+ const parsed = AgentReviewRequestSchema.safeParse(value);
108
+ if (!parsed.success) {
109
+ throw new AgentRequestError(isLegacyKind(value)
110
+ ? 'legacy-kind'
111
+ : isUnsupportedVersion(value)
112
+ ? 'unsupported-version'
113
+ : isUnknownMode(value)
114
+ ? 'unknown-mode'
115
+ : isBaseWithUnstaged(value)
116
+ ? 'base-with-unstaged'
117
+ : 'invalid-request');
118
+ }
119
+ return freezeRequest(parsed.data);
120
+ }