@cueloop/schema 0.1.0-alpha.64 → 0.1.0-alpha.65

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.
package/src/history.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * A review session's history as an append-only tree of entries. Each entry
3
+ * points at its parent; a branch is a named tip; the active path is the chain
4
+ * from the current branch's tip back to the root. Nothing is ever deleted:
5
+ * navigating moves a tip, a comment removal is its own entry, a fork copies a
6
+ * path into a new history. Every view of the session - the artifact text,
7
+ * the open comments, the verdict history - derives from the active path.
8
+ *
9
+ * Pure: no store, no daemon. The daemon owns persistence; the client and the
10
+ * feedback document read what this module derives.
11
+ */
12
+
13
+ import type { Annotation, ReviewSession, Verdict } from "./types";
14
+
15
+ export type EntryAuthor = "agent" | "reviewer";
16
+
17
+ interface EntryBase {
18
+ id: string;
19
+ /** null on the root entry. */
20
+ parentId: string | null;
21
+ createdAt: string;
22
+ }
23
+
24
+ export type SessionEntry =
25
+ | (EntryBase & { type: "revision"; by: EntryAuthor; content: string })
26
+ | (EntryBase & { type: "comment"; annotationId: string })
27
+ | (EntryBase & { type: "comment-removed"; annotationId: string })
28
+ | (EntryBase & { type: "verdict"; verdict: Verdict })
29
+ | (EntryBase & { type: "branch-summary"; text: string; abandoned: string[] });
30
+
31
+ export type EntryType = SessionEntry["type"];
32
+
33
+ /** Omit that keeps a discriminated union's members apart. */
34
+ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
35
+
36
+ /** An entry as a caller describes it: the tree pointers are the history's business. */
37
+ export type NewEntry = DistributiveOmit<SessionEntry, "id" | "parentId"> & { id?: string };
38
+
39
+ export interface AppendResult {
40
+ history: SessionHistory;
41
+ entry: SessionEntry;
42
+ }
43
+
44
+ /** The agent's line; every other branch is the reviewer's. */
45
+ export const MAIN_BRANCH = "main";
46
+
47
+ export interface SessionHistory {
48
+ entries: SessionEntry[];
49
+ /** Named tips; `main` always exists. */
50
+ tips: Record<string, string>;
51
+ /** The branch whose path the session shows. */
52
+ branch: string;
53
+ /** Checkpoints: an entry id to the name a reviewer gave it. */
54
+ labels: Record<string, string>;
55
+ }
56
+
57
+ let entrySeq = 0;
58
+
59
+ export function newEntryId(): string {
60
+ return `e_${Date.now().toString(36)}${(entrySeq++).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
61
+ }
62
+
63
+ export class HistoryError extends Error {
64
+ constructor(
65
+ readonly code:
66
+ | "unknown-entry"
67
+ | "unknown-branch"
68
+ | "branch-exists"
69
+ | "off-path"
70
+ | "already-there"
71
+ | "not-a-revision"
72
+ | "cycle",
73
+ message: string,
74
+ ) {
75
+ super(message);
76
+ }
77
+ }
78
+
79
+ /* ------------------------------------------------------------------ reads */
80
+
81
+ export function entryOf(history: SessionHistory, id: string): SessionEntry | undefined {
82
+ return history.entries.find((entry) => entry.id === id);
83
+ }
84
+
85
+ export function tipOf(history: SessionHistory, branch: string = history.branch): string {
86
+ const tip = history.tips[branch];
87
+
88
+ if (tip === undefined) throw new HistoryError("unknown-branch", `no branch "${branch}"`);
89
+
90
+ return tip;
91
+ }
92
+
93
+ /** The entries from `fromId` (default: the current tip) back to the root, root first. */
94
+ export function pathOf(history: SessionHistory, fromId: string = tipOf(history)): SessionEntry[] {
95
+ const byId = new Map(history.entries.map((entry) => [entry.id, entry]));
96
+ const path: SessionEntry[] = [];
97
+ const seen = new Set<string>();
98
+
99
+ for (
100
+ let entry = byId.get(fromId);
101
+ entry;
102
+ entry = entry.parentId ? byId.get(entry.parentId) : undefined
103
+ ) {
104
+ if (seen.has(entry.id))
105
+ throw new HistoryError("cycle", `entry "${entry.id}" is its own ancestor`);
106
+ seen.add(entry.id);
107
+ path.unshift(entry);
108
+ }
109
+
110
+ return path;
111
+ }
112
+
113
+ /**
114
+ * The graph invariants every history must hold, as one message or null: ids
115
+ * are unique, every parent exists, no entry is its own ancestor, every tip
116
+ * names an entry, `main` and the current branch exist, and every tip's path
117
+ * reaches a revision. Run at every boundary a history crosses.
118
+ */
119
+ export function validateHistory(history: SessionHistory): string | null {
120
+ const ids = new Set<string>();
121
+
122
+ for (const entry of history.entries) {
123
+ if (ids.has(entry.id)) return `duplicate entry id "${entry.id}"`;
124
+ ids.add(entry.id);
125
+ }
126
+ for (const entry of history.entries) {
127
+ if (entry.parentId !== null && !ids.has(entry.parentId)) {
128
+ return `entry "${entry.id}" points at missing parent "${entry.parentId}"`;
129
+ }
130
+ }
131
+ if (history.tips[MAIN_BRANCH] === undefined) return `no "${MAIN_BRANCH}" branch`;
132
+ if (history.tips[history.branch] === undefined)
133
+ return `current branch "${history.branch}" has no tip`;
134
+ for (const [branch, tip] of Object.entries(history.tips)) {
135
+ if (!ids.has(tip)) return `branch "${branch}" points at missing entry "${tip}"`;
136
+ try {
137
+ if (!pathOf(history, tip).some((entry) => entry.type === "revision")) {
138
+ return `branch "${branch}" has no revision on its path`;
139
+ }
140
+ } catch (error) {
141
+ return error instanceof HistoryError ? error.message : String(error);
142
+ }
143
+ }
144
+ for (const entry of history.entries) {
145
+ // a cycle off every path still hangs a walk that starts inside it
146
+ try {
147
+ pathOf(history, entry.id);
148
+ } catch (error) {
149
+ return error instanceof HistoryError ? error.message : String(error);
150
+ }
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ export interface DerivedPath {
157
+ /** The last revision on the path: what the artifact shows. */
158
+ head: SessionEntry & { type: "revision" };
159
+ /**
160
+ * Comments added on the path and not removed after, in order. Whether one
161
+ * is addressed by a revision is the annotation's own state, not the path's.
162
+ */
163
+ annotationIds: string[];
164
+ /** Verdicts on the path, oldest first. */
165
+ verdicts: Verdict[];
166
+ summaries: Array<SessionEntry & { type: "branch-summary" }>;
167
+ /** Agent revisions on the path: one per round. */
168
+ rounds: number;
169
+ }
170
+
171
+ /** Everything a viewer sees on the current path, folded from its entries. */
172
+ export function derivePath(history: SessionHistory, fromId?: string): DerivedPath {
173
+ const path = pathOf(history, fromId);
174
+ const revisions = path.filter((entry) => entry.type === "revision");
175
+ const head = revisions.at(-1);
176
+
177
+ if (!head) throw new HistoryError("not-a-revision", "a path without a revision has no head");
178
+ const open = new Set<string>();
179
+ const verdicts: Verdict[] = [];
180
+ const summaries: Array<SessionEntry & { type: "branch-summary" }> = [];
181
+
182
+ for (const entry of path) {
183
+ if (entry.type === "comment") open.add(entry.annotationId);
184
+ if (entry.type === "comment-removed") open.delete(entry.annotationId);
185
+ if (entry.type === "verdict") verdicts.push(entry.verdict);
186
+ if (entry.type === "branch-summary") summaries.push(entry);
187
+ }
188
+
189
+ return {
190
+ head,
191
+ annotationIds: [...open],
192
+ verdicts,
193
+ summaries,
194
+ rounds: revisions.filter((entry) => entry.by === "agent").length,
195
+ };
196
+ }
197
+
198
+ /* ----------------------------------------------------------------- writes */
199
+
200
+ /** Append an entry after the current branch's tip and move the tip onto it. */
201
+ export function appendEntry(history: SessionHistory, entry: NewEntry): AppendResult {
202
+ const parentId = tipOf(history);
203
+ // SAFETY: the spread keeps `type` and its fields intact; only the tree
204
+ // pointers are added, which the union's every member carries
205
+ const full = { ...entry, id: entry.id ?? newEntryId(), parentId } as SessionEntry;
206
+
207
+ return {
208
+ history: {
209
+ ...history,
210
+ entries: [...history.entries, full],
211
+ tips: { ...history.tips, [history.branch]: full.id },
212
+ },
213
+ entry: full,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Replace the text of the agent's current revision on `main` in place. A
219
+ * re-capture of a working tree is the revision the agent is still working on,
220
+ * not a new one, so nothing is appended.
221
+ */
222
+ export function recaptureMainHead(history: SessionHistory, content: string): SessionHistory {
223
+ const head = pathOf(history, tipOf(history, MAIN_BRANCH))
224
+ .filter((entry) => entry.type === "revision" && entry.by === "agent")
225
+ .at(-1);
226
+
227
+ if (!head) throw new HistoryError("not-a-revision", `"${MAIN_BRANCH}" has no agent revision`);
228
+
229
+ return {
230
+ ...history,
231
+ entries: history.entries.map((entry) => (entry.id === head.id ? { ...entry, content } : entry)),
232
+ };
233
+ }
234
+
235
+ /** Name the current tip as a checkpoint. */
236
+ export function labelTip(history: SessionHistory, label: string): SessionHistory {
237
+ return { ...history, labels: { ...history.labels, [tipOf(history)]: label } };
238
+ }
239
+
240
+ /** Start a branch at the current tip and switch to it; `main` is untouched. */
241
+ export function createBranch(history: SessionHistory, name: string): SessionHistory {
242
+ if (history.tips[name] !== undefined) {
243
+ throw new HistoryError("branch-exists", `branch "${name}" exists`);
244
+ }
245
+
246
+ return { ...history, tips: { ...history.tips, [name]: tipOf(history) }, branch: name };
247
+ }
248
+
249
+ export function switchBranch(history: SessionHistory, name: string): SessionHistory {
250
+ tipOf(history, name);
251
+
252
+ return { ...history, branch: name };
253
+ }
254
+
255
+ export interface NavigateOptions {
256
+ /** Record the abandoned segment as a branch summary parented at the target. */
257
+ summary?: string;
258
+ createdAt?: string;
259
+ }
260
+
261
+ /**
262
+ * Move the current branch's tip to an earlier entry on its path. The entries
263
+ * after it stay in the tree; with a summary, a branch-summary entry that
264
+ * names them is appended at the target and becomes the tip.
265
+ */
266
+ export function navigateTo(
267
+ history: SessionHistory,
268
+ targetId: string,
269
+ options: NavigateOptions = {},
270
+ ): SessionHistory {
271
+ if (!entryOf(history, targetId)) {
272
+ throw new HistoryError("unknown-entry", `no entry "${targetId}"`);
273
+ }
274
+ const tip = tipOf(history);
275
+
276
+ if (targetId === tip) throw new HistoryError("already-there", "already at that entry");
277
+ const path = pathOf(history);
278
+ const index = path.findIndex((entry) => entry.id === targetId);
279
+
280
+ if (index === -1) {
281
+ throw new HistoryError(
282
+ "off-path",
283
+ `"${targetId}" is not on branch "${history.branch}" - switch branch first`,
284
+ );
285
+ }
286
+ const abandoned = path.slice(index + 1).map((entry) => entry.id);
287
+ const moved: SessionHistory = {
288
+ ...history,
289
+ tips: { ...history.tips, [history.branch]: targetId },
290
+ };
291
+
292
+ if (options.summary === undefined) return moved;
293
+
294
+ return appendEntry(moved, {
295
+ type: "branch-summary",
296
+ text: options.summary,
297
+ abandoned,
298
+ createdAt: options.createdAt ?? new Date().toISOString(),
299
+ }).history;
300
+ }
301
+
302
+ /**
303
+ * A fork's history: the current path copied as one branch, keeping comments
304
+ * and labels on it, dropping verdicts and the reviewer's edits, with the tip
305
+ * at the copied head. The kept entries are chained anew so no entry points at
306
+ * one that was dropped.
307
+ */
308
+ export function forkHistory(history: SessionHistory): SessionHistory {
309
+ const kept: SessionEntry[] = [];
310
+ const labels: Record<string, string> = {};
311
+
312
+ for (const entry of pathOf(history)) {
313
+ if (entry.type === "verdict" || (entry.type === "revision" && entry.by === "reviewer"))
314
+ continue;
315
+ kept.push({ ...entry, parentId: kept.at(-1)?.id ?? null });
316
+ const label = history.labels[entry.id];
317
+
318
+ if (label !== undefined) labels[entry.id] = label;
319
+ }
320
+ const tip = kept.at(-1);
321
+
322
+ if (!tip) throw new HistoryError("not-a-revision", "nothing to fork");
323
+
324
+ return { entries: kept, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels };
325
+ }
326
+
327
+ /**
328
+ * The history as a share carries it: one branch's path and nothing else, so a
329
+ * collaborator sees that branch wherever the owner stands. Labels on the path
330
+ * travel; other branches and their entries do not.
331
+ */
332
+ export function followBranch(history: SessionHistory, branch: string): SessionHistory {
333
+ const entries = pathOf(history, tipOf(history, branch));
334
+ const labels: Record<string, string> = {};
335
+
336
+ for (const entry of entries) {
337
+ const label = history.labels[entry.id];
338
+
339
+ if (label !== undefined) labels[entry.id] = label;
340
+ }
341
+
342
+ return { entries, tips: { [branch]: entries.at(-1)!.id }, branch, labels };
343
+ }
344
+
345
+ /** The comment removals a history records, in entry order: what a merge carries across a share. */
346
+ export function removalEntries(
347
+ history: SessionHistory,
348
+ ): Array<SessionEntry & { type: "comment-removed" }> {
349
+ return history.entries.filter(
350
+ (entry): entry is SessionEntry & { type: "comment-removed" } =>
351
+ entry.type === "comment-removed",
352
+ );
353
+ }
354
+
355
+ /* -------------------------------------------------------------- migration */
356
+
357
+ /**
358
+ * A linear session as a one-branch tree: its revisions chained on `main` as
359
+ * agent revisions, its comments attached after the revision that was current
360
+ * when they were made, its verdict (when resolved) after the last revision.
361
+ * Deterministic: the same record migrates to the same ids.
362
+ */
363
+ export function historyFromLinear(
364
+ session: Pick<ReviewSession, "id" | "revisions" | "annotations" | "verdict" | "createdAt">,
365
+ ): SessionHistory {
366
+ const entries: SessionEntry[] = [];
367
+ let parentId: string | null = null;
368
+ const push = (entry: DistributiveOmit<SessionEntry, "parentId">): void => {
369
+ // SAFETY: only the parent pointer is added; the union member is untouched
370
+ const full = { ...entry, parentId } as SessionEntry;
371
+
372
+ entries.push(full);
373
+ parentId = full.id;
374
+ };
375
+ const revisions = [...session.revisions].sort((left, right) => left.revision - right.revision);
376
+ const comments = [...session.annotations].sort((left, right) =>
377
+ left.createdAt.localeCompare(right.createdAt),
378
+ );
379
+ let nextComment = 0;
380
+
381
+ revisions.forEach((revision, index) => {
382
+ push({
383
+ id: `${session.id}_rev${revision.revision}`,
384
+ type: "revision",
385
+ by: "agent",
386
+ content: revision.content,
387
+ createdAt: revision.submittedAt,
388
+ });
389
+ const until = revisions[index + 1]?.submittedAt;
390
+
391
+ while (
392
+ nextComment < comments.length &&
393
+ (until === undefined || comments[nextComment]!.createdAt < until)
394
+ ) {
395
+ const annotation: Annotation = comments[nextComment]!;
396
+
397
+ push({
398
+ id: `${session.id}_com_${annotation.id}`,
399
+ type: "comment",
400
+ annotationId: annotation.id,
401
+ createdAt: annotation.createdAt,
402
+ });
403
+ nextComment++;
404
+ }
405
+ });
406
+ if (session.verdict) {
407
+ push({
408
+ id: `${session.id}_verdict`,
409
+ type: "verdict",
410
+ verdict: session.verdict,
411
+ createdAt: session.verdict.resolvedAt,
412
+ });
413
+ }
414
+ const tip = entries.at(-1);
415
+
416
+ if (!tip) throw new HistoryError("not-a-revision", `session ${session.id} has no revision`);
417
+
418
+ return { entries, tips: { [MAIN_BRANCH]: tip.id }, branch: MAIN_BRANCH, labels: {} };
419
+ }
package/src/index.ts CHANGED
@@ -8,3 +8,5 @@ export * from "./anchor";
8
8
  export * from "./fuzzy";
9
9
  export * from "./feedback";
10
10
  export * from "./herdr";
11
+ export * from "./history";
12
+ export * from "./path-view";
@@ -0,0 +1,201 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appendEntry,
4
+ createBranch,
5
+ historyFromLinear,
6
+ navigateTo,
7
+ switchBranch,
8
+ type SessionHistory,
9
+ } from "./history";
10
+ import { applyPathView, viewFollowing, viewOfPath } from "./path-view";
11
+ import type { Annotation, ReviewSession } from "./types";
12
+
13
+ const AT = "2026-09-01T10:00:00.000Z";
14
+
15
+ function annotation(id: string): Annotation {
16
+ return {
17
+ id,
18
+ kind: "comment",
19
+ anchor: { quote: "q", prefix: "", suffix: "" },
20
+ body: id,
21
+ createdAt: AT,
22
+ };
23
+ }
24
+
25
+ /** rev1 (agent) -> comment a1 -> reviewer edit -> comment a2, all on main. */
26
+ function threaded(): SessionHistory {
27
+ let history = historyFromLinear({
28
+ id: "ses_1",
29
+ revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
30
+ annotations: [],
31
+ verdict: null,
32
+ createdAt: AT,
33
+ });
34
+
35
+ history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
36
+ history = appendEntry(history, {
37
+ type: "revision",
38
+ by: "reviewer",
39
+ content: "Plan v1 edited",
40
+ createdAt: AT,
41
+ }).history;
42
+ history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
43
+
44
+ return history;
45
+ }
46
+
47
+ describe("viewOfPath", () => {
48
+ test("the artifact is the agent's last revision and the reviewer's head is the working copy", () => {
49
+ // Act
50
+ const view = viewOfPath(threaded(), [annotation("a1"), annotation("a2")]);
51
+
52
+ // Assert
53
+ expect(view.content).toBe("Plan v1");
54
+ expect(view.workingCopy).toBe("Plan v1 edited");
55
+ expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
56
+ expect(view.shelvedAnnotations).toEqual([]);
57
+ });
58
+
59
+ test("navigating back shelves the comments the path no longer reaches, and forward again unshelves them", () => {
60
+ // Arrange: a branch that stays at the full path, main moved back to the first comment
61
+ let history = createBranch(threaded(), "full");
62
+ const known = [annotation("a1"), annotation("a2")];
63
+
64
+ history = switchBranch(history, "main");
65
+ history = navigateTo(history, history.entries[1]!.id);
66
+
67
+ // Act
68
+ const back = viewOfPath(history, known);
69
+ const forward = viewOfPath(
70
+ switchBranch(history, "full"),
71
+ back.annotations.concat(back.shelvedAnnotations),
72
+ );
73
+
74
+ // Assert
75
+ expect(back.workingCopy).toBeUndefined();
76
+ expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
77
+ expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
78
+ expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
79
+ expect(forward.shelvedAnnotations).toEqual([]);
80
+ });
81
+
82
+ test("a removed comment stays shelved, not deleted", () => {
83
+ // Arrange
84
+ let history = threaded();
85
+
86
+ history = appendEntry(history, {
87
+ type: "comment-removed",
88
+ annotationId: "a1",
89
+ createdAt: AT,
90
+ }).history;
91
+
92
+ // Act
93
+ const view = viewOfPath(history, [annotation("a1"), annotation("a2")]);
94
+
95
+ // Assert
96
+ expect(view.annotations.map((entry) => entry.id)).toEqual(["a2"]);
97
+ expect(view.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a1"]);
98
+ });
99
+ });
100
+
101
+ describe("applyPathView", () => {
102
+ test("writes the view into the record and removes the fields the view leaves empty", () => {
103
+ // Arrange
104
+ const session: ReviewSession = {
105
+ schemaVersion: "1",
106
+ id: "ses_1",
107
+ workspace: { repoRoot: "/repo", branch: "main" },
108
+ artifact: { type: "plan", content: "old", meta: {} },
109
+ revisions: [],
110
+ annotations: [annotation("a2")],
111
+ shelvedAnnotations: [annotation("a1")],
112
+ workingCopy: "edited",
113
+ verdict: null,
114
+ status: "pending",
115
+ createdAt: AT,
116
+ };
117
+
118
+ // Act
119
+ applyPathView(session, {
120
+ content: "Plan v1",
121
+ workingCopy: undefined,
122
+ annotations: [annotation("a1"), annotation("a2")],
123
+ shelvedAnnotations: [],
124
+ });
125
+
126
+ // Assert
127
+ expect(session.artifact.content).toBe("Plan v1");
128
+ expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
129
+ expect("workingCopy" in session).toBe(false);
130
+ expect("shelvedAnnotations" in session).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe("viewFollowing", () => {
135
+ test("a share carries one branch's path: its entries, tip, labels, and open comments, nothing shelved", () => {
136
+ // Arrange: main has a1 and an edit; a branch off a1 has its own comment; the owner stands on the branch
137
+ let history = threaded();
138
+
139
+ history = navigateTo(history, history.entries[1]!.id);
140
+ history = createBranch(history, "alt");
141
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
142
+ const session: ReviewSession = {
143
+ schemaVersion: "1",
144
+ id: "ses_1",
145
+ workspace: { repoRoot: "/repo", branch: "main" },
146
+ artifact: { type: "plan", content: "Plan v1", meta: {} },
147
+ revisions: [],
148
+ annotations: [annotation("a1"), annotation("b1")],
149
+ shelvedAnnotations: [annotation("a2")],
150
+ workingCopy: "Plan v1 edited",
151
+ history,
152
+ verdict: null,
153
+ status: "pending",
154
+ createdAt: AT,
155
+ };
156
+
157
+ // Act
158
+ const shared = viewFollowing(session);
159
+
160
+ // Assert: main's path only, back at the first comment, no working copy, no shelf, the branch named
161
+ expect(shared.shareBranch).toBe("main");
162
+ expect(shared.history!.tips).toEqual({ main: history.entries[1]!.id });
163
+ expect(shared.history!.entries.map((entry) => entry.type)).toEqual(["revision", "comment"]);
164
+ expect(shared.annotations.map((entry) => entry.id)).toEqual(["a1"]);
165
+ expect("shelvedAnnotations" in shared).toBe(false);
166
+ expect("workingCopy" in shared).toBe(false);
167
+ // the owner's record is untouched
168
+ expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "b1"]);
169
+ });
170
+
171
+ test("a followed branch that no longer exists falls back to main", () => {
172
+ // Arrange: a plain main-only history, asked to follow a branch that is not there
173
+ const history = historyFromLinear({
174
+ id: "ses_1",
175
+ revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
176
+ annotations: [],
177
+ verdict: null,
178
+ createdAt: AT,
179
+ });
180
+ const session: ReviewSession = {
181
+ schemaVersion: "1",
182
+ id: "ses_1",
183
+ workspace: { repoRoot: "/repo", branch: "main" },
184
+ artifact: { type: "plan", content: "Plan v1", meta: {} },
185
+ revisions: [],
186
+ annotations: [],
187
+ shareBranch: "gone",
188
+ history,
189
+ verdict: null,
190
+ status: "pending",
191
+ createdAt: AT,
192
+ };
193
+
194
+ // Act
195
+ const shared = viewFollowing(session);
196
+
197
+ // Assert: it does not throw, and falls back to main
198
+ expect(shared.shareBranch).toBe("main");
199
+ expect(shared.history!.branch).toBe("main");
200
+ });
201
+ });