@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

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 (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -29,17 +29,9 @@ import { type ToolFailure, toolFailure } from "./tool-failure.js";
29
29
  const RELOAD_TOOLS =
30
30
  "Whiteboard is running now, but this session listed Whiteboard's tools before it started, so you may see only session_get_instructions. Before authoring, reload the `whiteboard` MCP server's tools (reconnect it in your agent, or start a new agent session). If you can already see tools such as session_create, carry on.";
31
31
 
32
- function mcpAuthoringGuidance(context: {
33
- scratchpadAvailable: boolean;
34
- traceEnabled: boolean;
35
- }): string {
32
+ function mcpAuthoringGuidance(context: { traceEnabled: boolean }): string {
36
33
  return [
37
34
  "Whiteboard explains code in documents the user reads in Whiteboard Desktop. Call session_get_instructions before creating or editing a Whiteboard and follow it. Read session_capabilities before authoring; session_create opens the new review in Desktop when it is available, so call session_open only for an existing review, and generate software maps only when softwareMapEnabled is true. When the user asks for a Whiteboard or to use Whiteboard (for example to review a branch, a change or a pull request, or to explain a system in Whiteboard), author a Whiteboard with the default topic.",
38
- ...(context.scratchpadAvailable
39
- ? [
40
- 'When the user asks in conversation to be shown how code works or wants a diagram, without asking for a Whiteboard, call session_capabilities; if it reports scratchpadEnabled and desktopAvailable, draw on the Whiteboard scratchpad rather than answering only in chat, starting with session_get_instructions({topic:"scratchpad"}).',
41
- ]
42
- : []),
43
35
  ...(context.traceEnabled
44
36
  ? [
45
37
  'For why code exists, what an agent was thinking, or whether an agent solved something before, call session_get_instructions({topic:"trace-archaeology"}).',
@@ -66,7 +58,7 @@ export async function serveReviewMcp(
66
58
  environmentAgent?: ReviewSessionAgent,
67
59
  ) {
68
60
  const instructionsTool = {
69
- ...authoringTools(false, traceEnabled).find(
61
+ ...authoringTools(traceEnabled).find(
70
62
  (tool) => tool.name === "review_get_instructions",
71
63
  )!,
72
64
  name: "session_get_instructions",
@@ -135,7 +127,7 @@ export async function serveReviewMcp(
135
127
  // guidance on the discovery tool instead, including before Desktop starts.
136
128
  description:
137
129
  name === instructionsTool.name
138
- ? `${mcpAuthoringGuidance({ scratchpadAvailable: true, traceEnabled })}\n\n${description}`
130
+ ? `${mcpAuthoringGuidance({ traceEnabled })}\n\n${description}`
139
131
  : description,
140
132
  inputSchema,
141
133
  }),
@@ -462,7 +462,7 @@ export function registerOnboardingRoutes(
462
462
  agent: input.agent,
463
463
  ...host.historyHooks({
464
464
  id,
465
- reviewId,
465
+ anchor: { board: reviewId },
466
466
  agent: input.agent,
467
467
  version: created.version,
468
468
  head,
@@ -4,8 +4,11 @@ import { DatabaseSync } from "node:sqlite";
4
4
 
5
5
  import { withFileLock } from "@dev.fast/trace-core";
6
6
 
7
+ import { CheckoutGroups } from "./checkout-groups.js";
7
8
  import { openLocalReviewStore } from "./local-data.js";
9
+ import { groupCheckoutsAhead } from "./store-migrations.js";
8
10
  import { initializeReviewStoreSchema } from "./store-schema.js";
11
+ import { projectOfCheckout } from "./store.js";
9
12
 
10
13
  const lockOptions = {
11
14
  retryMs: 50,
@@ -16,7 +19,7 @@ const lockOptions = {
16
19
 
17
20
  export async function openReviewProfile(
18
21
  home: string,
19
- options: { manageWorkspaces: boolean },
22
+ options: { manageWorkspaces: boolean; pullRequestStates?: boolean },
20
23
  ) {
21
24
  await mkdir(home, { recursive: true, mode: 0o700 });
22
25
 
@@ -30,7 +33,12 @@ export async function openReviewProfile(
30
33
  ])
31
34
  await importHeadlessStore(home, source);
32
35
 
33
- return openLocalReviewStore(path.join(home, "review-api.db"), options);
36
+ const database = path.join(home, "review-api.db");
37
+
38
+ return openLocalReviewStore(database, {
39
+ ...options,
40
+ checkoutOwners: await groupCheckoutsAhead(database),
41
+ });
34
42
  },
35
43
  );
36
44
 
@@ -89,7 +97,7 @@ async function importHeadlessStore(home: string, source: string) {
89
97
  `Cannot merge ${source}: the shared store already contains one of its review IDs. Both databases are unchanged.`,
90
98
  );
91
99
  database.exec(`
92
- INSERT INTO repositories SELECT s.* FROM headless.repositories s WHERE NOT EXISTS (SELECT 1 FROM repositories t WHERE t.path=s.path);
100
+ INSERT INTO repositories(id,path,name) SELECT s.id,s.path,s.name FROM headless.repositories s WHERE NOT EXISTS (SELECT 1 FROM repositories t WHERE t.path=s.path);
93
101
  CREATE TEMP TABLE repository_ids AS SELECT s.id old_id,t.id new_id FROM headless.repositories s JOIN repositories t ON s.path=t.path;
94
102
  `);
95
103
 
@@ -114,13 +122,36 @@ async function importHeadlessStore(home: string, source: string) {
114
122
  `Cannot merge ${source}: a resource ID has different content. Both databases are unchanged.`,
115
123
  );
116
124
 
125
+ if (
126
+ database
127
+ .prepare(
128
+ "SELECT 1 FROM pragma_table_info('reviews') WHERE name='project_id'",
129
+ )
130
+ .get()
131
+ ) {
132
+ // A store with projects: file each board under its repository's,
133
+ // grouping the checkouts once for all of them.
134
+ const groups = new CheckoutGroups(database);
135
+
136
+ for (const { new_id: repositoryId } of database
137
+ .prepare("SELECT new_id FROM repository_ids")
138
+ .all())
139
+ projectOfCheckout(database, String(repositoryId), groups);
140
+ database.exec(`INSERT INTO reviews(id,version,next_id,project_id) SELECT s.id,s.version,s.next_id,p.project_id
141
+ FROM headless.reviews s JOIN headless.versions v ON v.review_id=s.id AND v.version=s.version
142
+ JOIN repository_ids r ON json_extract(v.snapshot,'$.pins.repositoryId')=r.old_id
143
+ JOIN repositories p ON p.id=r.new_id;`);
144
+ } else
145
+ database.exec(
146
+ "INSERT INTO reviews(id,version,next_id) SELECT id,version,next_id FROM headless.reviews;",
147
+ );
117
148
  database.exec(`
118
149
  INSERT OR IGNORE INTO resources SELECT s.id,r.new_id,s.kind,s.mime_type,s.data FROM headless.resources s JOIN repository_ids r ON s.repository_id=r.old_id;
119
- INSERT INTO reviews SELECT * FROM headless.reviews;
120
- INSERT INTO versions SELECT s.review_id,s.version,
150
+ INSERT INTO versions(review_id,version,snapshot,thread_id,author_kind) SELECT s.review_id,s.version,
121
151
  CASE WHEN json_type(s.snapshot,'$.target')='object'
122
152
  THEN json_set(s.snapshot,'$.pins.repositoryId',r.new_id,'$.target.repositoryId',r.new_id)
123
- ELSE json_set(s.snapshot,'$.pins.repositoryId',r.new_id) END
153
+ ELSE json_set(s.snapshot,'$.pins.repositoryId',r.new_id) END,
154
+ NULL, NULL
124
155
  FROM headless.versions s JOIN repository_ids r ON json_extract(s.snapshot,'$.pins.repositoryId')=r.old_id;
125
156
  INSERT INTO review_attention SELECT * FROM headless.review_attention;
126
157
  `);
@@ -1,4 +1,5 @@
1
1
  import { type JsonValue, isJsonObject, isStringValue } from "@dev.fast/json";
2
+ import { boardTitle } from "@dev.fast/review-protocol";
2
3
 
3
4
  import {
4
5
  type AuthoringTool,
@@ -78,7 +79,13 @@ export function publicResult(value: JsonValue): JsonValue {
78
79
 
79
80
  return [
80
81
  name,
81
- key === "review" || key === "reviews" ? publicResult(item) : item,
82
+ key === "review" || key === "reviews"
83
+ ? publicResult(item)
84
+ : // A board's title as every surface shows it: an empty one is
85
+ // "Untitled".
86
+ key === "title" && "reviewId" in value && isStringValue(item)
87
+ ? boardTitle(item)
88
+ : item,
82
89
  ];
83
90
  }),
84
91
  );
@@ -98,5 +105,20 @@ export async function callPublicTool(
98
105
  if (sessionId !== undefined) fields.reviewId = sessionId;
99
106
  const result = await callAuthoringTool(client, tool, fields, signal);
100
107
 
101
- return result instanceof ToolText ? result : publicResult(result);
108
+ return result instanceof ToolText
109
+ ? result
110
+ : withoutRepositoryIds(publicResult(result));
111
+ }
112
+
113
+ /** Agents never see repository ids. */
114
+ function withoutRepositoryIds(value: JsonValue): JsonValue {
115
+ if (Array.isArray(value)) return value.map(withoutRepositoryIds);
116
+
117
+ if (!isJsonObject(value)) return value;
118
+
119
+ return Object.fromEntries(
120
+ Object.entries(value)
121
+ .filter(([key]) => key !== "repositoryId")
122
+ .map(([key, item]) => [key, withoutRepositoryIds(item)]),
123
+ );
102
124
  }
@@ -1,18 +1,29 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
-
4
- import type { LocalVcsKind } from "@dev.fast/local-vcs";
3
+ import { promisify } from "node:util";
4
+
5
+ import {
6
+ type LocalVcsKind,
7
+ type VcsArg,
8
+ renderVcsArgs,
9
+ runVcs,
10
+ } from "@dev.fast/local-vcs";
11
+ import type { PullRequestState } from "@dev.fast/review-protocol";
5
12
  import { errorMessage } from "@dev.fast/trace-core";
6
13
 
7
14
  import { ReviewInputError } from "./document.js";
8
15
 
9
- /** Runs one subprocess and resolves its stdout; rejects on failure or timeout. */
16
+ /** Runs one subprocess and resolves its stdout; rejects on failure or
17
+ * timeout. git, jj and gh go through the VCS runner; Azure DevOps's az (by
18
+ * way of cmd.exe on Windows) gets its operands checked the same way. */
10
19
  export type RunCommand = (
11
- file: string,
12
- args: string[],
20
+ file: "git" | "jj" | "gh" | "az" | "cmd.exe",
21
+ args: VcsArg[],
13
22
  options: { cwd?: string; timeoutMs: number },
14
23
  ) => Promise<string>;
15
24
 
25
+ const execFileAsync = promisify(execFile);
26
+
16
27
  export interface PullRequestDeps {
17
28
  run: RunCommand;
18
29
  fetch: typeof fetch;
@@ -31,6 +42,7 @@ export interface PullRequestRecord {
31
42
  headBranch?: string;
32
43
  /** GitHub's base commit, frozen at the PR's last update. */
33
44
  baseRefOid?: string;
45
+ state?: PullRequestState;
34
46
  }
35
47
 
36
48
  const FETCH_TIMEOUT_MS = 120_000;
@@ -38,35 +50,38 @@ const FETCH_TIMEOUT_MS = 120_000;
38
50
  const LOCAL_TIMEOUT_MS = 30_000;
39
51
 
40
52
  export const defaultPullRequestDeps: PullRequestDeps = {
41
- run: (file, args, options) =>
42
- new Promise((resolve, reject) => {
43
- execFile(
44
- file,
45
- args,
46
- {
47
- cwd: options.cwd,
48
- timeout: options.timeoutMs,
49
- maxBuffer: 16 * 1024 * 1024,
50
- // Never wait on a credential or confirmation prompt nobody can see.
51
- env: {
52
- ...process.env,
53
- GIT_TERMINAL_PROMPT: "0",
54
- GH_PROMPT_DISABLED: "1",
55
- GIT_SSH_COMMAND:
56
- process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
57
- },
58
- },
59
- (error, stdout, stderr) => {
60
- if (!error) return resolve(stdout);
61
-
62
- const reason = error.killed
63
- ? `timed out after ${options.timeoutMs / 1000}s`
64
- : String(stderr).trim() || error.message;
65
-
66
- reject(new Error(`${file} ${args[0]}: ${reason}`));
67
- },
68
- );
69
- }),
53
+ run: async (file, args, options) => {
54
+ const spawnOptions = {
55
+ cwd: options.cwd,
56
+ timeout: options.timeoutMs,
57
+ maxBuffer: 16 * 1024 * 1024,
58
+ // Never wait on ssh asking for a passphrase nobody can see.
59
+ env: {
60
+ ...process.env,
61
+ GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
62
+ },
63
+ };
64
+
65
+ try {
66
+ return (
67
+ file === "az" || file === "cmd.exe"
68
+ ? await execFileAsync(file, renderVcsArgs(args), {
69
+ ...spawnOptions,
70
+ encoding: "utf8",
71
+ })
72
+ : await runVcs(file, args, spawnOptions)
73
+ ).stdout;
74
+ } catch (error) {
75
+ // SAFETY: execFile rejects with an ExecFileException carrying stderr.
76
+ const failure = error as { killed?: boolean; stderr?: string };
77
+
78
+ const reason = failure.killed
79
+ ? `timed out after ${options.timeoutMs / 1000}s`
80
+ : String(failure.stderr ?? "").trim() || errorMessage(error);
81
+
82
+ throw new Error(`${file} ${String(args[0])}: ${reason}`);
83
+ }
84
+ },
70
85
  fetch: (input, init) => fetch(input, init),
71
86
  };
72
87
 
@@ -135,55 +150,62 @@ export async function fetchPullRequest(
135
150
  const { pullRequest: pr } = input;
136
151
  const refs = pullRequestRefs(input.provider, pr);
137
152
 
138
- const git = (args: string[], timeoutMs = LOCAL_TIMEOUT_MS) =>
153
+ const git = (args: VcsArg[], timeoutMs = LOCAL_TIMEOUT_MS) =>
139
154
  deps
140
155
  .run("git", ["--git-dir", input.gitDir, ...args], { timeoutMs })
141
156
  .then((stdout) => stdout.trim());
142
157
 
143
- const fetchRefs = (...refspecs: string[]) =>
158
+ const fetchRefs = (...refspecs: VcsArg[]) =>
144
159
  git(
145
160
  [
146
161
  "fetch",
147
162
  "--no-tags",
148
163
  "--no-write-fetch-head",
149
164
  "--quiet",
150
- input.remote,
165
+ { remote: input.remote },
151
166
  ...refspecs,
152
167
  ],
153
168
  FETCH_TIMEOUT_MS,
154
169
  );
155
170
 
156
171
  const commit = (ref: string) =>
157
- git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(
172
+ git(["rev-parse", "--verify", "--quiet", { rev: `${ref}^{commit}` }]).catch(
158
173
  () => undefined,
159
174
  );
160
175
 
161
176
  const mergeBase = (left: string, right: string) =>
162
- git(["merge-base", left, right]).catch(() => undefined);
177
+ git(["merge-base", { rev: left }, { rev: right }]).catch(() => undefined);
163
178
 
164
179
  const frozenBase = async () => {
165
180
  if (!pr.baseRefOid) return undefined;
166
181
 
167
182
  if (!(await commit(pr.baseRefOid)))
168
- await fetchRefs(`+${pr.baseRefOid}:${refs.frozenBase}`).catch(
169
- () => undefined,
170
- );
183
+ await fetchRefs({
184
+ refspec: { from: pr.baseRefOid, to: refs.frozenBase, force: true },
185
+ }).catch(() => undefined);
171
186
 
172
187
  return commit(pr.baseRefOid);
173
188
  };
174
189
 
175
190
  let baseTip: string | undefined;
176
191
 
192
+ const pullHead: VcsArg = {
193
+ refspec: { from: pr.headRef, to: refs.head, force: true },
194
+ };
195
+
177
196
  try {
178
- await fetchRefs(
179
- `+${pr.headRef}:${refs.head}`,
180
- `+refs/heads/${pr.baseRefName}:${refs.base}`,
181
- );
197
+ await fetchRefs(pullHead, {
198
+ refspec: {
199
+ from: `refs/heads/${pr.baseRefName}`,
200
+ to: refs.base,
201
+ force: true,
202
+ },
203
+ });
182
204
  baseTip = await commit(refs.base);
183
205
  } catch (error) {
184
206
  // The base branch may be gone; the head and the frozen base suffice.
185
207
  try {
186
- await fetchRefs(`+${pr.headRef}:${refs.head}`);
208
+ await fetchRefs(pullHead);
187
209
  } catch {
188
210
  throw new ReviewInputError(
189
211
  `Could not fetch PR #${pr.number} from remote "${input.remote}" (${pr.slug}). Check \`git fetch ${input.remote}\` works with your Git credentials, then retry. Git said: ${firstLine(errorMessage(error))}`,
@@ -232,7 +254,7 @@ export async function fetchPullRequest(
232
254
  async function indexForJj(
233
255
  rootPath: string,
234
256
  commits: string[],
235
- git: (args: string[]) => Promise<string>,
257
+ git: (args: VcsArg[]) => Promise<string>,
236
258
  deps: PullRequestDeps,
237
259
  ) {
238
260
  const tags = commits.map(() => `refs/tags/review-index-${randomUUID()}`);
@@ -246,7 +268,7 @@ async function indexForJj(
246
268
 
247
269
  try {
248
270
  for (const [index, tag] of tags.entries())
249
- await git(["update-ref", tag, commits[index]!]);
271
+ await git(["update-ref", { ref: tag }, { commit: commits[index]! }]);
250
272
  await jjImport();
251
273
  } finally {
252
274
  for (const tag of tags)
@@ -255,6 +277,17 @@ async function indexForJj(
255
277
  }
256
278
  }
257
279
 
280
+ /** GitHub refused a read, as it does past a rate limit; it takes reads
281
+ * again at `retryAt`. */
282
+ export class GitHubRefusedRead extends ReviewInputError {
283
+ constructor(
284
+ message: string,
285
+ readonly retryAt: number,
286
+ ) {
287
+ super(message, 409);
288
+ }
289
+ }
290
+
258
291
  export function firstLine(text: string) {
259
292
  return text.split("\n")[0]!.slice(0, 300);
260
293
  }
@@ -13,6 +13,16 @@ export const REVIEW_VIA_HEADER = "x-review-via";
13
13
 
14
14
  export const REVIEW_AGENT_HEADER = "x-review-agent";
15
15
 
16
+ /** The Ask thread a request comes from. The CLI reads it from ASK_THREAD_ENV,
17
+ * which Desktop sets for the thread's agent. */
18
+ export const REVIEW_THREAD_HEADER = "x-review-thread";
19
+
20
+ export const ASK_THREAD_ENV = "DEV_REVIEW_ASK_THREAD";
21
+
22
+ /** Set by Desktop and the canvas on a command a person gave, such as a new
23
+ * board from the sidebar; its version is credited to them. */
24
+ export const REVIEW_PERSON_HEADER = "x-review-person";
25
+
16
26
  export type ReviewRequestVia = "api" | "mcp" | "other";
17
27
 
18
28
  const viaSchema = z.enum(["api", "mcp", "other"]).catch("other");
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  type LensSource,
3
+ SelectionUnavailableError,
4
+ type SelectionUnavailableReason,
3
5
  comparisonKey,
4
6
  resolveDiffSelection,
5
7
  selectionKey,
@@ -15,8 +17,15 @@ import {
15
17
  UNCATEGORIZED_LENS_ID,
16
18
  lensSelections,
17
19
  } from "./diff-lenses.js";
18
- import { type Pins, anchorPins, selectionReferences } from "./document.js";
20
+ import {
21
+ type Pins,
22
+ anchorPins,
23
+ boardRepositoryId,
24
+ selectionReferences,
25
+ } from "./document.js";
19
26
  import { resolveFileLens, uncategorizedSources } from "./file-lenses.js";
27
+ import { ReviewInputError } from "./input-error.js";
28
+ import { textualRows } from "./lens-alignment.js";
20
29
  import type { LocalReviewData } from "./local-data.js";
21
30
  import type { ReviewStore, Snapshot } from "./store.js";
22
31
 
@@ -40,7 +49,7 @@ export const progressUpdateSchema = z.strictObject({
40
49
 
41
50
  export interface ReviewProgress {
42
51
  complete?: boolean;
43
- unavailableSelections?: Record<string, string>;
52
+ unavailableSelections?: Record<string, SelectionUnavailableReason>;
44
53
  files: CoverageFile[];
45
54
  /** Changed files of the comparisons that references with their own pins
46
55
  * named, by `comparisonKey` and then path. They are not the document's
@@ -130,7 +139,7 @@ export async function reviewProgress(
130
139
  const alignments = new Map(comparison.alignments);
131
140
  const resolvedSelections: Record<string, FileLineRange[]> = {};
132
141
 
133
- const unavailableSelections: Record<string, string> = {};
142
+ const unavailableSelections: Record<string, SelectionUnavailableReason> = {};
134
143
  const pendingSources = new Set<string>();
135
144
 
136
145
  const resolve = async (source: LensSource): Promise<FileLineRange[]> => {
@@ -169,7 +178,8 @@ export async function reviewProgress(
169
178
  let rows = file && ownAlignments.get(file.path);
170
179
 
171
180
  // Context-only files are absent from the changed-file stream. Read both pins
172
- // and require equality rather than inventing correspondence for missing diffs.
181
+ // and require equality rather than inventing correspondence for missing
182
+ // diffs; a file on one side only, such as an untracked one, is that side.
173
183
  if (!file && counting && !own) {
174
184
  pendingSources.add(key);
175
185
 
@@ -177,20 +187,43 @@ export async function reviewProgress(
177
187
  }
178
188
 
179
189
  if (!file) {
180
- const [base, head] = await Promise.all([
181
- data.file(sourcePins, "base", source.file),
182
- data.file(sourcePins, "head", source.file),
183
- ]);
184
-
185
- if (base.text !== head.text)
186
- throw new Error("Changed context file has no alignment.");
187
- rows = Array.from(
188
- { length: base.text.split("\n").length },
189
- (_, line) => [line, line] as const,
190
+ const [base, head] = await Promise.all(
191
+ (["base", "head"] as const).map((side) =>
192
+ data.file(sourcePins, side, source.file).then(
193
+ (value) => value.text,
194
+ (error) => {
195
+ if (error instanceof ReviewInputError && error.status === 404)
196
+ return null;
197
+ throw error;
198
+ },
199
+ ),
200
+ ),
201
+ );
202
+
203
+ if (base === null && head === null)
204
+ throw new SelectionUnavailableError(
205
+ "missing_file",
206
+ "File is unavailable at these pins.",
207
+ );
208
+
209
+ if (base !== null && head !== null && base !== head)
210
+ throw new SelectionUnavailableError(
211
+ "no_alignment",
212
+ "Changed context file has no alignment.",
213
+ );
214
+ rows = textualRows(
215
+ source.file,
216
+ "",
217
+ base?.split("\n").length ?? 0,
218
+ head?.split("\n").length ?? 0,
190
219
  );
191
220
  }
192
221
 
193
- if (!rows) throw new Error("File has no text alignment.");
222
+ if (!rows)
223
+ throw new SelectionUnavailableError(
224
+ "no_alignment",
225
+ "File has no text alignment.",
226
+ );
194
227
 
195
228
  const result = resolveDiffSelection(
196
229
  source,
@@ -206,9 +239,9 @@ export async function reviewProgress(
206
239
  const resolveAvailable = async (source: LensSource) => {
207
240
  try {
208
241
  return await resolve(source);
209
- } catch {
242
+ } catch (error) {
210
243
  unavailableSelections[selectionKey(source)] =
211
- "Source links need updating at these pins";
244
+ error instanceof SelectionUnavailableError ? error.reason : "other";
212
245
 
213
246
  return [];
214
247
  }
@@ -254,15 +287,16 @@ export async function reviewProgress(
254
287
 
255
288
  // Peeks share alignment resolution without claiming lens coverage.
256
289
  await Promise.all(
257
- selectionReferences(snapshot.document, { tolerant: true }).map(
258
- async ({ source }) => {
259
- try {
260
- await resolveAvailable(source);
261
- } catch {
262
- /* An unavailable peek does not hide other content. */
263
- }
264
- },
265
- ),
290
+ selectionReferences(snapshot.document, {
291
+ tolerant: true,
292
+ repositoryId: boardRepositoryId(snapshot),
293
+ }).map(async ({ source }) => {
294
+ try {
295
+ await resolveAvailable(source);
296
+ } catch {
297
+ /* An unavailable peek does not hide other content. */
298
+ }
299
+ }),
266
300
  );
267
301
 
268
302
  const uncategorized = counting
@@ -299,7 +333,7 @@ export async function reviewProgress(
299
333
  });
300
334
 
301
335
  const progress: ReviewProgress = {
302
- complete: !counting,
336
+ complete: !counting && !pendingSources.size,
303
337
  files,
304
338
  referenceFiles,
305
339
  lenses,