@dev.fast/whiteboard 0.2.4 → 0.2.5-preview.20261013.109

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 (77) hide show
  1. package/dist/{account-alias-_1_7vROx.js → account-alias-D0hF0sKk.js} +2 -3
  2. package/dist/{agent-cli-BrFqhnzM.js → agent-cli-BBPxwUOd.js} +6 -6
  3. package/dist/agent-cli-YSuhE5X8.js +2 -0
  4. package/dist/{agent-client-B_b1GtUc.js → agent-client-P3J4y8Zn.js} +3 -3
  5. package/dist/{authoring-tools-DvIO906l.js → authoring-tools-D8WXsm_g.js} +11 -10
  6. package/dist/build-info.json +1 -1
  7. package/dist/{cli-gRGxkwc0.js → cli-C0ziP6hg.js} +1 -1
  8. package/dist/{cli-runner-Cjl8h376.js → cli-runner-C1gY-HAI.js} +16 -17
  9. package/dist/cli.js +1 -1
  10. package/dist/{client-BFClfQLO.js → client-DzcSt6pV.js} +63 -13
  11. package/dist/{desktop-discovery-C-Qz54wO.js → desktop-discovery-Ba3dD5Cn.js} +1 -1
  12. package/dist/{headless-host-DcWaxx9P.js → headless-host-BHD_Vg5F.js} +4 -4
  13. package/dist/{local-data-Bm84Xdck.js → local-data-BlERtVm2.js} +281 -75
  14. package/dist/{mcp-BDy9Aybd.js → mcp-t7VN9R3k.js} +4 -4
  15. package/dist/{package-paths-B6-zxvIO.js → package-paths-B9PDoAl6.js} +1 -1
  16. package/dist/{process-error-telemetry-Dzh5a3s_.js → process-error-telemetry-C0swByfp.js} +158 -57
  17. package/dist/{profile-RDEioF8b.js → profile-BsYDBr7t.js} +2 -2
  18. package/dist/profile-j97aRTS7.js +2 -0
  19. package/dist/{review-agent-traces-DNJFYzc8.js → review-home-paths-Y9mYWkCI.js} +2600 -62
  20. package/dist/{review-telemetry-DBy3eVCo.js → review-telemetry-CmM6tCbj.js} +2 -2
  21. package/dist/server/desktop-host.js +35 -46
  22. package/dist/{server-discovery-Dbzn3w6Z.js → server-discovery-ZVi8Ay8J.js} +1 -1
  23. package/dist/sharing/index.d.ts +37 -49
  24. package/dist/sharing/index.js +1 -1
  25. package/dist/{src-CmdiBL20.js → src-DiHP11l3.js} +11 -1
  26. package/dist/{stored-document-migration-BxZWQQjv.js → stored-document-migration-u4L_1VZa.js} +15 -36
  27. package/dist/{tool-failure-DJmTieh5.js → tool-failure-CJHBCHMh.js} +2 -2
  28. package/instructions/scratchpad.md +1 -1
  29. package/package.json +5 -4
  30. package/src/ask/agents.ts +6 -0
  31. package/src/ask/thread-state.ts +17 -1
  32. package/src/ask/thread.ts +116 -22
  33. package/src/ask/threads.ts +8 -2
  34. package/src/error-telemetry.ts +13 -9
  35. package/src/fixtures/blocks/tutorial.json +3 -40
  36. package/src/review-api/README.md +10 -7
  37. package/src/review-api/ask-history.ts +37 -0
  38. package/src/review-api/authoring-tools.ts +8 -5
  39. package/src/review-api/blocks/database_lens.ts +5 -0
  40. package/src/review-api/blocks/tutorial.ts +2 -18
  41. package/src/review-api/comparison-coverage.ts +16 -9
  42. package/src/review-api/component-reference.ts +1 -1
  43. package/src/review-api/document.ts +24 -10
  44. package/src/review-api/http.ts +267 -189
  45. package/src/review-api/local-data.ts +126 -37
  46. package/src/review-api/store-schema.ts +25 -1
  47. package/src/review-api/store.ts +165 -33
  48. package/src/review-api/workspaces.ts +14 -14
  49. package/src/review-api/worktree-events.ts +46 -0
  50. package/src/review-app.ts +1 -1
  51. package/src/review-info.ts +1 -1
  52. package/src/review-stack.ts +2 -2
  53. package/src/server/review-server-core.ts +25 -1
  54. package/src/server/tutorial-service.ts +22 -46
  55. package/src/sharing/export.ts +1 -0
  56. package/src/sharing/import.ts +111 -26
  57. package/tutorial/document.json +27 -86
  58. package/tutorial/lenses.json +10 -0
  59. package/tutorial/runtime-manifest.json +2 -3
  60. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +16 -0
  61. package/tutorial/sample-service/src/database/schema.ts +9 -10
  62. package/tutorial/sample-service/src/inventory/forklift-mood.ts +4 -0
  63. package/tutorial/sample-service/src/inventory/inventory-service.ts +18 -1
  64. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +11 -0
  65. package/tutorial/sample-service/src/orders/order-service.ts +3 -2
  66. package/tutorial/sample-service/src/orders/order.ts +27 -5
  67. package/tutorial/sample-service/src/payments/fraud-vibes.ts +4 -0
  68. package/tutorial/sample-service/src/payments/payment-gateway.ts +19 -2
  69. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +9 -0
  70. package/dist/agent-cli-BR2j-2n5.js +0 -2
  71. package/dist/input-error-OLgB_h31.js +0 -11
  72. package/dist/profile-BQGaX8aX.js +0 -2
  73. package/dist/review-home-paths-6zZH-1c9.js +0 -2560
  74. package/dist/tutorial-trace-DrBvkaeL.js +0 -43
  75. package/src/tutorial-conversation.ts +0 -18
  76. package/tutorial/software-map.json +0 -139
  77. package/tutorial/trace.json +0 -20
