@dev.fast/whiteboard 0.3.4 → 0.3.5-preview.20261021.131

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 (120) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CisiYUZT.js} +13 -8
  2. package/dist/agent-cli-BglNRDZN.js +2 -0
  3. package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
  14. package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
  15. package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
  16. package/dist/lens-selection-BxPk53c8.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
  18. package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
  20. package/dist/profile-Dbyidrcr.js +2 -0
  21. package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
  23. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
  24. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
  31. package/dist/runtime-CF4JXdb8.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +28 -104
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -74
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
  42. package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
  46. package/instructions/authoring.md +20 -5
  47. package/package.json +4 -4
  48. package/src/agent-selection.ts +6 -0
  49. package/src/ask/agents.ts +13 -1
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +61 -31
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/markdown.ts +42 -0
  61. package/src/posthog-capture-client.ts +1 -1
  62. package/src/review-api/README.md +52 -13
  63. package/src/review-api/activity.ts +1 -4
  64. package/src/review-api/agent-cli.ts +9 -1
  65. package/src/review-api/anchor-quotes.ts +14 -7
  66. package/src/review-api/ask-history.ts +118 -51
  67. package/src/review-api/authoring-tools.ts +45 -30
  68. package/src/review-api/azure-devops-provider.ts +2 -20
  69. package/src/review-api/azure-remote.ts +21 -0
  70. package/src/review-api/blocks/definition.ts +1 -1
  71. package/src/review-api/blocks/markdown.ts +2 -2
  72. package/src/review-api/board-order.ts +144 -0
  73. package/src/review-api/checkout-groups.ts +586 -0
  74. package/src/review-api/document-headings.ts +58 -27
  75. package/src/review-api/document-text.ts +31 -2
  76. package/src/review-api/document.ts +362 -67
  77. package/src/review-api/foreign-references.ts +328 -0
  78. package/src/review-api/github-provider.ts +51 -13
  79. package/src/review-api/http.ts +734 -135
  80. package/src/review-api/instructions.ts +0 -19
  81. package/src/review-api/link-preview.ts +113 -0
  82. package/src/review-api/local-data.ts +310 -46
  83. package/src/review-api/mcp.ts +7 -13
  84. package/src/review-api/onboarding.ts +1 -1
  85. package/src/review-api/profile.ts +37 -6
  86. package/src/review-api/public-tools.ts +24 -2
  87. package/src/review-api/pull-request.ts +82 -49
  88. package/src/review-api/request-origin.ts +10 -0
  89. package/src/review-api/review-progress.ts +61 -27
  90. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  91. package/src/review-api/store-migrations.ts +355 -0
  92. package/src/review-api/store-schema.ts +38 -29
  93. package/src/review-api/store.ts +2176 -495
  94. package/src/review-api/worktree-events.ts +3 -5
  95. package/src/review-api/worktree-source.ts +3 -7
  96. package/src/review-api/worktree-structural.integration.ts +72 -0
  97. package/src/review-stack.ts +15 -23
  98. package/src/runtime.ts +4 -30
  99. package/src/server/desktop-host.ts +6 -1
  100. package/src/server/desktop-server.ts +1 -36
  101. package/src/server/headless-host.ts +0 -2
  102. package/src/server/hono-http.ts +2 -1
  103. package/src/server/review-gateway-list.ts +3 -5
  104. package/src/server/review-gateway-memory.ts +1 -1
  105. package/src/server/review-lifecycle-telemetry.ts +2 -2
  106. package/src/server/review-server-core.ts +1 -5
  107. package/src/server/structural-comparisons.ts +47 -0
  108. package/src/sharing/export.ts +36 -7
  109. package/src/sharing/host.ts +34 -14
  110. package/src/sharing/import.ts +156 -24
  111. package/src/sharing/repository.ts +143 -24
  112. package/src/startup-trace.ts +3 -3
  113. package/src/stored-document-migration.ts +110 -19
  114. package/src/ui-telemetry-events.ts +44 -18
  115. package/dist/agent-cli-C6SPSII0.js +0 -2
  116. package/dist/profile-CYPmvBoA.js +0 -2
  117. package/dist/runtime-CKiWhGrf.js +0 -52
  118. package/instructions/scratchpad.md +0 -23
  119. package/src/review-preferences.ts +0 -88
  120. package/src/server/legacy-tutorial-cleanup.ts +0 -38
