@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
@@ -17,7 +17,10 @@ import { ReviewInputError } from "./document.js";
17
17
 
18
18
  /** A saved Ask conversation: its history entry, and how to reopen it. */
19
19
  export const askRecordSchema = askHistoryEntrySchema.extend({
20
- reviewId: z.string(),
20
+ /** The board the conversation started on, and the block if any. */
21
+ anchor: z.object({ board: z.string(), block: z.string().optional() }),
22
+ /** Absent for a shared board. */
23
+ projectId: z.string().optional(),
21
24
  /** The ACP session the agent keeps the transcript under. */
22
25
  sessionId: z.string(),
23
26
  /** The review version asked about; its pins recreate the checkout. */
@@ -34,7 +37,9 @@ export type AskRecord = z.infer<typeof askRecordSchema>;
34
37
  const rowSchema = z
35
38
  .object({
36
39
  id: z.string(),
37
- review_id: z.string(),
40
+ anchor_board: z.string(),
41
+ anchor_block: z.string().nullable(),
42
+ project_id: z.string().nullable(),
38
43
  agent: z.string(),
39
44
  session_id: z.string(),
40
45
  version: z.number(),
@@ -51,7 +56,11 @@ const rowSchema = z
51
56
  .transform((row) =>
52
57
  askRecordSchema.parse({
53
58
  id: row.id,
54
- reviewId: row.review_id,
59
+ anchor:
60
+ row.anchor_block === null
61
+ ? { board: row.anchor_board }
62
+ : { board: row.anchor_board, block: row.anchor_block },
63
+ projectId: row.project_id ?? undefined,
55
64
  agent: row.agent,
56
65
  sessionId: row.session_id,
57
66
  version: row.version,
@@ -68,17 +77,33 @@ const rowSchema = z
68
77
  }),
69
78
  );
70
79
 
71
- /** Saved Ask conversations, per review. Shared reviews are not in
72
- * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
80
+ /** A thread's project is always its anchor board's. */
81
+ const THREAD_PROJECT_SQL =
82
+ "(SELECT project_id FROM reviews WHERE reviews.id=ask_conversations.anchor_board)";
83
+
84
+ /** Every column of a thread, with its project. */
85
+ const THREAD_COLUMNS = `ask_conversations.*, ${THREAD_PROJECT_SQL} AS project_id`;
86
+
87
+ /** Saved Ask conversations by project, each anchored on the board it started
88
+ * on. No foreign key on the anchor: shared reviews are not in `reviews`.
89
+ * So deleting a board removes its threads by hand (removeReview). */
73
90
  export class AskHistory {
91
+ private readonly listeners = new Set<() => void>();
92
+
74
93
  private externalWriteGuard?: (reviewId: string) => void;
75
94
  setExternalWriteGuard(guard: (reviewId: string) => void) {
76
95
  this.externalWriteGuard = guard;
77
96
  }
78
- constructor(private readonly db: DatabaseSync) {
97
+ constructor(
98
+ private readonly db: DatabaseSync,
99
+ /** The store's rule for writing about a board: one in Trash is
100
+ * read-only. */
101
+ private readonly writable: (reviewId: string) => void = () => {},
102
+ ) {
79
103
  db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
80
104
  id TEXT PRIMARY KEY,
81
- review_id TEXT NOT NULL,
105
+ anchor_board TEXT NOT NULL,
106
+ anchor_block TEXT,
82
107
  agent TEXT NOT NULL,
83
108
  session_id TEXT NOT NULL,
84
109
  version INTEGER NOT NULL,
@@ -91,25 +116,12 @@ export class AskHistory {
91
116
  entries TEXT,
92
117
  bypass INTEGER NOT NULL DEFAULT 0,
93
118
  permissions TEXT,
94
- UNIQUE(agent, session_id));
95
- CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
96
-
97
- // Conversations saved before bypassing permissions, or before auto mode,
98
- // lack its column.
99
- const columns = new Set(
100
- db
101
- .prepare("PRAGMA table_info(ask_conversations)")
102
- .all()
103
- .map((column) => String(column.name)),
104
- );
119
+ viewed_at TEXT,
120
+ UNIQUE(agent, session_id));`);
105
121
 
106
- if (!columns.has("bypass"))
107
- db.exec(
108
- "ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0",
109
- );
110
-
111
- if (!columns.has("permissions"))
112
- db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
122
+ db.exec(
123
+ "CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(anchor_board, updated_at)",
124
+ );
113
125
 
114
126
  // What each agent offered last, so a new question can pick a model and
115
127
  // effort, and a command, before its agent starts.
@@ -122,34 +134,46 @@ export class AskHistory {
122
134
  );
123
135
  }
124
136
 
125
- private assertWritable(reviewId: string) {
126
- if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
137
+ /** Calls back when a conversation is saved, renamed, used or deleted. */
138
+ subscribe(listener: () => void) {
139
+ this.listeners.add(listener);
127
140
 
128
- const row = this.db
129
- .prepare("SELECT deleted_at FROM review_attention WHERE review_id=?")
130
- .get(reviewId);
141
+ return () => {
142
+ this.listeners.delete(listener);
143
+ };
144
+ }
131
145
 
132
- if (row?.deleted_at)
133
- throw new ReviewInputError(
134
- `This whiteboard was moved to Trash on ${new Date(String(row.deleted_at)).toLocaleDateString("en-US", { month: "short", day: "numeric" })}. Restore it with session_lifecycle({"sessionId":"${reviewId}","state":"active"}) to continue.`,
135
- 409,
136
- );
146
+ private changed() {
147
+ for (const listener of this.listeners) listener();
148
+ }
149
+
150
+ private assertWritable(reviewId: string) {
151
+ if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
152
+ else this.writable(reviewId);
137
153
  }
154
+
138
155
  private assertThreadWritable(id: string) {
139
156
  const record = this.get(id);
140
157
 
141
- if (record) this.assertWritable(record.reviewId);
158
+ if (record) this.assertWritable(record.anchor.board);
142
159
  }
143
160
  save(record: AskRecord) {
144
- this.assertWritable(record.reviewId);
161
+ this.assertWritable(record.anchor.board);
145
162
  this.db
146
163
  .prepare(
147
- `INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
148
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
164
+ // Its columns, not the row: when it was last seen, and what the
165
+ // panel showed, stay.
166
+ `INSERT INTO ask_conversations(id,anchor_board,anchor_block,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
167
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
168
+ ON CONFLICT(id) DO UPDATE SET anchor_board=excluded.anchor_board, anchor_block=excluded.anchor_block,
169
+ agent=excluded.agent, session_id=excluded.session_id, version=excluded.version, head=excluded.head,
170
+ cwd=excluded.cwd, selection=excluded.selection, title=excluded.title, created_at=excluded.created_at,
171
+ updated_at=excluded.updated_at, bypass=excluded.bypass, permissions=excluded.permissions`,
149
172
  )
150
173
  .run(
151
174
  record.id,
152
- record.reviewId,
175
+ record.anchor.board,
176
+ record.anchor.block ?? null,
153
177
  record.agent,
154
178
  record.sessionId,
155
179
  record.version,
@@ -162,6 +186,7 @@ export class AskHistory {
162
186
  record.permissions === "bypass" ? 1 : 0,
163
187
  record.permissions ?? null,
164
188
  );
189
+ this.changed();
165
190
  }
166
191
 
167
192
  /** What the agent may do without asking when the conversation reopens. */
@@ -189,6 +214,7 @@ export class AskHistory {
189
214
  this.db
190
215
  .prepare("UPDATE ask_conversations SET entries=? WHERE id=?")
191
216
  .run(JSON.stringify(entries), id);
217
+ this.changed();
192
218
  }
193
219
 
194
220
  /** The agent's name for the conversation, which the list shows. */
@@ -197,6 +223,7 @@ export class AskHistory {
197
223
  this.db
198
224
  .prepare("UPDATE ask_conversations SET title=? WHERE id=?")
199
225
  .run(title.slice(0, 200), id);
226
+ this.changed();
200
227
  }
201
228
 
202
229
  saveOffer(agent: AskAgentId, offer: AskOffer) {
@@ -245,50 +272,90 @@ export class AskHistory {
245
272
  this.db
246
273
  .prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?")
247
274
  .run(at, id);
275
+ this.changed();
276
+ }
277
+
278
+ /** Conversations in a board's project, or started on the board when it
279
+ * has none. Newest first. */
280
+ list(board: string, projectId?: string) {
281
+ // The project's boards by their index on project_id, then their
282
+ // threads by ask_conversations_review.
283
+ return this.entries(
284
+ "anchor_board=? OR anchor_board IN (SELECT id FROM reviews WHERE project_id=?)",
285
+ board,
286
+ projectId ?? null,
287
+ );
248
288
  }
249
289
 
250
- /** Newest first. */
251
- list(reviewId: string): AskHistoryEntry[] {
290
+ /** Every project's conversations, newest first. */
291
+ listAll() {
292
+ return this.entries("1");
293
+ }
294
+
295
+ private entries(
296
+ where: string,
297
+ ...params: (string | null)[]
298
+ ): (AskHistoryEntry & { projectId?: string; viewedAt?: string })[] {
299
+ // A transcript can be long: SQLite finds its first question, and the
300
+ // rest of it is left unread.
252
301
  return this.db
253
302
  .prepare(
254
- "SELECT * FROM ask_conversations WHERE review_id=? ORDER BY updated_at DESC",
303
+ `SELECT id, anchor_board, anchor_block, ${THREAD_PROJECT_SQL} AS project_id, agent, session_id, version, head, cwd, selection, title,
304
+ created_at, updated_at, NULL AS entries, bypass, permissions, viewed_at,
305
+ (SELECT json_extract(value,'$.text') FROM json_each(entries)
306
+ WHERE json_extract(value,'$.kind')='user' LIMIT 1) AS question
307
+ FROM ask_conversations WHERE ${where} ORDER BY updated_at DESC`,
255
308
  )
256
- .all(reviewId)
309
+ .all(...params)
257
310
  .map((row) => {
258
311
  const {
259
- reviewId: _reviewId,
312
+ anchor,
260
313
  sessionId: _sessionId,
261
314
  version: _version,
262
315
  cwd: _cwd,
263
- entries,
316
+ entries: _entries,
264
317
  permissions: _permissions,
265
318
  ...entry
266
319
  } = rowSchema.parse(row);
267
320
 
268
- const question = entries?.find((entry) => entry.kind === "user")?.text;
321
+ const question = z.string().nullable().parse(row.question) ?? "";
269
322
 
270
- if (question?.trim()) entry.question = question;
323
+ if (question.trim()) entry.question = question;
271
324
 
272
- return entry;
325
+ const viewedAt = z.string().nullable().parse(row.viewed_at);
326
+
327
+ return { ...entry, anchor, ...(viewedAt !== null && { viewedAt }) };
273
328
  });
274
329
  }
275
330
 
276
331
  get(id: string): AskRecord | undefined {
277
332
  const row = this.db
278
- .prepare("SELECT * FROM ask_conversations WHERE id=?")
333
+ .prepare(`SELECT ${THREAD_COLUMNS} FROM ask_conversations WHERE id=?`)
279
334
  .get(id);
280
335
 
281
336
  return row ? rowSchema.parse(row) : undefined;
282
337
  }
283
338
 
339
+ /** The reviewer saw the thread, on screen with the app focused. Only
340
+ * when it was seen changes: not its order, nor when it was used. */
341
+ markViewed(id: string, at = new Date().toISOString()) {
342
+ this.db
343
+ .prepare("UPDATE ask_conversations SET viewed_at=? WHERE id=?")
344
+ .run(at, id);
345
+ this.changed();
346
+ }
347
+
348
+ /** Forgets the threads that started on a board gone for good. */
284
349
  removeReview(reviewId: string) {
285
350
  this.db
286
- .prepare("DELETE FROM ask_conversations WHERE review_id=?")
351
+ .prepare("DELETE FROM ask_conversations WHERE anchor_board=?")
287
352
  .run(reviewId);
353
+ this.changed();
288
354
  }
289
355
 
290
356
  delete(id: string) {
291
357
  this.assertThreadWritable(id);
292
358
  this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
359
+ this.changed();
293
360
  }
294
361
  }
@@ -1,3 +1,4 @@
1
+ import { type JsonValue, isJsonObject } from "@dev.fast/json";
1
2
  import { z } from "zod";
2
3
 
3
4
  import {
@@ -15,10 +16,7 @@ import { commandSchema } from "./store.js";
15
16
  /** The host publishes its input schemas, except where agents write less than
16
17
  * the host accepts (publishedEditSchema, publishedLensEditSchema) or name a
17
18
  * checkout by path (pathTargetSchema); adapters validate nothing. */
18
- export function authoringTools(
19
- scratchpadAvailable = false,
20
- traceEnabled = false,
21
- ) {
19
+ export function authoringTools(traceEnabled = false) {
22
20
  const id = z.string().min(1);
23
21
  const review = { reviewId: id };
24
22
 
@@ -31,19 +29,21 @@ export function authoringTools(
31
29
  ...trace.shape,
32
30
  kind: z.enum(["image", "trace"]),
33
31
  })
32
+ .omit({ repositoryId: true })
33
+ .extend({ repositoryPath: id })
34
34
  .partial()
35
- .required({ id: true, repositoryId: true, kind: true });
35
+ .required({ id: true, repositoryPath: true, kind: true });
36
36
 
37
37
  const descriptions = {
38
38
  create:
39
- 'Create a review of saved working files, immutable commits or a GitHub PR. Revisions are resolved on acceptance. A worktree target reviews the saved files in its checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base: the branch to compare against, by default the default branch of the repository. The diff starts at the merge base of base and HEAD, which follows rebases. Omitted commits base means source at head with no diff; supply the parent to review introduced changes. For a branch stacked on another, pass the branch below it as base; Desktop links reviews into a stack through their bases. For a GitHub PR, pullRequestUrl alone is enough: target and title become optional, and the host fetches the PR into a registered checkout of its repository and pins the current PR head and GitHub diff base, titled from the PR. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting. kind:"scratchpad" names the one scratchpad, which the host creates itself. The result carries review, the review as review_list shows it: its target with resolved commits, origin (its PR), repositoryName and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop.',
39
+ 'Create a whiteboard. target names its repository by the checkout\'s path, and what it shows: {kind:"project"} for a plan in that repository\'s project with no change; {kind:"worktree", base?} for the saved files in the checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base, by default the repository\'s default branch, from the merge base of base and HEAD, which follows rebases; {kind:"commits", head, base?} for immutable commits (omitted base means source at head with no diff; supply the parent to review introduced changes); {kind:"pullRequest", pullRequest} for a GitHub PR by URL or number, fetched into that checkout and pinned at the PR head and GitHub diff base. For a branch stacked on another, pass the branch below it as base. Group plan implementations or a PR stack under one project-target overview with parentId, and order its children with dependsOn; Git bases do not set these relationships. Revisions are resolved on acceptance. With a PR and no title the PR\'s title is used. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting:false. The result carries review, as review_list shows it, with its target and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop. parentId and dependsOn set its place among the project\'s reviews, as review_set_parent and review_set_dependencies do; projectId files it in another project, by project.id from review_list.',
40
40
  set_target:
41
- "Move the review to a new target, such as the commits after a rebase or amend, preserving the document and component IDs. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. Omitted pullRequestUrl keeps the PR within the same repository; changing repositories clears it. Supply a URL to replace it or null to detach.",
41
+ "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories.",
42
42
  edit: [
43
- "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions (authoring or scratchpad).",
44
- "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {repositoryId, head, base?} say which commits anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
45
- "insert adds content: parentId nests it, afterId places it, and omitted placement appends (on the scratchpad, it lands at the top). update patches a component's own fields; null removes an optional one. A section's children and a diagram's units change through their own IDs; a call stack's frames change by replacing the call stack. move and remove take a targetId; removing a flow_node removes its edges.",
46
- "The host assigns short durable IDs. The result names the edited component and, for an insert or replace, its first-level children, and quotes the first and last line of each anchor the edit added or changed (the first 20; unquotedAnchors counts the rest): check they are the lines you meant. Accepted edits save at once.",
43
+ "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions.",
44
+ "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {head, base?} say which commits of the whiteboard's repository anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
45
+ "insert adds content: parentId nests it, afterId places it, and omitted placement appends. update patches a component's own fields; null removes an optional one. A section's children and a diagram's units change through their own IDs; a call stack's frames change by replacing the call stack. move and remove take a targetId; removing a flow_node removes its edges.",
46
+ "The host assigns short durable IDs. The result names the edited component and, for an insert or replace, its first-level children, and quotes the first and last line of each anchor the edit added or changed (the first 20; unquotedAnchors counts the rest): check they are the lines you meant. Accepted edits save at once. An edit to a block the user is editing is refused with an error: edit another block, or retry later.",
47
47
  "While a reader may be watching, write small and often: one paragraph per edit. Insert a new diagram whole. Change one already drawn a unit at a time (a flow_node, flow_edge or step, with parentId naming the diagram), and link each added flow_node to a node already drawn.",
48
48
  ].join("\n"),
49
49
  lens_edit:
@@ -56,7 +56,11 @@ export function authoringTools(
56
56
  lifecycle:
57
57
  "Move a review between active and trash. Trash retains the review for 30 days before automatic deletion. Moving it back to active restores it; moving it to trash again does not restart that period.",
58
58
  delete:
59
- "Permanently delete a review and its history. The review must be in Trash. This cannot be undone.",
59
+ "Permanently delete a review and its history. The review must be in Trash. This cannot be undone. Its children move up to top level, and dependencies on it are dropped.",
60
+ set_parent:
61
+ "Nest the review under another review of the same project, or make it top-level with parentId:null. Nesting is one level deep: the parent must be top-level, and a review with children can't become a child. The sidebar shows children under their parent. Saves no review version. Returns the review's relations: parentId, children in sibling order, and dependsOn.",
62
+ set_dependencies:
63
+ "Replace the list of reviews this one builds on: any reviews of the same project. Siblings list after what they depend on, directly or through other reviews; a PR stack is a chain. A list that would close a cycle is refused, naming the cycle. Saves no review version. Returns the review's relations.",
60
64
  };
61
65
 
62
66
  const tool = (
@@ -70,7 +74,7 @@ export function authoringTools(
70
74
  name: `review_${name === "restore" ? "restore_version" : name}`,
71
75
  description,
72
76
  inputSchema: {
73
- ...z.toJSONSchema(schema, { io: "input" }),
77
+ ...withoutRepositoryIds(z.toJSONSchema(schema, { io: "input" })),
74
78
  type: "object" as const,
75
79
  },
76
80
  method,
@@ -92,9 +96,6 @@ export function authoringTools(
92
96
  'Read Whiteboard\'s guidance before creating or editing a review. The default topic gives the authoring workflow; "file-lenses" covers Diff-view file lenses.' +
93
97
  (traceEnabled
94
98
  ? ' Call review_get_instructions({topic:"trace-archaeology"}) for why code exists, what an agent was thinking, or whether an agent solved this before.'
95
- : "") +
96
- (scratchpadAvailable
97
- ? ' When the user asks in conversation to be shown how code works or wants a diagram, without asking for a review, draw it on the scratchpad rather than answering only in chat: call review_get_instructions({topic:"scratchpad"}) first. A request for a review or to use Whiteboard means authoring a review with the default topic.'
98
99
  : ""),
99
100
  instructionsQuerySchema.partial(),
100
101
  "GET",
@@ -124,10 +125,12 @@ export function authoringTools(
124
125
  ...commandSchema.shape.operation.options.map((operation) => {
125
126
  const type = operation.shape.type.value;
126
127
 
127
- // Agents name a checkout by its path; /commands registers it.
128
+ // Hidden from agents: they name checkouts by path inside the target
129
+ // and give PRs as a target.
128
130
  const {
129
131
  type: _type,
130
132
  repositoryId: _repositoryId,
133
+ pullRequestUrl: _pullRequestUrl,
131
134
  ...fields
132
135
  }: Record<string, z.ZodType> = operation.shape;
133
136
 
@@ -137,12 +140,7 @@ export function authoringTools(
137
140
  z.strictObject({
138
141
  ...fields,
139
142
  ...(type === "create" && {
140
- target: pathTargetSchema.optional(),
141
- repositoryPath: id
142
- .optional()
143
- .describe(
144
- "Only with pullRequestUrl and no target: the local checkout to fetch the PR into. Default: the existing review's, else the first registered checkout with a remote for the PR's repository.",
145
- ),
143
+ target: pathTargetSchema,
146
144
  open: z.boolean().optional(),
147
145
  }),
148
146
  ...(type === "set_target" && { target: pathTargetSchema }),
@@ -196,13 +194,6 @@ export function authoringTools(
196
194
  "POST",
197
195
  "/workspace-cleanup",
198
196
  ),
199
- tool(
200
- "register_repository",
201
- "Register a local Git or jj repository and return its id, for pins on scratchpad blocks. session_create and session_set_target take the checkout's path instead.",
202
- z.strictObject({ path: id }),
203
- "POST",
204
- "/repositories",
205
- ),
206
197
  tool(
207
198
  "upload",
208
199
  'Retain an image or trace for use in a review. kind:"image" takes base64; kind:"trace" takes trace. Reusing an upload ID requires identical content; rejected uploads are not saved.',
@@ -212,3 +203,27 @@ export function authoringTools(
212
203
  ),
213
204
  ];
214
205
  }
206
+
207
+ /** Drops repositoryId from pins: the host fills in the board's own. */
208
+ function withoutRepositoryIds<T>(schema: T): T {
209
+ // SAFETY: a JSON round trip of a JSON Schema, dropping one property.
210
+ return JSON.parse(JSON.stringify(schema), (_key, value: JsonValue) => {
211
+ if (
212
+ !isJsonObject(value) ||
213
+ !isJsonObject(value.properties) ||
214
+ !("repositoryId" in value.properties) ||
215
+ !("head" in value.properties)
216
+ )
217
+ return value;
218
+
219
+ const { repositoryId: _repositoryId, ...properties } = value.properties;
220
+
221
+ return {
222
+ ...value,
223
+ properties,
224
+ ...(Array.isArray(value.required) && {
225
+ required: value.required.filter((name) => name !== "repositoryId"),
226
+ }),
227
+ };
228
+ }) as T;
229
+ }
@@ -1,6 +1,7 @@
1
1
  import { errorMessage } from "@dev.fast/trace-core";
2
2
  import { z } from "zod";
3
3
 
4
+ import { azureRepositoryOf, slugOf } from "./azure-remote.js";
4
5
  import { ReviewInputError } from "./document.js";
5
6
  import type { PullRequestProvider } from "./provider.js";
6
7
  import { firstLine } from "./pull-request.js";
@@ -12,13 +13,6 @@ const PULL_REQUEST_URLS = [
12
13
  /^https:\/\/([A-Za-z0-9-]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+)\/pullrequest\/([1-9]\d*)$/,
13
14
  ];
14
15
 
15
- const REMOTE_URLS = [
16
- /^https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
17
- /^https:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
18
- /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
19
- /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
20
- ];
21
-
22
16
  const metadataSchema = z.object({
23
17
  title: z.string(),
24
18
  sourceRefName: z.string().min(1),
@@ -31,17 +25,6 @@ const metadataSchema = z.object({
31
25
  }),
32
26
  });
33
27
 
34
- /** org/project/repository, decoded; undefined for malformed escapes. */
35
- function slugOf(patterns: RegExp[], url: string) {
36
- const match = patterns.map((pattern) => pattern.exec(url)).find(Boolean);
37
-
38
- try {
39
- return match && match.slice(1, 4).map(decodeURIComponent).join("/");
40
- } catch {
41
- return undefined;
42
- }
43
- }
44
-
45
28
  function azFailure(url: string, failure: string) {
46
29
  const unreadable = (hint: string) =>
47
30
  new ReviewInputError(
@@ -162,8 +145,7 @@ export const azureDevOpsProvider: PullRequestProvider = {
162
145
 
163
146
  matchesRemote(remoteUrl, address) {
164
147
  return (
165
- slugOf(REMOTE_URLS, remoteUrl.trim())?.toLowerCase() ===
166
- address.slug.toLowerCase()
148
+ azureRepositoryOf(remoteUrl)?.toLowerCase() === address.slug.toLowerCase()
167
149
  );
168
150
  },
169
151
  };
@@ -0,0 +1,21 @@
1
+ const REMOTE_URLS = [
2
+ /^https:\/\/(?:[^@]+@)?dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
3
+ /^https:\/\/([^.]+)\.visualstudio\.com\/(?:DefaultCollection\/)?([^/]+)\/_git\/([^/]+?)(?:\.git)?\/?$/,
4
+ /^git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
5
+ /^[^@]+@vs-ssh\.visualstudio\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/,
6
+ ];
7
+
8
+ /** org/project/repository, decoded; undefined for malformed escapes. */
9
+ export function slugOf(patterns: RegExp[], url: string) {
10
+ const match = patterns.map((pattern) => pattern.exec(url)).find(Boolean);
11
+
12
+ try {
13
+ return match && match.slice(1, 4).map(decodeURIComponent).join("/");
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ export function azureRepositoryOf(remoteUrl: string) {
20
+ return slugOf(REMOTE_URLS, remoteUrl.trim());
21
+ }
@@ -13,7 +13,7 @@ export const identity = { id: text.optional() };
13
13
  * them reads its document's. */
14
14
  export const elementPins = sourcePinsSchema
15
15
  .optional()
16
- .describe("Pins for the anchors here; needed on the scratchpad.");
16
+ .describe("Pins for the anchors here.");
17
17
 
18
18
  /**
19
19
  * One block kind: the strict schema the store parses with, and the rules a
@@ -6,12 +6,12 @@ export const markdown = {
6
6
  type: "markdown",
7
7
  schema: defineBlock("markdown", {
8
8
  markdown: text.describe(
9
- "Safe Markdown. Repository file links must use [label](review-source:head/path#L10-L24) or review-source:base/path#L10-L24, with a repository-relative path and verified line numbers. Relative paths, absolute filesystem paths, and file/editor URLs are rejected. External links use https://, http://, or mailto:; document anchors use #heading.",
9
+ "Safe Markdown. Repository file links must use [label](whiteboard:source/head/path#L10-L24) or whiteboard:source/base/path#L10-L24, or whiteboard:source/<commit>/path#L10-L24 at any commit of the board's repository, with a repository-relative path and verified line numbers. Link another board as [label](whiteboard:board/<sessionId>) or one of its blocks as whiteboard:board/<sessionId>#<blockId>; both must exist. Relative paths, absolute filesystem paths, and file/editor URLs are rejected. External links use https://, http://, or mailto:; document anchors use #heading.",
10
10
  ),
11
11
  pins: sourcePinsSchema
12
12
  .optional()
13
13
  .describe(
14
- "Repository and commits this block's review-source links resolve against, instead of the document's pins.",
14
+ "Repository and commits this block's whiteboard:source/ links resolve against, instead of the document's pins.",
15
15
  ),
16
16
  }),
17
17
  } as const;