package/src/ask/thread.ts CHANGED
@@ -74,6 +74,17 @@ const piGreetingSchema = z.object({
74
74
 
75
75
  const cursorTodosSchema = z.object({ todos: z.unknown() }).loose();
76
76
 
77
+ /** The steering extension Claude's adapter takes: a follow-up joins the
78
+ * turn under way rather than waiting for it to end. */
79
+ const STEER_METHOD = "_session/steering";
80
+
81
+ /** How an agent says, in its `initialize` response, that it takes steering. */
82
+ const steeringSchema = z.object({
83
+ steering: z.object({ supported: z.literal(true) }),
84
+ });
85
+
86
+ const steerOutcomeSchema = z.object({ outcome: z.string() });
87
+
77
88
  /** MCP servers the reviewer's own agent may bring whose tools only read,
78
89
  * so their calls run without asking, like the agent reading files. */
79
90
  const READ_ONLY_MCP_SERVERS = new Set([
@@ -203,6 +214,10 @@ export class AskThread {
203
214
  /** What Pi greets a new session with, its version and skills, which is no
204
215
  * part of the answer. */
205
216
  private greeting?: string;
217
+ /** The agent takes follow-ups into the turn under way. */
218
+ private steerable = false;
219
+ /** Follow-ups waiting for the turn under way to end, oldest first. */
220
+ private queue: { id: string; question: AskQuestion }[] = [];
206
221
 
207
222
  constructor(
208
223
  private readonly launch: AskAgentLauncher,
@@ -283,20 +298,29 @@ export class AskThread {
283
298
  askRefusal(): string | undefined {
284
299
  switch (this.state.status) {
285
300
  case "idle":
301
+ case "running":
302
+ case "waiting":
286
303
  return undefined;
287
304
  case "failed":
288
305
  return `${this.state.agentName} stopped. Try again first.`;
289
306
  default:
290
- return "The agent is still answering.";
307
+ return `${this.state.agentName} is still starting.`;
291
308
  }
292
309
  }
293
310
 
294
- /** Asks a follow-up, starting the agent again if it has stopped since. */
311
+ /** Asks a follow-up, starting the agent again if it has stopped since.
312
+ * While the agent answers, it joins that turn or waits for it to end. */
295
313
  async ask(question: AskQuestion) {
296
314
  const refusal = this.askRefusal();
297
315
 
298
316
  if (refusal) throw new Error(refusal);
299
317
 
318
+ if (this.state.status !== "idle") {
319
+ await this.steer(question);
320
+
321
+ return;
322
+ }
323
+
300
324
  this.asking = { id: this.addUser(question), question };
301
325
  await this.attempt(async () => {
302
326
  await this.reconnect();
@@ -340,6 +364,60 @@ export class AskThread {
340
364
  });
341
365
  }
342
366
 
367
+ /** Gives the turn under way a follow-up when the agent takes one, else
368
+ * keeps it for when the turn ends. */
369
+ private async steer(question: AskQuestion) {
370
+ const { connection, sessionId } = this;
371
+
372
+ if (this.steerable && connection && sessionId) {
373
+ const id = this.addUser(question);
374
+
375
+ const outcome = await connection.agent
376
+ .request(STEER_METHOD, {
377
+ sessionId,
378
+ prompt: [
379
+ { type: "text", text: question.text },
380
+ ...this.attachments(question),
381
+ ],
382
+ // A turn that ended meanwhile leaves the follow-up to Whiteboard,
383
+ // rather than the agent starting a turn Whiteboard does not see end.
384
+ _meta: { steering: { idleBehavior: "promptRequired" } },
385
+ })
386
+ .then(
387
+ (response) => steerOutcomeSchema.safeParse(response).data?.outcome,
388
+ )
389
+ .catch(() => undefined);
390
+
391
+ if (outcome === "injected") return;
392
+ this.emit({ type: "remove", ids: [id] });
393
+ }
394
+
395
+ this.queue.push({ id: randomUUID(), question });
396
+ this.showQueue();
397
+ // The turn may have ended while the agent was asked.
398
+ this.askQueued();
399
+ }
400
+
401
+ /** Asks the oldest waiting follow-up, once no turn is under way. */
402
+ private askQueued() {
403
+ if (this.closed || this.state.status !== "idle") return;
404
+ const next = this.queue.shift();
405
+
406
+ if (!next) return;
407
+ this.showQueue();
408
+ void this.ask(next.question);
409
+ }
410
+
411
+ private showQueue() {
412
+ this.emit({
413
+ type: "set",
414
+ queued: this.queue.map(({ id, question }) => ({
415
+ id,
416
+ text: question.text,
417
+ })),
418
+ });
419
+ }
420
+
343
421
  decide(permissionId: string, optionId: string) {
344
422
  const resolve = this.decisions.get(permissionId);
345
423
 
@@ -352,6 +430,12 @@ export class AskThread {
352
430
  /** Stops the turn, or the start, in progress. A turn the agent does not
353
431
  * end soon after is ended by stopping the agent. */
354
432
  async cancel() {
433
+ // Stop drops the follow-ups waiting on the turn as well.
434
+ if (this.queue.length) {
435
+ this.queue = [];
436
+ this.showQueue();
437
+ }
438
+
355
439
  // ACP: the Client answers every pending permission request as cancelled.
356
440
  for (const resolve of this.decisions.values())
357
441
  resolve({ outcome: { outcome: "cancelled" } });
@@ -379,31 +463,33 @@ export class AskThread {
379
463
  .catch(() => {});
380
464
  }
381
465
 
382
- close() {
466
+ close(save = true) {
383
467
  if (this.closed) return;
384
468
  this.closed = true;
385
469
 
386
- // A conversation still starting may be half replayed, and one that
387
- // failed has nothing new; neither replaces what was saved.
388
- if (
389
- this.sessionId &&
390
- this.state.status !== "starting" &&
391
- this.state.status !== "failed"
392
- )
393
- this.start.onSave?.(settled(this.state.entries));
394
-
395
- for (const resolve of this.decisions.values())
396
- resolve({ outcome: { outcome: "cancelled" } });
470
+ try {
471
+ // Starting and failed conversations must not replace saved history.
472
+ if (
473
+ save &&
474
+ this.sessionId &&
475
+ this.state.status !== "starting" &&
476
+ this.state.status !== "failed"
477
+ )
478
+ this.start.onSave?.(settled(this.state.entries));
479
+ } finally {
480
+ for (const resolve of this.decisions.values())
481
+ resolve({ outcome: { outcome: "cancelled" } });
397
482
 
398
- if (this.connection && this.sessionId)
399
- void this.connection.agent
400
- .notify(methods.agent.session.cancel, { sessionId: this.sessionId })
401
- .catch(() => {});
402
- this.disconnect();
403
- this.listeners.clear();
483
+ if (this.connection && this.sessionId)
484
+ void this.connection.agent
485
+ .notify(methods.agent.session.cancel, { sessionId: this.sessionId })
486
+ .catch(() => {});
487
+ this.disconnect();
488
+ this.listeners.clear();
404
489
 
405
- for (const closed of this.closers) closed();
406
- this.closers.clear();
490
+ for (const closed of this.closers) closed();
491
+ this.closers.clear();
492
+ }
407
493
  }
408
494
 
409
495
  /** Runs when the thread closes, at once if it already has. */
@@ -556,6 +642,9 @@ export class AskThread {
556
642
  clientInfo: { name: "whiteboard", title: "Whiteboard", version: "1" },
557
643
  });
558
644
 
645
+ this.steerable =
646
+ askAgents[this.start.agent].steers === true &&
647
+ steeringSchema.safeParse(initialized._meta).success;
559
648
  this.emit({
560
649
  type: "set",
561
650
  accepts: {
@@ -793,6 +882,8 @@ export class AskThread {
793
882
  _meta: this.sessionMeta(),
794
883
  });
795
884
 
885
+ if (this.closed) throw new Error("This conversation has ended.");
886
+
796
887
  this.sessionId = response.sessionId;
797
888
  start.onSession?.(response.sessionId);
798
889
 
@@ -895,6 +986,8 @@ export class AskThread {
895
986
  clearTimeout(this.stopTimer);
896
987
  }
897
988
 
989
+ if (this.closed) return;
990
+
898
991
  if (withSelection) this.needsContext = false;
899
992
 
900
993
  if (withPermission) this.permitted = false;
@@ -936,6 +1029,7 @@ export class AskThread {
936
1029
  });
937
1030
  this.start.onTurn?.();
938
1031
  this.start.onSave?.(this.state.entries);
1032
+ this.askQueued();
939
1033
  }
940
1034
 
941
1035
  private async requestPermission(
@@ -265,10 +265,16 @@ export class AskThreads {
265
265
  return offer;
266
266
  }
267
267
 
268
- close(id: string) {
269
- this.threads.get(id)?.close();
268
+ close(id: string, save = true) {
269
+ const thread = this.threads.get(id);
270
270
  this.threads.delete(id);
271
271
  this.unwatchedSince.delete(id);
272
+ thread?.close(save);
273
+ }
274
+
275
+ closeDeletedReviews(isDeleted: (reviewId: string) => boolean) {
276
+ for (const [id, thread] of this.threads)
277
+ if (isDeleted(thread.reviewId)) this.close(id, false);
272
278
  }
273
279
 
274
280
  closeAll() {
@@ -95,17 +95,21 @@ export function deriveErrorTelemetryProperties(
95
95
  const message = jsonString(cause.message);
96
96
 
97
97
  if (message !== undefined && message.length > 0) {
98
+ const cleaned = cleanTelemetryText(message, NO_DELETED_DIRECTORIES);
99
+
98
100
  // The digest goes on every report, cleaned message or not. It is what
99
101
  // groups the reports whose message does not survive the checks below.
100
- derived.message_hash = hashErrorMessage(message);
101
-
102
- if (!SCHEMA_ERROR_NAMES.has(derived.error_name ?? "")) {
103
- const cleaned = cleanTelemetryText(message, NO_DELETED_DIRECTORIES);
104
-
105
- // Ask the allowlist's own check before sending. Failing here rather
106
- // than there keeps a message that the cleaner could not finish out of
107
- // the payload entirely, instead of relying on the later gate.
108
- if (isReportableCleanedMessage(cleaned)) derived.message = cleaned;
102
+ // Cleaned, so an error that names a file has one digest for all files.
103
+ derived.message_hash = hashErrorMessage(cleaned);
104
+
105
+ // Ask the allowlist's own check before sending. Failing here rather
106
+ // than there keeps a message that the cleaner could not finish out of
107
+ // the payload entirely, instead of relying on the later gate.
108
+ if (
109
+ !SCHEMA_ERROR_NAMES.has(derived.error_name ?? "") &&
110
+ isReportableCleanedMessage(cleaned)
111
+ ) {
112
+ derived.message = cleaned;
109
113
  }
110
114
  }
111
115
 
@@ -4,48 +4,11 @@
4
4
  "kind": "keymap",
5
5
  "id": "block-1"
6
6
  },
7
- {
8
- "type": "tutorial",
9
- "kind": "conversation",
10
- "conversation": {
11
- "version": 1,
12
- "title": "How an agent authors a review",
13
- "messages": [
14
- {
15
- "role": "user",
16
- "body": "Explain this change."
17
- },
18
- {
19
- "role": "assistant",
20
- "body": "Here is a review."
21
- }
22
- ]
23
- },
24
- "id": "block-2"
25
- },
26
7
  {
27
8
  "type": "tutorial",
28
9
  "kind": "view",
29
- "view": "commits",
30
- "label": "Explore commits",
31
- "id": "block-3"
32
- },
33
- {
34
- "type": "tutorial",
35
- "kind": "feature",
36
- "feature": "softwareMap",
37
- "children": [
38
- {
39
- "type": "markdown",
40
- "markdown": "Explore the map.",
41
- "id": "block-5"
42
- },
43
- {
44
- "id": "block-6",
45
- "type": "software_map",
46
- "mapVersionId": "33333333-3333-4333-8333-333333333333"
47
- }
48
- ],
49
- "id": "block-4"
10
+ "view": "diff",
11
+ "label": "Open the diff",
12
+ "id": "block-2"
50
13
  }
51
14
  ]
@@ -16,7 +16,7 @@ catalog. Tests inject the native store and source-data provider.
16
16
  - `authoring_presences`: one row per agent working on a review (focus, color slot, where it last wrote, expiry).
17
17
  - `repositories`: server-only local paths; clients receive an ID and display name.
18
18
  - `resources`: immutable image, trace and software-map bytes, scoped to a repository.
19
- - `review_attention`: viewed/dismissed timestamps, separate from document history.
19
+ - `review_attention`: viewed/trashed timestamps, separate from document history.
20
20
 
21
21
  One host-owned store serializes writes, including asynchronous validation.
22
22
  Presences show who is working across database connections and never block a
@@ -96,12 +96,15 @@ Commands: `create {title,target,pullRequestUrl?,reuseExisting?}` or `create {pul
96
96
  Agents name the checkout by `repositoryPath` in a target, or on a create from a PR alone; `/commands` registers the path and passes the store its id. Retargeting preserves content and component IDs. Restore restores title, target, PR identity, and content. Live targets still read the current checkout.
97
97
  PR URLs must be canonical `https://github.com/owner/repository/pull/123` URLs. The PR number is derived from the URL; identity is metadata alongside immutable pins, not a moving source reference. A `create` with a target keeps that target and treats the URL as identity only. A `create` with the URL and no target resolves the PR when accepted: it reads the PR with `gh pr view` (falling back to the public GitHub API), picks the registered checkout named by `repositoryId`, else the checkout of the PR's existing review, else the first registered checkout with a remote whose configured URL is `github.com/owner/repository`, and fetches `refs/pull/N/head` and the base branch from that remote into `refs/review/github/owner/repository/pull/N/*`, never moving branches or bookmarks (a jj repository indexes the commits through a transient tag). It pins that head and its merge base with the base branch; for a PR absorbed by a merge commit it uses the merge base with GitHub's frozen base commit instead. The title defaults to the PR title. A repeat resolves the PR again, so `headMoved` compares the existing review with the PR's current head. `set_target` preserves the document and component IDs, including when source commits change. Its response reports retained source ranges to verify, those in files the new commits changed, and resources that no longer match the pins; agents repair these with `edit`. Existing versions keep their original pins and content. It preserves omitted PR identity within one repository, clears it when switching repositories, and accepts an explicit URL or null.
98
98
 
99
- `attention {reviewId,action:"view"|"dismiss"|"restore"}` records viewing or
100
- reversible dismissal without creating a document version. Home summaries include
101
- the repository name and attention timestamps.
102
- The list stream is separate from document streams. Dismissal closes
103
- the native tab and can be undone from Home. Dismissed API reviews stay saved;
104
- `delete {reviewId}` permanently removes their versions. Old command
99
+ `attention {reviewId,action:"view"}` records viewing without changing lifecycle or document history.
100
+ `lifecycle {reviewId,state:"active"|"trash"}` moves a whiteboard to Trash or restores it.
101
+ Trash retains whiteboards for 30 days; repeated moves to Trash keep the original deadline.
102
+ `delete {reviewId}` permanently removes a whiteboard and its history, but only from Trash.
103
+ The agent tools are `session_attention`, `session_lifecycle`, and `session_delete`.
104
+ `session_restore_version` restores saved content; it does not move a whiteboard out of Trash.
105
+ Home summaries include the repository name, `viewedAt`, and `trashedAt`.
106
+ The list stream is separate from document streams; moving to Trash closes the native tab.
107
+ Old command
105
108
  inputs are erased but their IDs remain, so delayed retries cannot resurrect content.
106
109
  Repository resources remain shared. Home and the canvas open a pinned, read-only
107
110
  source tree. Each source tab names its review version; files opened from it use
@@ -11,6 +11,8 @@ import {
11
11
  } from "@review/ask/thread-state.js";
12
12
  import { z } from "zod";
13
13
 
14
+ import { ReviewInputError } from "./document.js";
15
+
14
16
  /** A saved Ask conversation: its history entry, and how to reopen it. */
15
17
  export const askRecordSchema = askHistoryEntrySchema.extend({
16
18
  reviewId: z.string(),
@@ -64,6 +66,10 @@ const rowSchema = z
64
66
  /** Saved Ask conversations, per review. Shared reviews are not in
65
67
  * `reviews`, so there is no foreign key; deleting a review deletes its rows. */
66
68
  export class AskHistory {
69
+ private externalWriteGuard?: (reviewId: string) => void;
70
+ setExternalWriteGuard(guard: (reviewId: string) => void) {
71
+ this.externalWriteGuard = guard;
72
+ }
67
73
  constructor(private readonly db: DatabaseSync) {
68
74
  db.exec(`CREATE TABLE IF NOT EXISTS ask_conversations(
69
75
  id TEXT PRIMARY KEY,
@@ -104,7 +110,26 @@ export class AskHistory {
104
110
  );
105
111
  }
106
112
 
113
+ private assertWritable(reviewId: string) {
114
+ if (reviewId.startsWith("shared-")) this.externalWriteGuard?.(reviewId);
115
+
116
+ const row = this.db
117
+ .prepare("SELECT deleted_at FROM review_attention WHERE review_id=?")
118
+ .get(reviewId);
119
+
120
+ if (row?.deleted_at)
121
+ throw new ReviewInputError(
122
+ `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.`,
123
+ 409,
124
+ );
125
+ }
126
+ private assertThreadWritable(id: string) {
127
+ const record = this.get(id);
128
+
129
+ if (record) this.assertWritable(record.reviewId);
130
+ }
107
131
  save(record: AskRecord) {
132
+ this.assertWritable(record.reviewId);
108
133
  this.db
109
134
  .prepare(
110
135
  `INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass)
@@ -128,6 +153,7 @@ export class AskHistory {
128
153
 
129
154
  /** Whether reopening the conversation bypasses permissions. */
130
155
  setBypass(id: string, bypass: boolean) {
156
+ this.assertThreadWritable(id);
131
157
  this.db
132
158
  .prepare("UPDATE ask_conversations SET bypass=? WHERE id=?")
133
159
  .run(bypass ? 1 : 0, id);
@@ -136,6 +162,7 @@ export class AskHistory {
136
162
  /** Points a conversation at a new session, when its agent could not
137
163
  * reopen the one it had. */
138
164
  updateSession(id: string, sessionId: string) {
165
+ this.assertThreadWritable(id);
139
166
  this.db
140
167
  .prepare("UPDATE ask_conversations SET session_id=? WHERE id=?")
141
168
  .run(sessionId, id);
@@ -143,6 +170,7 @@ export class AskHistory {
143
170
 
144
171
  /** Keeps what the panel shows, so a reopen need not wait on the agent. */
145
172
  saveEntries(id: string, entries: AskEntry[]) {
173
+ this.assertThreadWritable(id);
146
174
  this.db
147
175
  .prepare("UPDATE ask_conversations SET entries=? WHERE id=?")
148
176
  .run(JSON.stringify(entries), id);
@@ -150,6 +178,7 @@ export class AskHistory {
150
178
 
151
179
  /** The agent's name for the conversation, which the list shows. */
152
180
  rename(id: string, title: string) {
181
+ this.assertThreadWritable(id);
153
182
  this.db
154
183
  .prepare("UPDATE ask_conversations SET title=? WHERE id=?")
155
184
  .run(title.slice(0, 200), id);
@@ -197,6 +226,7 @@ export class AskHistory {
197
226
  }
198
227
 
199
228
  touch(id: string, at = new Date().toISOString()) {
229
+ this.assertThreadWritable(id);
200
230
  this.db
201
231
  .prepare("UPDATE ask_conversations SET updated_at=? WHERE id=?")
202
232
  .run(at, id);
@@ -236,7 +266,14 @@ export class AskHistory {
236
266
  return row ? rowSchema.parse(row) : undefined;
237
267
  }
238
268
 
269
+ removeReview(reviewId: string) {
270
+ this.db
271
+ .prepare("DELETE FROM ask_conversations WHERE review_id=?")
272
+ .run(reviewId);
273
+ }
274
+
239
275
  delete(id: string) {
276
+ this.assertThreadWritable(id);
240
277
  this.db.prepare("DELETE FROM ask_conversations WHERE id=?").run(id);
241
278
  }
242
279
  }
@@ -36,7 +36,7 @@ export function authoringTools(
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 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 a 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 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 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.',
40
40
  set_target:
41
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.",
42
42
  edit: [
@@ -52,8 +52,11 @@ export function authoringTools(
52
52
  restore:
53
53
  "Restore title, source pins, PR identity and content from a saved version.",
54
54
  attention:
55
- "Mark a review viewed, dismissed or restored without changing its content.",
56
- delete: "Permanently delete this review and its history.",
55
+ "Mark an active review viewed without changing its content or lifecycle. Restore a review from Trash before marking it viewed.",
56
+ lifecycle:
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
+ delete:
59
+ "Permanently delete a review and its history. The review must be in Trash. This cannot be undone.",
57
60
  };
58
61
 
59
62
  const tool = (
@@ -64,7 +67,7 @@ export function authoringTools(
64
67
  path: string,
65
68
  commandType?: string,
66
69
  ) => ({
67
- name: `review_${name}`,
70
+ name: `review_${name === "restore" ? "restore_version" : name}`,
68
71
  description,
69
72
  inputSchema: {
70
73
  ...z.toJSONSchema(schema, { io: "input" }),
@@ -174,7 +177,7 @@ export function authoringTools(
174
177
  ),
175
178
  tool(
176
179
  "open",
177
- "Show an existing review immediately and prepare current pinned checkouts in the background. Returns softwareMapEnabled and any already-recorded environmentIssues. Missing optional setup is not an issue; use review_environment to recheck.",
180
+ "Show an active review immediately; restore it from Trash before opening it. Prepare current pinned checkouts in the background. Returns softwareMapEnabled and any already-recorded environmentIssues. Missing optional setup is not an issue; use review_environment to recheck.",
178
181
  z.strictObject(review),
179
182
  "POST",
180
183
  "/:reviewId/open",
@@ -57,6 +57,11 @@ export const storeSchema = z.strictObject({
57
57
  label,
58
58
  key: label.optional(),
59
59
  fields: z.record(text, fieldSchema),
60
+ source: anchorSchema
61
+ .optional()
62
+ .describe(
63
+ "Where the table or collection is declared, such as its schema definition. Its use cases' tours start here.",
64
+ ),
60
65
  }),
61
66
  ),
62
67
  });
@@ -1,36 +1,20 @@
1
- import {
2
- type TutorialAuthoringConversation,
3
- tutorialAuthoringConversationSchema,
4
- } from "@review/tutorial-conversation.js";
5
1
  import { z } from "zod";
6
2
 
7
3
  import { type BlockDefinition, defineBlock } from "./definition.js";
8
- import { type Block, blockSchema } from "./index.js";
9
4
 
10
5
  type TutorialContent =
11
6
  | { kind: "keymap" }
12
- | { kind: "conversation"; conversation: TutorialAuthoringConversation }
13
- | { kind: "view"; view: "review" | "commits" | "diff" | "map"; label: string }
14
- | { kind: "feature"; feature: "softwareMap"; children: Block[] };
7
+ | { kind: "view"; view: "diff"; label: string };
15
8
 
16
9
  export type TutorialBlock = { id?: string; type: "tutorial" } & TutorialContent;
17
10
 
18
11
  export const tutorialSchema = z.discriminatedUnion("kind", [
19
12
  defineBlock("tutorial", { kind: z.literal("keymap") }),
20
- defineBlock("tutorial", {
21
- kind: z.literal("conversation"),
22
- conversation: tutorialAuthoringConversationSchema,
23
- }),
24
13
  defineBlock("tutorial", {
25
14
  kind: z.literal("view"),
26
- view: z.enum(["review", "commits", "diff", "map"]),
15
+ view: z.literal("diff"),
27
16
  label: z.string(),
28
17
  }),
29
- defineBlock("tutorial", {
30
- kind: z.literal("feature"),
31
- feature: z.literal("softwareMap"),
32
- children: z.array(z.lazy((): z.ZodType<Block> => blockSchema)),
33
- }),
34
18
  ]);
35
19
 
36
20
  export const tutorial: BlockDefinition<TutorialBlock> = {
@@ -151,11 +151,14 @@ export async function comparisonCoverage(
151
151
 
152
152
  if (mode === "textual") return coverage;
153
153
 
154
- // Keep Git's totals and file identities while structural results add folding.
155
- const publishCurrent = () =>
156
- publish?.({ ...coverage, files: [...coverage.files] });
154
+ const unsupported = new Set<string>();
157
155
 
158
- publishCurrent();
156
+ const current = () => ({
157
+ ...coverage,
158
+ files: coverage.files.filter((file) => !unsupported.has(file.path)),
159
+ });
160
+
161
+ publish?.(current());
159
162
 
160
163
  const baseFiles = new Map(
161
164
  coverage.files.map((file, index) => [
@@ -195,12 +198,18 @@ export async function comparisonCoverage(
195
198
  if (!remaining.delete(path))
196
199
  throw new Error(`Unexpected structural result: ${path}`);
197
200
 
201
+ const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
202
+ const headIndex = event.file.rhs && headFiles.get(event.file.rhs.path);
203
+
198
204
  if (event.error) {
199
205
  if (
200
206
  event.error.code !== "unsupported_file_type" &&
201
207
  event.error.code !== "not_utf8"
202
208
  )
203
209
  throw new Error(`Cannot count ${path}: ${event.error.message}`);
210
+
211
+ for (const index of [baseIndex, headIndex])
212
+ if (index !== undefined) unsupported.add(coverage.files[index].path);
204
213
  } else if (event.diff.type === "text") {
205
214
  const diff = event.diff;
206
215
  const folded = foldedChanges(diff);
@@ -229,8 +238,6 @@ export async function comparisonCoverage(
229
238
  };
230
239
  }
231
240
 
232
- const baseIndex = event.file.lhs && baseFiles.get(event.file.lhs.path);
233
- const headIndex = event.file.rhs && headFiles.get(event.file.rhs.path);
234
241
  const index = headIndex ?? baseIndex;
235
242
 
236
243
  if (index !== undefined) {
@@ -249,16 +256,16 @@ export async function comparisonCoverage(
249
256
  structuralRows(diff),
250
257
  );
251
258
  }
252
-
253
- publishCurrent();
254
259
  }
255
260
 
261
+ publish?.(current());
262
+
256
263
  if (!remaining.size) break;
257
264
  }
258
265
 
259
266
  if (remaining.size) throw new Error("Structural coverage is incomplete.");
260
267
 
261
- return coverage;
268
+ return current();
262
269
  }
263
270
 
264
271
  /**
@@ -22,7 +22,7 @@ export const COMPONENT_REFERENCE = [
22
22
  "- call_stack_diff {title, base: frame[], head: frame[], pins?}; either side may be empty",
23
23
  "- frame {source: anchor, label?, key?, parentKey? (an earlier frame on that side), callSite?: anchor, contextSources?: anchor[], via?: {kind: call|queue|callback|rpc, reason}, pins?}",
24
24
  "- database_lens {title, actors: {key: label}, stores: {key: store}, useCases: useCase[] (1 or more), pins?}",
25
- "- store {label, storage: relational|document, collections: {key: {label, fields: {key: field}}}, dataStoreKind?: database|objectStore|bucket|artifactStore|fileStore}",
25
+ "- store {label, storage: relational|document, collections: {key: {label, fields: {key: field}, source?: anchor of its schema declaration}}, dataStoreKind?: database|objectStore|bucket|artifactStore|fileStore}",
26
26
  "- field {label, dataType, nullable?, primaryKey?, references?: {store, collection, field}, fields?: {key: field}}",
27
27
  "- useCase {label, summary?, operations: operation[] (1 or more)}; operation {kind: read|write, actor, store, collection, label, source: anchor, field?, detail?, pins?}, naming declared keys",
28
28
  "",