@@ -1,8 +1,6 @@
1
- import { execFile } from "node:child_process";
2
1
  import { isAbsolute, relative } from "node:path";
3
- import { promisify } from "node:util";
4
2
 
5
- const exec = promisify(execFile);
3
+ import { runVcs } from "@dev.fast/local-vcs";
6
4
 
7
5
  const normalize = (name: string) => name.replaceAll("\\", "/").normalize("NFC");
8
6
 
@@ -53,7 +51,7 @@ export async function ignoredDirectories(
53
51
  gitDirectory?: string,
54
52
  ): Promise<string[]> {
55
53
  try {
56
- const { stdout } = await exec(
54
+ const { stdout } = await runVcs(
57
55
  "git",
58
56
  [
59
57
  "-C",
@@ -105,7 +103,7 @@ export async function gitDirectoryPrefix(
105
103
  common: string,
106
104
  ): Promise<string | null> {
107
105
  try {
108
- const { stdout } = await exec("git", [
106
+ const { stdout } = await runVcs("git", [
109
107
  "-C",
110
108
  rootPath,
111
109
  "rev-parse",
@@ -1,9 +1,7 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash } from "node:crypto";
3
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
- import { promisify } from "node:util";
5
3
 
6
- import { type LocalVcs, gitCommonDir } from "@dev.fast/local-vcs";
4
+ import { type LocalVcs, gitCommonDir, runVcs } from "@dev.fast/local-vcs";
7
5
  import { isMissingFileError } from "@review/fs-utils.js";
8
6
 
9
7
  import { checkoutFs } from "./checkout-fs.js";
@@ -11,8 +9,6 @@ import { ReviewInputError } from "./document.js";
11
9
 
12
10
  const { lstat, readFile, readlink, realpath } = checkoutFs;
13
11
 
14
- const exec = promisify(execFile);
15
-
16
12
  export const EMPTY_SOURCE = "empty-worktree-baseline";
17
13
 
18
14
  /** Never follow a working-copy link outside the selected checkout. */
@@ -49,7 +45,7 @@ export async function workingFiles(vcs: LocalVcs): Promise<string[]> {
49
45
  "Working source requires a Git-backed jj repository.",
50
46
  );
51
47
 
52
- const { stdout } = await exec(
48
+ const { stdout } = await runVcs(
53
49
  "git",
54
50
  [
55
51
  "-C",
@@ -100,7 +96,7 @@ export async function untrackedFileCount(
100
96
  if (vcs.kind !== "git") return undefined;
101
97
 
102
98
  try {
103
- const { stdout } = await exec(
99
+ const { stdout } = await runVcs(
104
100
  "git",
105
101
  [
106
102
  "-C",
@@ -318,3 +318,75 @@ it("compares a staged file in an unborn repository with empty source", async ()
318
318
  rmSync(root, { recursive: true, force: true });
319
319
  }
320
320
  }, 30_000);
321
+
322
+ it("shows a peek on an untracked file as head lines", async () => {
323
+ const root = mkdtempSync(path.join(tmpdir(), "worktree-untracked-"));
324
+ const repository = path.join(root, "repository");
325
+ mkdirSync(repository);
326
+
327
+ const git = (...args: string[]) =>
328
+ execFileSync("git", ["-C", repository, ...args], {
329
+ encoding: "utf8",
330
+ }).trim();
331
+
332
+ git("init", "-q", "-b", "main");
333
+ writeFileSync(path.join(repository, "value.ts"), "export const value = 1;\n");
334
+ git("add", "value.ts");
335
+ git(
336
+ "-c",
337
+ "user.name=Test",
338
+ "-c",
339
+ "user.email=test@example.invalid",
340
+ "commit",
341
+ "-qm",
342
+ "Base",
343
+ );
344
+ writeFileSync(path.join(repository, "new.ts"), "export const a = 1;\n");
345
+
346
+ const local = openLocalReviewStore(path.join(root, "reviews.db"));
347
+
348
+ const selection = {
349
+ file: "new.ts",
350
+ start: { side: "head" as const, line: 1 },
351
+ end: { side: "head" as const, line: 1 },
352
+ };
353
+
354
+ try {
355
+ const { id } = await local.data.register(repository);
356
+
357
+ const { reviewId } = await local.store.execute({
358
+ operation: {
359
+ type: "create",
360
+ title: "Live",
361
+ target: { kind: "worktree", repositoryId: id },
362
+ },
363
+ });
364
+
365
+ await local.store.execute({
366
+ operation: {
367
+ type: "edit",
368
+ reviewId,
369
+ edit: {
370
+ type: "insert",
371
+ content: { type: "code_peek", source: formatAnchor(selection) },
372
+ },
373
+ },
374
+ });
375
+
376
+ const progress = await (
377
+ await createReviewApi(local.store, local.data).request(
378
+ `/${reviewId}/progress`,
379
+ )
380
+ ).json();
381
+
382
+ expect(progress.untrackedFiles).toBe(1);
383
+ expect(progress.unavailableSelections).toEqual({});
384
+ expect(progress.resolvedSelections[selectionKey(selection)]).toEqual([
385
+ { file: "new.ts", side: "head", fromLine: 1, toLine: 1 },
386
+ ]);
387
+ } finally {
388
+ await local.data.close();
389
+ await local.store.close();
390
+ rmSync(root, { recursive: true, force: true });
391
+ }
392
+ });
@@ -1,15 +1,10 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- import { defaultBranch, git } from "@dev.fast/local-vcs";
1
+ import { defaultBranch, git, runVcs } from "@dev.fast/local-vcs";
5
2
  import type {
6
3
  ReviewApiSummary,
7
4
  ReviewStackLayer,
8
5
  } from "@dev.fast/review-protocol";
9
6
  import { z } from "zod";
10
7
 
11
- const execFileAsync = promisify(execFile);
12
-
13
8
  const GitHubStacksSchema = z.array(
14
9
  z.object({
15
10
  pull_requests: z.array(
@@ -27,7 +22,7 @@ type Subject = Pick<ReviewApiSummary, "origin"> & { reviewId?: string };
27
22
 
28
23
  type Candidate = Pick<
29
24
  ReviewApiSummary,
30
- "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "repositoryGroup"
25
+ "reviewId" | "title" | "origin" | "pins" | "trashedAt" | "project"
31
26
  >;
32
27
 
33
28
  interface StackEntry {
@@ -140,7 +135,7 @@ async function localStack(
140
135
  repositoryPath: string,
141
136
  ): Promise<StackEntry[]> {
142
137
  const group = reviews.find((review) => review.reviewId === subject.reviewId)
143
- ?.repositoryGroup?.key;
138
+ ?.project?.id;
144
139
 
145
140
  const current = subject.origin?.branch;
146
141
 
@@ -152,9 +147,7 @@ async function localStack(
152
147
 
153
148
  const name = (ref: string) => ref.replace(/^origin\//, "");
154
149
 
155
- const inGroup = reviews.filter(
156
- (review) => review.repositoryGroup?.key === group,
157
- );
150
+ const inGroup = reviews.filter((review) => review.project?.id === group);
158
151
 
159
152
  const latest = (field: "branch" | "baseBranch", branch: string) => {
160
153
  const matches = inGroup.filter(
@@ -208,9 +201,13 @@ async function localStack(
208
201
  async function forkPoint(repositoryPath: string, main: string, branch: string) {
209
202
  const ref = `refs/heads/${branch}`;
210
203
 
211
- const fork = await git(repositoryPath, ["merge-base", main, ref], {
212
- allowFailure: true,
213
- });
204
+ const fork = await git(
205
+ repositoryPath,
206
+ ["merge-base", { rev: main }, { rev: ref }],
207
+ {
208
+ allowFailure: true,
209
+ },
210
+ );
214
211
 
215
212
  if (!fork.ok) return null;
216
213
 
@@ -218,7 +215,7 @@ async function forkPoint(repositoryPath: string, main: string, branch: string) {
218
215
 
219
216
  const log = await git(
220
217
  repositoryPath,
221
- ["log", "--reverse", "--format=%s", `${base}..${ref}`],
218
+ ["log", "--reverse", "--format=%s", { rev: `${base}..${ref}` }],
222
219
  { allowFailure: true },
223
220
  );
224
221
 
@@ -229,15 +226,10 @@ async function defaultRunGitHubApi(
229
226
  host: string,
230
227
  endpoint: string,
231
228
  ): Promise<string> {
232
- const { stdout } = await execFileAsync(
229
+ const { stdout } = await runVcs(
233
230
  "gh",
234
- ["api", "--hostname", host, endpoint],
235
- {
236
- env: { ...process.env, GH_PROMPT_DISABLED: "1" },
237
- encoding: "utf8",
238
- timeout: 10_000,
239
- maxBuffer: 2 * 1024 * 1024,
240
- },
231
+ ["api", "--hostname", { value: host }, { value: endpoint }],
232
+ { timeout: 10_000, maxBuffer: 2 * 1024 * 1024 },
241
233
  );
242
234
 
243
235
  return stdout;
package/src/runtime.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { spawn } from "node:child_process";
2
1
  import path from "node:path";
3
2
 
3
+ import { runVcs } from "@dev.fast/local-vcs";
4
+
4
5
  export async function resolveReviewRoot(
5
6
  cwd: string,
6
7
  execFile: typeof execFilePromise = execFilePromise,
@@ -39,36 +40,9 @@ export async function resolveReviewRoot(
39
40
  }
40
41
 
41
42
  function execFilePromise(
42
- file: string,
43
+ file: "git" | "jj",
43
44
  args: string[],
44
45
  options: { cwd?: string } = {},
45
46
  ): Promise<{ stdout: string }> {
46
- return execFileUntraced(file, args, options);
47
- }
48
-
49
- function execFileUntraced(
50
- file: string,
51
- args: string[],
52
- options: { cwd?: string } = {},
53
- ): Promise<{ stdout: string }> {
54
- return new Promise((resolve, reject) => {
55
- const child = spawn(file, args, {
56
- cwd: options.cwd,
57
- stdio: ["ignore", "pipe", "pipe"],
58
- });
59
-
60
- let stdout = "";
61
- let stderr = "";
62
- child.stdout?.on("data", (chunk) => {
63
- stdout += String(chunk);
64
- });
65
- child.stderr?.on("data", (chunk) => {
66
- stderr += String(chunk);
67
- });
68
- child.on("error", reject);
69
- child.on("exit", (code) => {
70
- if (code === 0) resolve({ stdout });
71
- else reject(new Error(stderr.trim() || `${file} exited with ${code}`));
72
- });
73
- });
47
+ return runVcs(file, args, options);
74
48
  }
@@ -84,6 +84,8 @@ export async function runDesktopHost(
84
84
  const { local, shared, server } = await takeOverServerHome(home, async () => {
85
85
  const local = await openReviewProfile(home, {
86
86
  manageWorkspaces: true,
87
+ // Desktop shows each PR's state beside its board.
88
+ pullRequestStates: true,
87
89
  });
88
90
 
89
91
  // JSON is the sole user-review store. A failure is surfaced, never replaced
@@ -197,9 +199,12 @@ function requiredPositiveInteger(
197
199
 
198
200
  if (process.env.DEV_FAST_REVIEW_DESKTOP_HOST_AUTOSTART !== "0") {
199
201
  void runDesktopHost().catch((error) => {
202
+ // Exit now: a host that couldn't start (a store that couldn't be
203
+ // upgraded) must not linger with timers alive, or Desktop waits out its
204
+ // ready timeout instead of reporting why.
200
205
  process.stderr.write(
201
206
  `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
207
+ () => process.exit(1),
202
208
  );
203
- process.exitCode = 1;
204
209
  });
205
210
  }
@@ -47,10 +47,6 @@ import {
47
47
  reviewInstanceDiscoveryPath,
48
48
  reviewLegacyDiscoveryPath,
49
49
  } from "@review/review-home-paths";
50
- import {
51
- readScratchpadEnabled,
52
- writeScratchpadEnabled,
53
- } from "@review/review-preferences";
54
50
  import {
55
51
  ReviewTelemetry,
56
52
  type ReviewTelemetryContext,
@@ -82,7 +78,6 @@ import {
82
78
  captureReviewUiEvent,
83
79
  createJsonReviewReporting,
84
80
  } from "./json-review-reporting";
85
- import { removeLegacyTutorial } from "./legacy-tutorial-cleanup";
86
81
  import { createReviewGateway } from "./review-gateway";
87
82
  import { reviewLifecycleTelemetry } from "./review-lifecycle-telemetry";
88
83
  import { ReviewOpenWatchdog } from "./review-open-watchdog";
@@ -176,10 +171,6 @@ export function createGlobalReviewServer(
176
171
  let closing = false;
177
172
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
178
173
 
179
- // The scratchpad preference, read once at listen and kept current by the
180
- // settings endpoint below: this server is the only writer while it runs.
181
- let scratchpadEnabled = false;
182
-
183
174
  const discovery: ReviewDesktopDiscovery = {
184
175
  version: REVIEW_DESKTOP_DISCOVERY_VERSION,
185
176
  instanceId,
@@ -212,7 +203,6 @@ export function createGlobalReviewServer(
212
203
  relay,
213
204
  token,
214
205
  instanceId,
215
- scratchpad: () => scratchpadEnabled,
216
206
  status: () => {
217
207
  const { key, channel, checkout, appVersion, cliVersion, instanceId } =
218
208
  discovery;
@@ -265,25 +255,6 @@ export function createGlobalReviewServer(
265
255
  app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
266
256
  app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
267
257
  app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
268
- app.get("/preferences/scratchpad", () =>
269
- serverJson(200, { enabled: scratchpadEnabled }),
270
- );
271
- app.put("/preferences/scratchpad", async (context) => {
272
- const request = z
273
- .object({ enabled: z.boolean() })
274
- .safeParse(await readBoundedRequestJson(context.req.raw));
275
-
276
- if (!request.success)
277
- throw new ReviewServerError("enabled must be a boolean.", 400);
278
-
279
- scratchpadEnabled = await writeScratchpadEnabled(request.data.enabled);
280
-
281
- // Home watches the catalog; the pad appears or goes without a store write.
282
- if (scratchpadEnabled) await reviewStore.ensureScratchpad();
283
- reviewStore.invalidateCatalog();
284
-
285
- return serverJson(200, { enabled: scratchpadEnabled });
286
- });
287
258
  app.post("/app/focus", async () => {
288
259
  const result = await relay.dispatch({
289
260
  name: "focusWindow",
@@ -521,11 +492,6 @@ export function createGlobalReviewServer(
521
492
  },
522
493
  listen: async () => {
523
494
  await migrateDiffrConfig();
524
- await removeLegacyTutorial({
525
- store: reviewStore,
526
- data: input.reviewData,
527
- });
528
- scratchpadEnabled = await readScratchpadEnabled();
529
495
  boundPort = await listen(httpServer, input.port);
530
496
  discovery.url = urlForBoundPort();
531
497
 
@@ -583,8 +549,7 @@ export function sessionStartedSource(
583
549
  try {
584
550
  const snapshot = reviewStore.read(reviewUuid);
585
551
 
586
- const sourceKind =
587
- snapshot.kind === "scratchpad" ? snapshot.kind : snapshot.target?.kind;
552
+ const sourceKind = snapshot.target?.kind;
588
553
 
589
554
  const pullRequestUrl = snapshot.origin?.pullRequestUrl;
590
555
 
@@ -221,8 +221,6 @@ async function serve(input: HeadlessServerInput) {
221
221
  token: discovery.token,
222
222
  instanceId: discovery.instanceId,
223
223
  softwareMapEnabled: input.softwareMapEnabled,
224
- // The scratchpad is the laptop's alone, even with a Desktop attached.
225
- scratchpad: () => false,
226
224
  status: () => ({ key: "headless", home: input.stateDir }),
227
225
  ask: {
228
226
  tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
@@ -7,6 +7,7 @@ import {
7
7
  parseJsonText,
8
8
  } from "@dev.fast/review-protocol";
9
9
  import { type HttpBindings, getRequestListener } from "@hono/node-server";
10
+ import { REVIEW_PERSON_HEADER } from "@review/review-api/request-origin";
10
11
  import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
11
12
  import type { Hono } from "hono";
12
13
  import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -73,7 +74,7 @@ export function applyCorsHeaders(
73
74
 
74
75
  response.headers.set(
75
76
  "access-control-allow-headers",
76
- `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`,
77
+ `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`,
77
78
  );
78
79
  response.headers.set(
79
80
  "access-control-allow-methods",
@@ -32,11 +32,9 @@ function decorate(
32
32
 
33
33
  return {
34
34
  ...entry,
35
- ...(entry.repositoryGroup && {
36
- repositoryGroup: {
37
- key: `${alias}:${entry.repositoryGroup.key}`,
38
- label: entry.repositoryGroup.label,
39
- },
35
+ // A project on another machine is that machine's.
36
+ ...(entry.project && {
37
+ project: { id: `${alias}:${entry.project.id}`, name: entry.project.name },
40
38
  }),
41
39
  host: alias,
42
40
  hostState,
@@ -16,7 +16,7 @@ const listEntrySchema = z.looseObject({
16
16
  repositoryName: z.string(),
17
17
  viewedAt: z.string().nullable(),
18
18
  trashedAt: z.string().nullable(),
19
- repositoryGroup: z.object({ key: z.string(), label: z.string() }).optional(),
19
+ project: z.object({ id: z.string(), name: z.string() }).optional(),
20
20
  });
21
21
 
22
22
  export const listEntriesSchema = z.array(z.unknown()).transform(
@@ -34,12 +34,12 @@ export function reviewLifecycleTelemetry(
34
34
  );
35
35
 
36
36
  return {
37
- onReviewCreated: ({ reviewId, kind, blocks, via, agentKind }) => {
37
+ onReviewCreated: ({ reviewId, blocks, via, agentKind }) => {
38
38
  if (reported.has(reviewId)) return;
39
39
  reported.add(reviewId);
40
40
 
41
41
  if (via === "api" || via === "mcp") authoring.set(reviewId, agentKind);
42
- const properties: PostHogCaptureProperties = { kind, blocks, via };
42
+ const properties: PostHogCaptureProperties = { blocks, via };
43
43
 
44
44
  if (agentKind) properties.agent_kind = agentKind;
45
45
  capture("review_review_created", properties, reviewId);
@@ -132,7 +132,6 @@ export interface WhiteboardCoreInput {
132
132
  token: string;
133
133
  instanceId: string;
134
134
  softwareMapEnabled?: boolean;
135
- scratchpad: () => boolean;
136
135
  status: () => JsonObject;
137
136
  hooks?: ReviewApiHooks;
138
137
  ask?: {
@@ -192,7 +191,6 @@ export function createWhiteboardCore(input: WhiteboardCoreInput) {
192
191
  callbacks.open,
193
192
  shared,
194
193
  callbacks.capabilities,
195
- input.scratchpad,
196
194
  () => traceMachineEnabled(),
197
195
  input.status,
198
196
  input.hooks,
@@ -236,9 +234,7 @@ export function relayReviewCallbacks(
236
234
 
237
235
  return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
238
236
  },
239
- async capabilities(): Promise<
240
- Omit<AuthoringCapabilities, "scratchpadEnabled">
241
- > {
237
+ async capabilities(): Promise<AuthoringCapabilities> {
242
238
  if (!relay.attached)
243
239
  return { desktopAvailable: false, softwareMapEnabled };
244
240
 
@@ -1,3 +1,8 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { git, gitCommonDir } from "@dev.fast/local-vcs";
1
6
  import type { StructuralDiffEvent } from "@dev.fast/review-protocol";
2
7
 
3
8
  import {
@@ -18,6 +23,7 @@ export class StructuralComparisons {
18
23
 
19
24
  const key = JSON.stringify([
20
25
  configGeneration,
26
+ await classificationStamp(input.repositoryPath),
21
27
  input.repositoryPath,
22
28
  input.comparison,
23
29
  input.paths,
@@ -66,6 +72,47 @@ export class StructuralComparisons {
66
72
 
67
73
  let configGeneration = 0;
68
74
 
75
+ const classificationFiles = new Map<string, Promise<string[]>>();
76
+
77
+ /** Git attribute and diffr config files that diffr's classifier reads. */
78
+ function classificationInputs(root: string): Promise<string[]> {
79
+ let files = classificationFiles.get(root);
80
+
81
+ if (!files) {
82
+ const config =
83
+ process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config");
84
+
85
+ const diffrDirectory =
86
+ process.env.DIFFR_CONFIG_DIR || path.join(config, "diffr");
87
+
88
+ files = Promise.all([
89
+ gitCommonDir(root),
90
+ git(root, ["config", "--path", "core.attributesFile"], {
91
+ allowFailure: true,
92
+ }),
93
+ ]).then(([common, user]) => [
94
+ ...(common ? [path.join(common, "info", "attributes")] : []),
95
+ path.join(root, ".gitattributes"),
96
+ user.stdout.trim() || path.join(config, "git", "attributes"),
97
+ path.join(diffrDirectory, "config.toml"),
98
+ ]);
99
+ classificationFiles.set(root, files);
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ async function classificationStamp(root: string) {
106
+ return Promise.all(
107
+ (await classificationInputs(root)).map((file) =>
108
+ stat(file).then(
109
+ ({ mtimeMs, size }) => [mtimeMs, size],
110
+ () => null,
111
+ ),
112
+ ),
113
+ );
114
+ }
115
+
69
116
  export function invalidateStructuralComparisons(): void {
70
117
  configGeneration++;
71
118
  }
@@ -14,10 +14,11 @@ import { lensSelections } from "@review/review-api/diff-lenses.js";
14
14
  import { ReviewInputError } from "@review/review-api/document.js";
15
15
  import {
16
16
  anchorPins,
17
+ boardRepositoryId,
17
18
  checkReferences,
18
19
  documentSchema,
19
20
  elements,
20
- hasCodeReferences,
21
+ readsAtBoardPins,
21
22
  resourceReference,
22
23
  resourceReferences,
23
24
  sourceReferences,
@@ -46,13 +47,33 @@ export function digestBytes(bytes: Uint8Array): string {
46
47
  export function validateShareSources(
47
48
  snapshot: Pick<Snapshot, "pins" | "document" | "lenses">,
48
49
  ) {
49
- if (!snapshot.pins && hasCodeReferences(snapshot))
50
+ if (!snapshot.pins && readsAtBoardPins(snapshot))
50
51
  throw new ReviewInputError(
51
52
  "Reviews with code references need source pins before sharing.",
52
53
  409,
53
54
  );
54
55
  }
55
56
 
57
+ /** The commits a board's references read at besides its base and head. A
58
+ * recipient fetches them too. A shared board names no repository of its
59
+ * own, so one stands in for its commit links. */
60
+ export function referenceCommits(
61
+ snapshot: Pick<Snapshot, "pins" | "document" | "target">,
62
+ repositoryId = boardRepositoryId(snapshot) ?? "shared",
63
+ ): string[] {
64
+ const own = new Set([snapshot.pins?.base, snapshot.pins?.head]);
65
+ const commits = new Set<string>();
66
+
67
+ for (const { source } of sourceReferences(snapshot.document, {
68
+ tolerant: true,
69
+ repositoryId,
70
+ }))
71
+ for (const commit of [source.pins?.base, source.pins?.head])
72
+ if (commit && !own.has(commit)) commits.add(commit);
73
+
74
+ return [...commits].sort();
75
+ }
76
+
56
77
  /** Freeze one stored version before any asynchronous source reads. */
57
78
  export async function exportShare(input: {
58
79
  store: ShareExportStore;
@@ -61,16 +82,24 @@ export async function exportShare(input: {
61
82
  version?: number;
62
83
  repository?: ShareManifest["repository"];
63
84
  }): Promise<ShareBundle> {
64
- // A share is the finished document: what edit last touched it stays home.
85
+ const stored = structuredClone(
86
+ input.store.read(input.reviewId, input.version),
87
+ );
88
+
89
+ // Commit links read from the board's repository, which a plan names only
90
+ // in its target.
91
+ const repositoryId = boardRepositoryId(stored);
92
+
93
+ // Local-only fields stay home; the target names a local checkout.
65
94
  const {
66
95
  target,
67
- kind: _kind,
68
96
  staleSources: _staleSources,
69
97
  sourceUnavailable: _sourceUnavailable,
70
98
  lastEdit: _lastEdit,
71
99
  trashedAt: _trashedAt,
100
+ personEdit: _personEdit,
72
101
  ...snapshot
73
- } = structuredClone(input.store.read(input.reviewId, input.version));
102
+ } = stored;
74
103
 
75
104
  if (target?.kind === "worktree")
76
105
  throw new ReviewInputError("Pin this review to commits before sharing it.");
@@ -102,7 +131,7 @@ export async function exportShare(input: {
102
131
 
103
132
  // Lens ranges travel with the snapshot and must resolve at its pins too.
104
133
  const sources = [
105
- ...sourceReferences(snapshot.document),
134
+ ...sourceReferences(snapshot.document, { repositoryId }),
106
135
  ...lensSelections(snapshot.lenses ?? []).flatMap((selection) =>
107
136
  sourceAnchors(selection.source).map((source) => ({
108
137
  ...selection,
@@ -116,7 +145,7 @@ export async function exportShare(input: {
116
145
 
117
146
  validateShareSources(snapshot);
118
147
 
119
- if (Boolean(pins) !== Boolean(input.repository))
148
+ if ((pins || referenceCommits(stored).length) && !input.repository)
120
149
  throw new ReviewInputError("Shared source pins require a repository.", 409);
121
150
 
122
151
  for (const block of elements(snapshot.document)) {