@dev.fast/whiteboard 0.0.0-stage → 0.2.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,300 @@
1
+ import { existsSync, realpathSync, rmSync } from "node:fs";
2
+ import { lstat, mkdir, readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ git,
7
+ gitCommonDir,
8
+ jjRevisionIsConflicted,
9
+ resolveRevision,
10
+ } from "@dev.fast/local-vcs";
11
+ import { errorMessage, withFileLock } from "@dev.fast/trace-core";
12
+
13
+ import {
14
+ type ReviewCheckoutRole,
15
+ reviewManagedCheckoutDir,
16
+ reviewManagedCheckoutRoot,
17
+ reviewManagedCheckoutsDir,
18
+ } from "./review-checkout-paths";
19
+ import { removeReviewPrepareArtifacts } from "./review-prepare.js";
20
+
21
+ // A review renders the pinned code on the canvas, but file reads against the
22
+ // user's working tree see whatever is checked out there — including edits
23
+ // made after the pin. Materialize any pinned commit (the head, the base) as a
24
+ // detached, reusable git worktree beside the other dev-fast state in the
25
+ // shared git dir so review sessions and graph builds read immutable bytes,
26
+ // even when the working copy currently sits at the pinned commit. Returns the
27
+ // checkout path, or null when the ref cannot be resolved to a commit or the
28
+ // repo has no shared git dir.
29
+ export async function ensureReviewPinnedCheckout(input: {
30
+ rootPath: string;
31
+ ref: string;
32
+ reviewUuid: string;
33
+ role?: ReviewCheckoutRole;
34
+ }): Promise<string | null> {
35
+ const target = await resolveReviewPinnedCheckout({
36
+ ...input,
37
+ role: input.role ?? "head",
38
+ });
39
+
40
+ if (!target) return null;
41
+
42
+ const prepared = await withFileLock(
43
+ `${target.checkoutPath}.prepare-lock`,
44
+ {
45
+ retryMs: 20,
46
+ timeoutMs: 30_000,
47
+ staleMs: 120_000,
48
+ heartbeatMs: 5_000,
49
+ unownedGraceMs: 1_000,
50
+ },
51
+ () =>
52
+ materializeReviewPinnedCheckout({ rootPath: input.rootPath, ...target }),
53
+ );
54
+
55
+ if (!prepared.acquired)
56
+ throw new Error(
57
+ `Pinned checkout ${target.commit} is busy; retry the operation.`,
58
+ );
59
+
60
+ return target.checkoutPath;
61
+ }
62
+
63
+ async function resolveReviewPinnedCheckout(input: {
64
+ rootPath: string;
65
+ ref: string;
66
+ reviewUuid: string;
67
+ role: ReviewCheckoutRole;
68
+ }): Promise<{ checkoutPath: string; commit: string } | null> {
69
+ const commit = await resolveReviewPinnedCommit(input.rootPath, input.ref);
70
+
71
+ if (!commit) return null;
72
+ const commonDir = await gitCommonDir(input.rootPath);
73
+
74
+ if (!commonDir) return null;
75
+
76
+ return {
77
+ checkoutPath: reviewManagedCheckoutDir(
78
+ commonDir,
79
+ input.reviewUuid,
80
+ input.role ?? "head",
81
+ commit,
82
+ ),
83
+ commit,
84
+ };
85
+ }
86
+
87
+ async function materializeReviewPinnedCheckout(input: {
88
+ rootPath: string;
89
+ checkoutPath: string;
90
+ commit: string;
91
+ }): Promise<void> {
92
+ const { checkoutPath } = input;
93
+
94
+ if (await isWorktreeAt(input.rootPath, checkoutPath, input.commit)) {
95
+ return;
96
+ }
97
+
98
+ // jj materializes conflict markers into the git export of a conflicted
99
+ // revision, so a checkout of it would silently contain marker text instead
100
+ // of code — and the graph would index that. Refuse with the remedy instead.
101
+ if (await jjRevisionIsConflicted(input.rootPath, input.commit)) {
102
+ throw new Error(
103
+ `Whiteboard cannot pin conflicted revision ${input.commit.slice(0, 12)}: the jj change has unresolved conflicts. Resolve them (jj resolve), then re-pin the review.`,
104
+ );
105
+ }
106
+
107
+ // A stale registration (e.g. a manually gutted or deleted directory) blocks
108
+ // re-adding the same path: drop both the registration and any leftover
109
+ // directory before recreating the worktree.
110
+ await git(input.rootPath, ["worktree", "remove", "--force", checkoutPath], {
111
+ allowFailure: true,
112
+ });
113
+ await git(input.rootPath, ["worktree", "prune"], { allowFailure: true });
114
+ rmSync(checkoutPath, { recursive: true, force: true });
115
+ await removeReviewPrepareArtifacts(checkoutPath);
116
+ await mkdir(path.dirname(checkoutPath), { recursive: true });
117
+ await git(input.rootPath, [
118
+ "worktree",
119
+ "add",
120
+ "--detach",
121
+ checkoutPath,
122
+ input.commit,
123
+ ]);
124
+ }
125
+
126
+ /**
127
+ * Remove every checkout one Review owns in a repository. Registrations go
128
+ * first, so an interrupted removal cannot leave a gutted tree that git still
129
+ * lists and a later ensure would reuse; an unregistered leftover is replaced
130
+ * by the next ensure. Each checkout is removed on its own, so one that cannot
131
+ * be deleted leaves the others removed; the error names every failure.
132
+ */
133
+ export async function removeReviewManagedCheckouts(
134
+ commonDir: string,
135
+ reviewUuid: string,
136
+ ): Promise<void> {
137
+ const root = reviewManagedCheckoutRoot(commonDir, reviewUuid);
138
+
139
+ if (!isInsideDirectory(root, reviewManagedCheckoutsDir(commonDir)))
140
+ throw new Error(`Refusing to remove non-managed checkouts at ${root}.`);
141
+
142
+ // Resolve symlinks above the root (say /tmp) only; never the root itself.
143
+ const roots = [
144
+ root,
145
+ path.join(realPath(path.dirname(root)), path.basename(root)),
146
+ ];
147
+
148
+ const errors: string[] = [];
149
+ // A checkout git refused to remove (say, locked) stays registered and whole.
150
+ const kept = new Set<string>();
151
+
152
+ const attempt = (work: Promise<unknown>) =>
153
+ work.then(
154
+ () => true,
155
+ (error) => {
156
+ errors.push(errorMessage(error));
157
+
158
+ return false;
159
+ },
160
+ );
161
+
162
+ for (const worktree of await listRegisteredWorktrees(commonDir))
163
+ if (
164
+ roots.some((dir) => isInsideDirectory(worktree.worktreePath, dir)) &&
165
+ !(await attempt(
166
+ git(commonDir, [
167
+ "worktree",
168
+ "remove",
169
+ "--force",
170
+ worktree.worktreePath,
171
+ ]),
172
+ ))
173
+ )
174
+ kept.add(realPath(worktree.worktreePath));
175
+
176
+ // Descend only into real directories. rm removes a symlink itself, never
177
+ // what it points to, so nothing outside the root is touched.
178
+ const entries = async (dir: string) =>
179
+ (await lstat(dir).catch(() => null))?.isDirectory()
180
+ ? await readdir(dir, { withFileTypes: true })
181
+ : [];
182
+
183
+ for (const entry of await entries(root)) {
184
+ const dir = path.join(root, entry.name);
185
+
186
+ // base/<commit> and head/<commit> are checkouts; anything else (the
187
+ // navigator workspaces) goes whole.
188
+ const checkouts =
189
+ entry.isDirectory() && (entry.name === "base" || entry.name === "head")
190
+ ? (await entries(dir)).map((child) => path.join(dir, child.name))
191
+ : [dir];
192
+
193
+ for (const checkout of checkouts)
194
+ if (
195
+ !kept.has(
196
+ path.join(realPath(path.dirname(checkout)), path.basename(checkout)),
197
+ )
198
+ )
199
+ await attempt(rm(checkout, { recursive: true, force: true }));
200
+ }
201
+
202
+ if (!errors.length) await rm(root, { recursive: true, force: true });
203
+ await git(commonDir, ["worktree", "prune"], { allowFailure: true });
204
+
205
+ if (errors.length) throw new Error(errors.join("\n"));
206
+ }
207
+
208
+ // Resolve a pinned ref to a commit. Prefer the jj-first local-vcs
209
+ // resolution (worktree-aware for HEAD/@ and branch names); fall back to git
210
+ // against the shared git dir for refs only the backing store knows, like the
211
+ // pinned refs/dev-fast/reviews/pr-N/head in a non-colocated jj workspace.
212
+ async function resolveReviewPinnedCommit(
213
+ rootPath: string,
214
+ ref: string,
215
+ ): Promise<string | null> {
216
+ const resolved = await resolveRevision(rootPath, ref).catch(() => null);
217
+
218
+ if (resolved) return resolved.commit;
219
+
220
+ const fromGitDir = await git(
221
+ rootPath,
222
+ ["rev-parse", "--verify", `${ref}^{commit}`],
223
+ { allowFailure: true },
224
+ ).catch(() => null);
225
+
226
+ if (!fromGitDir?.ok) return null;
227
+
228
+ return fromGitDir.stdout.trim() || null;
229
+ }
230
+
231
+ // A checkout is reusable only when git still registers a worktree at that
232
+ // path, its HEAD is the wanted commit, and the directory has not been gutted
233
+ // on disk (its .git link is the marker git itself relies on).
234
+ async function isWorktreeAt(
235
+ rootPath: string,
236
+ checkoutPath: string,
237
+ commit: string,
238
+ ): Promise<boolean> {
239
+ if (!existsSync(path.join(checkoutPath, ".git"))) return false;
240
+
241
+ const list = await git(rootPath, ["worktree", "list", "--porcelain"], {
242
+ allowFailure: true,
243
+ }).catch(() => null);
244
+
245
+ if (!list?.ok) return false;
246
+
247
+ return parseWorktreeList(list.stdout).some(
248
+ (entry) =>
249
+ path.resolve(entry.worktreePath) === path.resolve(checkoutPath) &&
250
+ entry.headCommit === commit,
251
+ );
252
+ }
253
+
254
+ function parseWorktreeList(
255
+ output: string,
256
+ ): Array<{ worktreePath: string; headCommit: string | null }> {
257
+ const entries: Array<{ worktreePath: string; headCommit: string | null }> =
258
+ [];
259
+
260
+ for (const block of output.split(/\n\n+/)) {
261
+ let worktreePath: string | null = null;
262
+ let headCommit: string | null = null;
263
+
264
+ for (const line of block.split("\n")) {
265
+ if (line.startsWith("worktree ")) {
266
+ worktreePath = line.slice("worktree ".length);
267
+ } else if (line.startsWith("HEAD ")) {
268
+ headCommit = line.slice("HEAD ".length);
269
+ }
270
+ }
271
+
272
+ if (worktreePath) entries.push({ worktreePath, headCommit });
273
+ }
274
+
275
+ return entries;
276
+ }
277
+
278
+ async function listRegisteredWorktrees(
279
+ rootPath: string,
280
+ ): Promise<Array<{ worktreePath: string; headCommit: string | null }>> {
281
+ const listed = await git(rootPath, ["worktree", "list", "--porcelain"], {
282
+ allowFailure: true,
283
+ }).catch(() => null);
284
+
285
+ return listed?.ok ? parseWorktreeList(listed.stdout) : [];
286
+ }
287
+
288
+ function realPath(filePath: string): string {
289
+ return existsSync(filePath) ? realpathSync(filePath) : path.resolve(filePath);
290
+ }
291
+
292
+ function isInsideDirectory(filePath: string, directory: string): boolean {
293
+ const relative = path.relative(directory, filePath);
294
+
295
+ return (
296
+ Boolean(relative) &&
297
+ !relative.startsWith("..") &&
298
+ !path.isAbsolute(relative)
299
+ );
300
+ }
@@ -0,0 +1,73 @@
1
+ import crypto from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ import { devReviewHome } from "@dev.fast/trace-core";
5
+
6
+ export { DEV_REVIEW_HOME_ENV, devReviewHome } from "@dev.fast/trace-core";
7
+
8
+ /** Return the repository-specific storage root under the shared Review home. */
9
+ export function reviewRepoStorageRoot(rootPath: string): string {
10
+ return path.join(devReviewHome(), "repos", hashedSegment(rootPath, "repo"));
11
+ }
12
+
13
+ /** `<basename>-<12 hex>` of a resolved path, one safe storage segment. */
14
+ function hashedSegment(target: string, fallback: string): string {
15
+ const resolved = path.resolve(target);
16
+ const basename = safeStorageSegment(path.basename(resolved) || fallback);
17
+
18
+ const hash = crypto
19
+ .createHash("sha256")
20
+ .update(resolved)
21
+ .digest("hex")
22
+ .slice(0, 12);
23
+
24
+ return `${basename}-${hash}`;
25
+ }
26
+
27
+ /** Encode a value as one filesystem-safe storage path segment. */
28
+ export function safeStorageSegment(value: string): string {
29
+ return value.replace(/[^A-Za-z0-9_.-]+/g, "__");
30
+ }
31
+
32
+ export function reviewDesktopRoot(
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): string {
35
+ return path.join(devReviewHome(env), "review-desktop");
36
+ }
37
+
38
+ /** Written by Desktops that predate instance selection. */
39
+ export function reviewLegacyDiscoveryPath(
40
+ env: NodeJS.ProcessEnv = process.env,
41
+ ): string {
42
+ return path.join(reviewDesktopRoot(env), "server.json");
43
+ }
44
+
45
+ export function reviewInstancesDir(
46
+ env: NodeJS.ProcessEnv = process.env,
47
+ ): string {
48
+ return path.join(reviewDesktopRoot(env), "instances");
49
+ }
50
+
51
+ export function reviewInstanceDiscoveryPath(
52
+ key: string,
53
+ env: NodeJS.ProcessEnv = process.env,
54
+ ): string {
55
+ return path.join(reviewInstancesDir(env), `${key}.json`);
56
+ }
57
+
58
+ export function reviewDefaultInstancePath(
59
+ env: NodeJS.ProcessEnv = process.env,
60
+ ): string {
61
+ return path.join(reviewDesktopRoot(env), "default-instance");
62
+ }
63
+
64
+ /** `dev-<name>-<hash>` for a source checkout, keyed like reviewRepoStorageRoot. */
65
+ export function reviewDevInstanceKey(checkout: string): string {
66
+ return `dev-${hashedSegment(checkout, "checkout")}`;
67
+ }
68
+
69
+ export function reviewDesktopStateDir(
70
+ env: NodeJS.ProcessEnv = process.env,
71
+ ): string {
72
+ return path.join(reviewDesktopRoot(env), "state");
73
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ ReviewApiClient,
3
+ type ReviewApiSummary,
4
+ } from "@dev.fast/review-protocol";
5
+
6
+ import { requireHealthyReviewDesktop } from "./desktop-discovery";
7
+ import { resolveReviewRoot } from "./runtime";
8
+
9
+ export interface RunReviewInfoInput {
10
+ cwd: string;
11
+ all?: boolean;
12
+ reviewUuid?: string;
13
+ }
14
+
15
+ export interface ReviewInfoEvent {
16
+ event: "info";
17
+ reviews: ReviewApiSummary[];
18
+ }
19
+
20
+ export async function runReviewInfo(
21
+ input: RunReviewInfoInput,
22
+ runtime = { requireHealthyReviewDesktop, resolveReviewRoot },
23
+ ): Promise<ReviewInfoEvent> {
24
+ if (input.all && input.reviewUuid)
25
+ throw new Error("Review info cannot combine all and reviewUuid.");
26
+ const discovery = await runtime.requireHealthyReviewDesktop();
27
+
28
+ const client = new ReviewApiClient({
29
+ serverUrl: discovery.url,
30
+ token: discovery.token,
31
+ });
32
+
33
+ // The API is mounted at "/reviews-api" and Hono matches strictly; "" is the
34
+ // catalog route and "/" is a 404.
35
+ const reviews = await client.read<ReviewApiSummary[]>("");
36
+
37
+ if (input.reviewUuid) {
38
+ const selected = reviews.find(
39
+ (review) => review.reviewId === input.reviewUuid,
40
+ );
41
+
42
+ if (!selected)
43
+ throw new Error(
44
+ `Review not found: ${input.reviewUuid}. If this is an old Whiteboard review, ask your agent to migrate your old Whiteboard reviews.`,
45
+ );
46
+
47
+ return { event: "info", reviews: [selected] };
48
+ }
49
+
50
+ const root = await runtime.resolveReviewRoot(input.cwd);
51
+
52
+ return {
53
+ event: "info",
54
+ reviews: reviews.filter(
55
+ (review) =>
56
+ review.repositoryPath === root && (input.all || !review.dismissedAt),
57
+ ),
58
+ };
59
+ }
@@ -0,0 +1,113 @@
1
+ import type { Writable } from "node:stream";
2
+
3
+ import {
4
+ REVIEW_INSTANCE_ENV,
5
+ clearDefaultReviewInstance,
6
+ readDefaultReviewInstance,
7
+ selectReviewInstance,
8
+ writeDefaultReviewInstance,
9
+ } from "./desktop-discovery";
10
+
11
+ interface ReviewInstancesInput {
12
+ env: NodeJS.ProcessEnv;
13
+ stdout: Writable;
14
+ stderr: Writable;
15
+ json?: boolean;
16
+ }
17
+
18
+ export async function listReviewInstancesCommand(input: ReviewInstancesInput) {
19
+ const selection = await selectReviewInstance({
20
+ env: input.env,
21
+ warn: (message) => input.stderr.write(`${message}\n`),
22
+ });
23
+
24
+ const defaultKey = await readDefaultReviewInstance(input.env);
25
+
26
+ const rows = selection.instances.map(({ key, discovery, healthy }) => ({
27
+ key,
28
+ channel: discovery.channel ?? null,
29
+ state: healthy ? "running" : "stopped",
30
+ version: discovery.appVersion ?? null,
31
+ url: discovery.url,
32
+ checkout: discovery.checkout ?? null,
33
+ selected: key === selection.key,
34
+ default: key === defaultKey,
35
+ }));
36
+
37
+ if (input.json) {
38
+ input.stdout.write(
39
+ `${JSON.stringify({ event: "instances", selected: selection.key, selectedBy: selection.source, default: defaultKey ?? null, problem: selection.problem?.message ?? null, instances: rows })}\n`,
40
+ );
41
+
42
+ return;
43
+ }
44
+
45
+ const table = [
46
+ ["", "KEY", "CHANNEL", "STATE", "VERSION", "URL", "CHECKOUT"],
47
+ ...rows.map((row) => [
48
+ row.selected ? "*" : "",
49
+ row.key,
50
+ row.channel ?? "",
51
+ row.state,
52
+ row.version ?? "",
53
+ row.url,
54
+ row.checkout ?? "",
55
+ ]),
56
+ ];
57
+
58
+ const widths = table[0]!.map((_, column) =>
59
+ Math.max(...table.map((row) => row[column]!.length)),
60
+ );
61
+
62
+ for (const row of table)
63
+ input.stdout.write(
64
+ `${row
65
+ .map((cell, column) => cell.padEnd(widths[column]!))
66
+ .join(" ")
67
+ .trimEnd()}\n`,
68
+ );
69
+
70
+ const state = selection.instance
71
+ ? ""
72
+ : selection.problem
73
+ ? ` Its record is unusable: ${selection.problem.message}`
74
+ : " It has not started on this machine.";
75
+
76
+ input.stdout.write(
77
+ `\nSelected: ${selection.key} (${describeSource(selection.source)}).${state}\n`,
78
+ );
79
+ }
80
+
81
+ export async function useReviewInstance(
82
+ key: string,
83
+ input: ReviewInstancesInput,
84
+ ) {
85
+ await writeDefaultReviewInstance(key, input.env);
86
+
87
+ input.stdout.write(
88
+ input.json
89
+ ? `${JSON.stringify({ event: "instances.use", default: key })}\n`
90
+ : `Default instance: ${key}\nFor this shell only: export ${REVIEW_INSTANCE_ENV}=${key}\n`,
91
+ );
92
+ }
93
+
94
+ export async function clearReviewInstance(input: ReviewInstancesInput) {
95
+ await clearDefaultReviewInstance(input.env);
96
+
97
+ input.stdout.write(
98
+ input.json
99
+ ? `${JSON.stringify({ event: "instances.clear" })}\n`
100
+ : "Cleared the default instance.\n",
101
+ );
102
+ }
103
+
104
+ function describeSource(
105
+ source: Awaited<ReturnType<typeof selectReviewInstance>>["source"],
106
+ ) {
107
+ return {
108
+ env: `from ${REVIEW_INSTANCE_ENV}`,
109
+ default: "the machine default",
110
+ "only-running": "the only one running",
111
+ fallback: "no selection, so stable",
112
+ }[source];
113
+ }