prreviewbuddy 0.93.0 → 0.96.0

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.
@@ -1,13 +1,425 @@
1
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
- import { execFile, execFileSync, spawn } from "node:child_process";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
2
  import { accessSync, appendFileSync, closeSync, constants, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { homedir, hostname, tmpdir } from "node:os";
5
- import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { execFile, execFileSync, spawn } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { platform } from "node:process";
9
9
  import { connect } from "node:net";
10
10
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
11
+ //#region ../../packages/review-harness/src/workspace/store_root.ts
12
+ /**
13
+ * Where everything this product keeps on a machine lives, and nothing else.
14
+ *
15
+ * A leaf on purpose. It was a line in `store.ts`, which is the right place for it right up until
16
+ * something needs the path without needing reviews: the postinstall script asks one question of
17
+ * this directory (has this machine used PR Review Buddy before) and must not pull a review model,
18
+ * a diff parser and a pull request extractor into its bundle to ask it. `store.ts` re-exports this,
19
+ * so every existing import is unchanged and there is still one definition.
20
+ *
21
+ * It is also the one path `uninstall.ts` is allowed to delete outright, which is a property worth
22
+ * being able to point at a single file for.
23
+ */
24
+ var STORE_ROOT = join(homedir(), ".prreviewbuddy");
25
+ //#endregion
26
+ //#region ../../packages/review-harness/src/workspace/write_lock.ts
27
+ /**
28
+ * One lock file per name, taken exclusively, so a read-change-write in one process cannot land in
29
+ * the middle of another's.
30
+ *
31
+ * Lifted out of `store.ts` unchanged, so the review queue can use it too. `queue.ts` cannot import
32
+ * the store (the store's prune imports the queue, and the reverse import would close the cycle), and
33
+ * a second lock written for the queue alone would be a second set of rules about stale holders to
34
+ * get right. This module depends on `store_root.ts` and on Node, nothing else, for the same reason.
35
+ *
36
+ * Names share one directory. A review is locked under its id, a UUID, so a fixed word such as
37
+ * `queue` can never collide with one.
38
+ */
39
+ /** Raised when another process has held a review's write lock for longer than any write takes. */
40
+ var WorkspaceWriteBusyError = class extends Error {
41
+ constructor() {
42
+ super("This review is being written by another process. Try again in a moment.");
43
+ this.name = "WorkspaceWriteBusyError";
44
+ }
45
+ };
46
+ var WRITE_LOCKS_DIR = join(STORE_ROOT, "locks");
47
+ /** Every write under the lock is one read and one rename, milliseconds. This is a hang, not a queue. */
48
+ var WRITE_LOCK_WAIT_MS = 5e3;
49
+ /**
50
+ * A lock this old is broken whoever holds it.
51
+ *
52
+ * Every hold is synchronous and lasts one read and one rename, so no live writer is anywhere near
53
+ * this. It is the fallback for what a pid cannot say: a holder on another machine, a lock not yet
54
+ * written out, and a pid that died and was reused by some unrelated long-lived process, which a
55
+ * liveness check alone would read as the holder still working, for ever.
56
+ */
57
+ var WRITE_LOCK_STALE_MS = 3e4;
58
+ var heldWriteLocks = /* @__PURE__ */ new Map();
59
+ var sleeper = new Int32Array(new SharedArrayBuffer(4));
60
+ /**
61
+ * One review's writes, one at a time across every process on the machine.
62
+ *
63
+ * The CLI and the workspace server are separate processes over one store. Without this, each could
64
+ * load the same review, change its own fields and rename its result into place, and whichever
65
+ * renamed last would discard the other: an update finishing in the terminal would put back a
66
+ * question the reviewer had just ticked on the page. A file created exclusively is the lock, so it
67
+ * holds between processes and needs nothing running to arbitrate.
68
+ *
69
+ * Synchronous, including the wait, because every write it guards is. It is held for a read and a
70
+ * rename, so a wait is milliseconds; one that runs to `WRITE_LOCK_WAIT_MS` means a holder has hung,
71
+ * and that is raised rather than written through. Re-entrant within a process, so a change made
72
+ * under it can save without deadlocking itself. A lock left by a process that died is broken: at
73
+ * once when its holder is on this machine and gone, and after `WRITE_LOCK_STALE_MS` in every case,
74
+ * because a pid can be reused and a live one proves nothing about who took the lock.
75
+ */
76
+ function withWriteLock(id, fn) {
77
+ const depth = heldWriteLocks.get(id) ?? 0;
78
+ if (depth > 0) {
79
+ heldWriteLocks.set(id, depth + 1);
80
+ try {
81
+ return fn();
82
+ } finally {
83
+ heldWriteLocks.set(id, depth);
84
+ }
85
+ }
86
+ mkdirSync(WRITE_LOCKS_DIR, { recursive: true });
87
+ const path = join(WRITE_LOCKS_DIR, `${id}.lock`);
88
+ const mine = {
89
+ token: randomUUID(),
90
+ pid: process.pid,
91
+ host: hostname(),
92
+ at: Date.now()
93
+ };
94
+ const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
95
+ for (;;) {
96
+ try {
97
+ const fd = openSync(path, "wx", 384);
98
+ try {
99
+ writeSync(fd, JSON.stringify(mine));
100
+ } finally {
101
+ closeSync(fd);
102
+ }
103
+ break;
104
+ } catch (error) {
105
+ if (error.code !== "EEXIST") throw error;
106
+ }
107
+ if (breakIfAbandoned(path)) continue;
108
+ if (Date.now() >= deadline) throw new WorkspaceWriteBusyError();
109
+ Atomics.wait(sleeper, 0, 0, 2);
110
+ }
111
+ heldWriteLocks.set(id, 1);
112
+ try {
113
+ return fn();
114
+ } finally {
115
+ heldWriteLocks.delete(id);
116
+ if (readHolder(path)?.token === mine.token) try {
117
+ unlinkSync(path);
118
+ } catch {}
119
+ }
120
+ }
121
+ function readHolder(path) {
122
+ try {
123
+ return JSON.parse(readFileSync(path, "utf8"));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** Removes a lock whose holder is gone, and says whether it did. */
129
+ function breakIfAbandoned(path) {
130
+ const holder = readHolder(path);
131
+ let age = 0;
132
+ try {
133
+ age = Date.now() - Math.min(statSync(path).mtimeMs, holder?.at ?? Infinity);
134
+ } catch {
135
+ return true;
136
+ }
137
+ if (!(age > WRITE_LOCK_STALE_MS || holder !== null && holder.host === hostname() && !processAlive(holder.pid))) return false;
138
+ if (readHolder(path)?.token !== holder?.token) return true;
139
+ try {
140
+ unlinkSync(path);
141
+ } catch {}
142
+ return true;
143
+ }
144
+ function processAlive(pid) {
145
+ try {
146
+ process.kill(pid, 0);
147
+ return true;
148
+ } catch (error) {
149
+ return error.code === "EPERM";
150
+ }
151
+ }
152
+ //#endregion
153
+ //#region ../../packages/review-harness/src/workspace/collections.ts
154
+ /**
155
+ * Collections: the reviewer's own groups of reviews, such as everything in one refactor.
156
+ *
157
+ * A collection says what belongs together and nothing else. Whether a member still needs attention
158
+ * is its lifecycle, which this file never reads or writes: assigning, removing, renaming and
159
+ * deleting all leave every review's state exactly where it was.
160
+ *
161
+ * **Members are lineage keys, not review ids.** A card is a branch, and analysing the branch again
162
+ * makes a new record with a new id. The queue stores ids and has to forward them (`forwardQueue`);
163
+ * a lineage key is already what the card stands for, so membership survives updates, re-analysis
164
+ * and restarts with nothing to keep in step.
165
+ *
166
+ * **Its own file**, for the reasons `queue.ts` gives: a record is rewritten whole by whoever loaded
167
+ * it last and carries its diff inline, and `ui.json` is best-effort. And because a write here never
168
+ * touches a record, it never moves the record's mtime, which is the clock retention runs on.
169
+ * Organising a review is not opening or marking it.
170
+ *
171
+ * `STORE_ROOT` from `store_root.ts` rather than `store.ts`, for the import cycle `queue.ts` explains:
172
+ * the prune in `store.ts` reads this file.
173
+ */
174
+ var COLLECTIONS_PATH = join(STORE_ROOT, "collections.json");
175
+ /** The lock's name. Reviews are locked under UUIDs in the same directory, so a word cannot collide. */
176
+ var COLLECTIONS_LOCK = "collections";
177
+ var VERSION$2 = 1;
178
+ var ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
179
+ /** Whether a value is shaped like a collection's id, which is what a fold key and a route accept. */
180
+ function isCollectionId$1(value) {
181
+ return typeof value === "string" && ID_PATTERN.test(value);
182
+ }
183
+ /**
184
+ * Every collection, in the order they were created.
185
+ *
186
+ * Best-effort like `readQueue`: missing, unreadable and corrupt all mean "no collections". An entry
187
+ * this code did not write is dropped rather than guessed at, and so is a second entry claiming an id
188
+ * or a name already taken, so the rules `createCollection` keeps hold for a hand-edited file too.
189
+ */
190
+ function readCollections() {
191
+ try {
192
+ const parsed = JSON.parse(readFileSync(COLLECTIONS_PATH, "utf8"));
193
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
194
+ const collections = parsed.collections;
195
+ return Array.isArray(collections) ? normalise$1(collections) : [];
196
+ } catch {
197
+ return [];
198
+ }
199
+ }
200
+ function normalise$1(raw) {
201
+ const kept = [];
202
+ for (const entry of raw) {
203
+ if (kept.length >= 100) break;
204
+ if (!entry || typeof entry !== "object") continue;
205
+ const { id, name, colour, createdAt, members } = entry;
206
+ const clean = typeof name === "string" ? cleanName(name) : "";
207
+ if (!isCollectionId$1(id) || clean === "" || clean.length > 60) continue;
208
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) continue;
209
+ if (kept.some((other) => other.id === id || sameName(other.name, clean))) continue;
210
+ const keys = Array.isArray(members) ? members.filter((key) => typeof key === "string" && key !== "") : [];
211
+ kept.push({
212
+ id,
213
+ name: clean,
214
+ ...typeof colour === "string" && colour !== "" ? { colour } : {},
215
+ createdAt,
216
+ members: [...new Set(keys)].slice(0, 500)
217
+ });
218
+ }
219
+ return kept;
220
+ }
221
+ /** Spaces trimmed and runs of them collapsed, so " Release 0.82 " is "Release 0.82". */
222
+ function cleanName(name) {
223
+ return name.trim().replace(/\s+/g, " ");
224
+ }
225
+ /** Two names are one collection's name regardless of case: "refactor" is not a second Refactor. */
226
+ function sameName(a, b) {
227
+ return a.localeCompare(b, void 0, { sensitivity: "base" }) === 0;
228
+ }
229
+ /**
230
+ * The collection a typed name means, by the same rule that keeps names unique, so at most one can
231
+ * match. By name only: a name that happens to look like an id is still a name.
232
+ */
233
+ function collectionNamed(name, collections) {
234
+ const clean = cleanName(name);
235
+ return collections.find((collection) => sameName(collection.name, clean));
236
+ }
237
+ function nameProblem(name, collections, except) {
238
+ const clean = cleanName(name);
239
+ if (clean === "") return "empty";
240
+ if (clean.length > 60) return "too-long";
241
+ if (collections.some((collection) => collection.id !== except && sameName(collection.name, clean))) return "duplicate";
242
+ }
243
+ /** What a refused change says, in the words the page shows. */
244
+ function nameProblemText(problem) {
245
+ if (problem === "empty") return "Give the collection a name.";
246
+ if (problem === "too-long") return `Use at most 60 characters for the name.`;
247
+ return "A collection with that name already exists.";
248
+ }
249
+ /**
250
+ * Change the collections as they are on disk now, under the lock: the only way they are written.
251
+ * Same contract as `updateQueue`: `change` returns what to store or null for nothing, and null from
252
+ * here means the write failed.
253
+ */
254
+ function updateCollections(change) {
255
+ try {
256
+ return withWriteLock(COLLECTIONS_LOCK, () => {
257
+ const current = readCollections();
258
+ const next = change(current);
259
+ return next === null ? current : writeUnlocked(next);
260
+ });
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ /** To a temporary file and renamed into place, for the reason `writeQueueUnlocked` gives. */
266
+ function writeUnlocked(collections) {
267
+ const next = normalise$1(collections);
268
+ mkdirSync(STORE_ROOT, { recursive: true });
269
+ const stored = {
270
+ version: VERSION$2,
271
+ collections: next
272
+ };
273
+ const temporary = `${COLLECTIONS_PATH}.${process.pid}.tmp`;
274
+ try {
275
+ writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
276
+ renameSync(temporary, COLLECTIONS_PATH);
277
+ } catch (error) {
278
+ try {
279
+ unlinkSync(temporary);
280
+ } catch {}
281
+ throw error;
282
+ }
283
+ return next;
284
+ }
285
+ function run$3(change) {
286
+ const outcome = {};
287
+ const stored = updateCollections((current) => {
288
+ const result = change(current);
289
+ if ("refused" in result) {
290
+ outcome.refused = result.refused;
291
+ return null;
292
+ }
293
+ outcome.id = result.id;
294
+ return result.next;
295
+ });
296
+ if (outcome.refused !== void 0) return {
297
+ ok: false,
298
+ reason: outcome.refused
299
+ };
300
+ if (stored === null) return {
301
+ ok: false,
302
+ reason: "unsaved"
303
+ };
304
+ return {
305
+ ok: true,
306
+ collections: stored,
307
+ ...outcome.id === void 0 ? {} : { id: outcome.id }
308
+ };
309
+ }
310
+ function createCollection(name, now = Date.now()) {
311
+ return run$3((current) => {
312
+ const problem = nameProblem(name, current);
313
+ if (problem) return { refused: problem };
314
+ if (current.length >= 100) return { refused: "full" };
315
+ const id = randomUUID();
316
+ return {
317
+ next: [...current, {
318
+ id,
319
+ name: cleanName(name),
320
+ createdAt: now,
321
+ members: []
322
+ }],
323
+ id
324
+ };
325
+ });
326
+ }
327
+ /**
328
+ * A new collection holding these lineages from the start: one transaction, so it is made with all of
329
+ * them or not at all. "Create Refactor and add these three" never leaves an empty Refactor behind a
330
+ * failure, and a retry never meets a duplicate name of its own making.
331
+ */
332
+ function createWithMembers(name, keys, now = Date.now()) {
333
+ const members = [...new Set(keys.filter((key) => key !== ""))];
334
+ return run$3((current) => {
335
+ const problem = nameProblem(name, current);
336
+ if (problem) return { refused: problem };
337
+ if (current.length >= 100 || members.length > 500) return { refused: "full" };
338
+ const id = randomUUID();
339
+ return {
340
+ next: [...current, {
341
+ id,
342
+ name: cleanName(name),
343
+ createdAt: now,
344
+ members
345
+ }],
346
+ id
347
+ };
348
+ });
349
+ }
350
+ /** A new name on the same collection: its id, its members and every fold kept against the id stay. */
351
+ function renameCollection(id, name) {
352
+ return run$3((current) => {
353
+ if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
354
+ const problem = nameProblem(name, current, id);
355
+ if (problem) return { refused: problem };
356
+ return {
357
+ next: current.map((collection) => collection.id === id ? {
358
+ ...collection,
359
+ name: cleanName(name)
360
+ } : collection),
361
+ id
362
+ };
363
+ });
364
+ }
365
+ /** The collection and its memberships go. No review is touched: they were never inside it. */
366
+ function deleteCollection(id) {
367
+ return run$3((current) => {
368
+ if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
369
+ return { next: current.filter((collection) => collection.id !== id) };
370
+ });
371
+ }
372
+ /**
373
+ * Add the given lineages to one collection, or take them out of it.
374
+ *
375
+ * **Only the named collection is touched.** Assigning a selection to Refactor leaves every other
376
+ * membership those reviews have exactly as it was, which is what makes a bulk assignment additive.
377
+ * Adding a member already there and removing one that is not are both nothing, not errors: a mixed
378
+ * selection is the ordinary case.
379
+ */
380
+ function setMembership(keys, id, action) {
381
+ return run$3((current) => {
382
+ const target = current.find((collection) => collection.id === id);
383
+ if (!target) return { refused: "missing" };
384
+ const members = action === "add" ? [...target.members, ...keys.filter((key, at) => key !== "" && !target.members.includes(key) && keys.indexOf(key) === at)] : target.members.filter((key) => !keys.includes(key));
385
+ if (members.length === target.members.length && members.every((key, at) => key === target.members[at])) return {
386
+ next: null,
387
+ id
388
+ };
389
+ if (members.length > 500) return { refused: "full" };
390
+ return {
391
+ next: current.map((collection) => collection.id === id ? {
392
+ ...collection,
393
+ members
394
+ } : collection),
395
+ id
396
+ };
397
+ });
398
+ }
399
+ /**
400
+ * Take lineages out of every collection, because their reviews are gone. Stores nothing when none
401
+ * of them was a member, so a deletion of an uncollected review never writes this file.
402
+ */
403
+ function dropMembers(keys) {
404
+ const gone = new Set(keys);
405
+ return updateCollections((current) => current.some((collection) => collection.members.some((key) => gone.has(key))) ? current.map((collection) => ({
406
+ ...collection,
407
+ members: collection.members.filter((key) => !gone.has(key))
408
+ })) : null);
409
+ }
410
+ /** Every lineage in at least one collection. What retention asks: collected is kept. */
411
+ function collectedKeys(collections = readCollections()) {
412
+ return new Set(collections.flatMap((collection) => collection.members));
413
+ }
414
+ /** A token for the index's revision, as `queueStamp` is: it changes only when a person changes it. */
415
+ function collectionsStamp() {
416
+ try {
417
+ return String(statSync(COLLECTIONS_PATH).mtimeMs);
418
+ } catch {
419
+ return "";
420
+ }
421
+ }
422
+ //#endregion
11
423
  //#region ../../packages/review-harness/src/agents/agent.ts
12
424
  /**
13
425
  * The variable every spawned agent runs under, and the structural half of containment.
@@ -327,7 +739,7 @@ function defaultConfigEnv(base) {
327
739
  return env;
328
740
  }
329
741
  /** `claude` with these arguments, never through a shell. Resolves with its output. */
330
- function run$3(args, env) {
742
+ function run$2(args, env) {
331
743
  return new Promise((resolvePromise, reject) => {
332
744
  execFile("claude", [...args], configCommandOptions(env), (error, stdout, stderr) => {
333
745
  const settled = settleConfigCommand("claude", error, stdout, stderr);
@@ -396,10 +808,10 @@ var claudeMcpRegistration = {
396
808
  configPath: configPath$1,
397
809
  read: read$1,
398
810
  async add(entry, base = process.env) {
399
- await run$3(addCommand$1(entry).slice(1), defaultConfigEnv(base));
811
+ await run$2(addCommand$1(entry).slice(1), defaultConfigEnv(base));
400
812
  },
401
813
  async remove(base = process.env) {
402
- await run$3(removeCommand$1().slice(1), defaultConfigEnv(base));
814
+ await run$2(removeCommand$1().slice(1), defaultConfigEnv(base));
403
815
  },
404
816
  addCommand: addCommand$1,
405
817
  removeCommand: removeCommand$1,
@@ -1140,7 +1552,7 @@ function configPath(base = process.env) {
1140
1552
  return join(base.CODEX_HOME || join(base.HOME || homedir(), ".codex"), "config.toml");
1141
1553
  }
1142
1554
  /** `codex` with these arguments, never through a shell. Resolves with its output. */
1143
- function run$2(args, env) {
1555
+ function run$1(args, env) {
1144
1556
  return new Promise((resolvePromise, reject) => {
1145
1557
  execFile("codex", [...args], configCommandOptions(env), (error, stdout, stderr) => {
1146
1558
  const settled = settleConfigCommand("codex", error, stdout, stderr);
@@ -1153,7 +1565,7 @@ function run$2(args, env) {
1153
1565
  async function read(base = process.env) {
1154
1566
  let stdout;
1155
1567
  try {
1156
- stdout = await run$2([
1568
+ stdout = await run$1([
1157
1569
  "mcp",
1158
1570
  "get",
1159
1571
  MCP_SERVER_NAME,
@@ -1208,10 +1620,10 @@ var codexMcpRegistration = {
1208
1620
  configPath,
1209
1621
  read,
1210
1622
  async add(entry, base = process.env) {
1211
- await run$2(addCommand(entry).slice(1), base);
1623
+ await run$1(addCommand(entry).slice(1), base);
1212
1624
  },
1213
1625
  async remove(base = process.env) {
1214
- await run$2(removeCommand().slice(1), base);
1626
+ await run$1(removeCommand().slice(1), base);
1215
1627
  },
1216
1628
  addCommand,
1217
1629
  removeCommand,
@@ -1714,21 +2126,6 @@ async function detectAgents() {
1714
2126
  return detectAgents$1(AGENT_IDS.map((id) => AGENTS[id]));
1715
2127
  }
1716
2128
  //#endregion
1717
- //#region ../../packages/review-harness/src/workspace/store_root.ts
1718
- /**
1719
- * Where everything this product keeps on a machine lives, and nothing else.
1720
- *
1721
- * A leaf on purpose. It was a line in `store.ts`, which is the right place for it right up until
1722
- * something needs the path without needing reviews: the postinstall script asks one question of
1723
- * this directory (has this machine used PR Review Buddy before) and must not pull a review model,
1724
- * a diff parser and a pull request extractor into its bundle to ask it. `store.ts` re-exports this,
1725
- * so every existing import is unchanged and there is still one definition.
1726
- *
1727
- * It is also the one path `uninstall.ts` is allowed to delete outright, which is a property worth
1728
- * being able to point at a single file for.
1729
- */
1730
- var STORE_ROOT = join(homedir(), ".prreviewbuddy");
1731
- //#endregion
1732
2129
  //#region ../../packages/review-harness/src/workspace/config_file.ts
1733
2130
  /**
1734
2131
  * `config.json`, read and written as a whole so that no module can lose another's setting.
@@ -5829,154 +6226,27 @@ function issuesOutstanding(issues, dispositions) {
5829
6226
  const byConversation = issues.filter(resolvedByConversation).length;
5830
6227
  const byVerdict = issues.filter((issue) => issue.reconciliationStatus !== "resolved" && dispositions.isClosedByVerdict(issue)).length;
5831
6228
  return {
5832
- ...counts,
5833
- ...byConversation > 0 ? { byConversation } : {},
5834
- ...byVerdict > 0 ? { byVerdict } : {}
5835
- };
5836
- }
5837
- /** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
5838
- function resolvedByConversation(issue) {
5839
- return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
5840
- }
5841
- function questionsOutstanding(questions, answered, closedByVerdict) {
5842
- return tally(questions, (question) => isQuestionOutstanding(question, answered, closedByVerdict), (question) => question.reconciliationStatus !== "answered" && (answered.has(question.id) || closedByVerdict(question.id)));
5843
- }
5844
- /**
5845
- * What to call the pile of findings nobody has to act on any more.
5846
- *
5847
- * "Resolved" is a claim about the code and is only true while the commits did all the closing. One
5848
- * dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
5849
- * is acceptable did not resolve anything.
5850
- */
5851
- function doneVerb(counts) {
5852
- return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
5853
- }
5854
- //#endregion
5855
- //#region ../../packages/review-harness/src/workspace/write_lock.ts
5856
- /**
5857
- * One lock file per name, taken exclusively, so a read-change-write in one process cannot land in
5858
- * the middle of another's.
5859
- *
5860
- * Lifted out of `store.ts` unchanged, so the review queue can use it too. `queue.ts` cannot import
5861
- * the store (the store's prune imports the queue, and the reverse import would close the cycle), and
5862
- * a second lock written for the queue alone would be a second set of rules about stale holders to
5863
- * get right. This module depends on `store_root.ts` and on Node, nothing else, for the same reason.
5864
- *
5865
- * Names share one directory. A review is locked under its id, a UUID, so a fixed word such as
5866
- * `queue` can never collide with one.
5867
- */
5868
- /** Raised when another process has held a review's write lock for longer than any write takes. */
5869
- var WorkspaceWriteBusyError = class extends Error {
5870
- constructor() {
5871
- super("This review is being written by another process. Try again in a moment.");
5872
- this.name = "WorkspaceWriteBusyError";
5873
- }
5874
- };
5875
- var WRITE_LOCKS_DIR = join(STORE_ROOT, "locks");
5876
- /** Every write under the lock is one read and one rename, milliseconds. This is a hang, not a queue. */
5877
- var WRITE_LOCK_WAIT_MS = 5e3;
5878
- /**
5879
- * A lock this old is broken whoever holds it.
5880
- *
5881
- * Every hold is synchronous and lasts one read and one rename, so no live writer is anywhere near
5882
- * this. It is the fallback for what a pid cannot say: a holder on another machine, a lock not yet
5883
- * written out, and a pid that died and was reused by some unrelated long-lived process, which a
5884
- * liveness check alone would read as the holder still working, for ever.
5885
- */
5886
- var WRITE_LOCK_STALE_MS = 3e4;
5887
- var heldWriteLocks = /* @__PURE__ */ new Map();
5888
- var sleeper = new Int32Array(new SharedArrayBuffer(4));
5889
- /**
5890
- * One review's writes, one at a time across every process on the machine.
5891
- *
5892
- * The CLI and the workspace server are separate processes over one store. Without this, each could
5893
- * load the same review, change its own fields and rename its result into place, and whichever
5894
- * renamed last would discard the other: an update finishing in the terminal would put back a
5895
- * question the reviewer had just ticked on the page. A file created exclusively is the lock, so it
5896
- * holds between processes and needs nothing running to arbitrate.
5897
- *
5898
- * Synchronous, including the wait, because every write it guards is. It is held for a read and a
5899
- * rename, so a wait is milliseconds; one that runs to `WRITE_LOCK_WAIT_MS` means a holder has hung,
5900
- * and that is raised rather than written through. Re-entrant within a process, so a change made
5901
- * under it can save without deadlocking itself. A lock left by a process that died is broken: at
5902
- * once when its holder is on this machine and gone, and after `WRITE_LOCK_STALE_MS` in every case,
5903
- * because a pid can be reused and a live one proves nothing about who took the lock.
5904
- */
5905
- function withWriteLock(id, fn) {
5906
- const depth = heldWriteLocks.get(id) ?? 0;
5907
- if (depth > 0) {
5908
- heldWriteLocks.set(id, depth + 1);
5909
- try {
5910
- return fn();
5911
- } finally {
5912
- heldWriteLocks.set(id, depth);
5913
- }
5914
- }
5915
- mkdirSync(WRITE_LOCKS_DIR, { recursive: true });
5916
- const path = join(WRITE_LOCKS_DIR, `${id}.lock`);
5917
- const mine = {
5918
- token: randomUUID(),
5919
- pid: process.pid,
5920
- host: hostname(),
5921
- at: Date.now()
5922
- };
5923
- const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
5924
- for (;;) {
5925
- try {
5926
- const fd = openSync(path, "wx", 384);
5927
- try {
5928
- writeSync(fd, JSON.stringify(mine));
5929
- } finally {
5930
- closeSync(fd);
5931
- }
5932
- break;
5933
- } catch (error) {
5934
- if (error.code !== "EEXIST") throw error;
5935
- }
5936
- if (breakIfAbandoned(path)) continue;
5937
- if (Date.now() >= deadline) throw new WorkspaceWriteBusyError();
5938
- Atomics.wait(sleeper, 0, 0, 2);
5939
- }
5940
- heldWriteLocks.set(id, 1);
5941
- try {
5942
- return fn();
5943
- } finally {
5944
- heldWriteLocks.delete(id);
5945
- if (readHolder(path)?.token === mine.token) try {
5946
- unlinkSync(path);
5947
- } catch {}
5948
- }
6229
+ ...counts,
6230
+ ...byConversation > 0 ? { byConversation } : {},
6231
+ ...byVerdict > 0 ? { byVerdict } : {}
6232
+ };
5949
6233
  }
5950
- function readHolder(path) {
5951
- try {
5952
- return JSON.parse(readFileSync(path, "utf8"));
5953
- } catch {
5954
- return null;
5955
- }
6234
+ /** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
6235
+ function resolvedByConversation(issue) {
6236
+ return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
5956
6237
  }
5957
- /** Removes a lock whose holder is gone, and says whether it did. */
5958
- function breakIfAbandoned(path) {
5959
- const holder = readHolder(path);
5960
- let age = 0;
5961
- try {
5962
- age = Date.now() - Math.min(statSync(path).mtimeMs, holder?.at ?? Infinity);
5963
- } catch {
5964
- return true;
5965
- }
5966
- if (!(age > WRITE_LOCK_STALE_MS || holder !== null && holder.host === hostname() && !processAlive(holder.pid))) return false;
5967
- if (readHolder(path)?.token !== holder?.token) return true;
5968
- try {
5969
- unlinkSync(path);
5970
- } catch {}
5971
- return true;
6238
+ function questionsOutstanding(questions, answered, closedByVerdict) {
6239
+ return tally(questions, (question) => isQuestionOutstanding(question, answered, closedByVerdict), (question) => question.reconciliationStatus !== "answered" && (answered.has(question.id) || closedByVerdict(question.id)));
5972
6240
  }
5973
- function processAlive(pid) {
5974
- try {
5975
- process.kill(pid, 0);
5976
- return true;
5977
- } catch (error) {
5978
- return error.code === "EPERM";
5979
- }
6241
+ /**
6242
+ * What to call the pile of findings nobody has to act on any more.
6243
+ *
6244
+ * "Resolved" is a claim about the code and is only true while the commits did all the closing. One
6245
+ * dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
6246
+ * is acceptable did not resolve anything.
6247
+ */
6248
+ function doneVerb(counts) {
6249
+ return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
5980
6250
  }
5981
6251
  //#endregion
5982
6252
  //#region ../../packages/review-harness/src/workspace/queue.ts
@@ -6011,7 +6281,7 @@ var QUEUE_LOCK = "queue";
6011
6281
  * `version` is not branched on today. It exists so that a later shape change is a decision rather
6012
6282
  * than a guess about what an old file meant.
6013
6283
  */
6014
- var VERSION$2 = 1;
6284
+ var VERSION$1 = 1;
6015
6285
  /**
6016
6286
  * What is queued, in order.
6017
6287
  *
@@ -6021,250 +6291,66 @@ var VERSION$2 = 1;
6021
6291
  * two representations of one order eventually disagree about it.
6022
6292
  */
6023
6293
  function readQueue() {
6024
- try {
6025
- const parsed = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
6026
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
6027
- const ids = parsed.ids;
6028
- return Array.isArray(ids) ? normalise$1(ids) : [];
6029
- } catch {
6030
- return [];
6031
- }
6032
- }
6033
- /**
6034
- * Change the queue as it is on disk now: read, change, write, with nothing able to land in between.
6035
- *
6036
- * **The only way the queue is written.** The workspace server and the CLI are separate processes
6037
- * over this one file, and a `--queue` from the terminal can arrive while somebody is adding or
6038
- * removing on the page. Each of them reading the queue, changing its copy and writing it back would
6039
- * keep whichever wrote last and drop the other's change -- while both reported success. So the
6040
- * whole read-change-write runs under the queue's cross-process lock, the same lock a review's own
6041
- * writes take (see `write_lock.ts`).
6042
- *
6043
- * One lock, taken once, here. Every mutation below is one call to this and nothing inside `change`
6044
- * writes, so the transaction is visibly this function's body and nothing depends on the lock being
6045
- * re-entrant. `change` may read other files -- `queue_live.ts` asks which reviews still exist --
6046
- * but must not write the queue or take another lock.
6047
- *
6048
- * `change` returns the queue to store, or null to store nothing, in which case the queue as it was
6049
- * read is the answer. Null from this function means the write failed, including a lock held past
6050
- * any real write, which is the same thing to the caller: it did not happen.
6051
- */
6052
- function updateQueue(change) {
6053
- try {
6054
- return withWriteLock(QUEUE_LOCK, () => {
6055
- const current = readQueue();
6056
- const next = change(current);
6057
- return next === null ? current : writeQueueUnlocked(next);
6058
- });
6059
- } catch {
6060
- return null;
6061
- }
6062
- }
6063
- /**
6064
- * The write itself, for `updateQueue` only: the caller holds the lock.
6065
- *
6066
- * To a temporary file and renamed into place, because a rename is atomic and a write is not.
6067
- * `writeFileSync` empties the file before it fills it, and `readQueue` reads a file it cannot parse
6068
- * as "nothing queued" -- so a reader in another process that caught it half-written would take the
6069
- * queue to be empty and, on its next write, make it so. Found doing exactly that: four processes
6070
- * adding twelve reviews each left one in the queue. The temporary name carries the pid so two
6071
- * processes never share one, although under the lock they never write at once anyway.
6072
- */
6073
- function writeQueueUnlocked(ids) {
6074
- const next = normalise$1(ids);
6075
- mkdirSync(STORE_ROOT, { recursive: true });
6076
- const stored = {
6077
- version: VERSION$2,
6078
- ids: next
6079
- };
6080
- const temporary = `${QUEUE_PATH}.${process.pid}.tmp`;
6081
- try {
6082
- writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
6083
- renameSync(temporary, QUEUE_PATH);
6084
- } catch (error) {
6085
- try {
6086
- unlinkSync(temporary);
6087
- } catch {}
6088
- throw error;
6089
- }
6090
- return next;
6091
- }
6092
- /**
6093
- * Append, and leave a review already queued exactly where it is.
6094
- *
6095
- * Adding something twice is a no-op rather than a move. Nothing in this UI reorders a queue, so a
6096
- * second Add that silently promoted a review would be the only control in the product that did --
6097
- * and it would do it without ever saying so.
6098
- */
6099
- function queueAdd(id) {
6100
- return updateQueue((current) => current.includes(id) ? null : [...current, id]);
6101
- }
6102
- /**
6103
- * Append several, in the order given, for the reviews index's bulk bar.
6104
- *
6105
- * The same rules as `queueAdd` for each one, in one write. The cap is where this differs: one Add
6106
- * at a time never meets it in practice, but a selection can hold a hundred cards, and `normalise`
6107
- * would drop everything past fifty without a word. So what does not fit is left out *here* and
6108
- * named in `full`, and the caller has something true to say about it.
6109
- */
6110
- function queueAddMany(ids) {
6111
- let full = [];
6112
- const queue = updateQueue((current) => {
6113
- const fresh = ids.filter((id, at) => !current.includes(id) && ids.indexOf(id) === at);
6114
- const room = Math.max(0, 50 - current.length);
6115
- full = fresh.slice(room);
6116
- return fresh.length <= full.length ? null : [...current, ...fresh.slice(0, room)];
6117
- });
6118
- return queue === null ? null : {
6119
- queue,
6120
- full
6121
- };
6122
- }
6123
- function queueRemove(id) {
6124
- return updateQueue((current) => current.includes(id) ? current.filter((queued) => queued !== id) : null);
6125
- }
6126
- /**
6127
- * A token for the index's revision, equality only.
6128
- *
6129
- * The queue changes only when a person deliberately changes it, so unlike a running job's progress
6130
- * this cannot make the page reload itself in a loop. Absent reads as a stable empty string rather
6131
- * than as a change, for the reason `refreshAction` gives: a missing field that reads as "different"
6132
- * is a reload that asks again and reloads again.
6133
- */
6134
- function queueStamp() {
6135
- try {
6136
- return String(statSync(QUEUE_PATH).mtimeMs);
6137
- } catch {
6138
- return "";
6139
- }
6140
- }
6141
- /** Strings only, first position wins, and never more than the cap. */
6142
- function normalise$1(ids) {
6143
- const seen = [];
6144
- for (const id of ids) {
6145
- if (typeof id !== "string" || id === "" || seen.includes(id)) continue;
6146
- seen.push(id);
6147
- if (seen.length === 50) break;
6148
- }
6149
- return seen;
6150
- }
6151
- //#endregion
6152
- //#region ../../packages/review-harness/src/workspace/collections.ts
6153
- /**
6154
- * Collections: the reviewer's own groups of reviews, such as everything in one refactor.
6155
- *
6156
- * A collection says what belongs together and nothing else. Whether a member still needs attention
6157
- * is its lifecycle, which this file never reads or writes: assigning, removing, renaming and
6158
- * deleting all leave every review's state exactly where it was.
6159
- *
6160
- * **Members are lineage keys, not review ids.** A card is a branch, and analysing the branch again
6161
- * makes a new record with a new id. The queue stores ids and has to forward them (`forwardQueue`);
6162
- * a lineage key is already what the card stands for, so membership survives updates, re-analysis
6163
- * and restarts with nothing to keep in step.
6164
- *
6165
- * **Its own file**, for the reasons `queue.ts` gives: a record is rewritten whole by whoever loaded
6166
- * it last and carries its diff inline, and `ui.json` is best-effort. And because a write here never
6167
- * touches a record, it never moves the record's mtime, which is the clock retention runs on.
6168
- * Organising a review is not opening or marking it.
6169
- *
6170
- * `STORE_ROOT` from `store_root.ts` rather than `store.ts`, for the import cycle `queue.ts` explains:
6171
- * the prune in `store.ts` reads this file.
6172
- */
6173
- var COLLECTIONS_PATH = join(STORE_ROOT, "collections.json");
6174
- /** The lock's name. Reviews are locked under UUIDs in the same directory, so a word cannot collide. */
6175
- var COLLECTIONS_LOCK = "collections";
6176
- var VERSION$1 = 1;
6177
- var ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
6178
- /** Whether a value is shaped like a collection's id, which is what a fold key and a route accept. */
6179
- function isCollectionId(value) {
6180
- return typeof value === "string" && ID_PATTERN.test(value);
6181
- }
6182
- /**
6183
- * Every collection, in the order they were created.
6184
- *
6185
- * Best-effort like `readQueue`: missing, unreadable and corrupt all mean "no collections". An entry
6186
- * this code did not write is dropped rather than guessed at, and so is a second entry claiming an id
6187
- * or a name already taken, so the rules `createCollection` keeps hold for a hand-edited file too.
6188
- */
6189
- function readCollections() {
6190
- try {
6191
- const parsed = JSON.parse(readFileSync(COLLECTIONS_PATH, "utf8"));
6192
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
6193
- const collections = parsed.collections;
6194
- return Array.isArray(collections) ? normalise(collections) : [];
6195
- } catch {
6196
- return [];
6197
- }
6198
- }
6199
- function normalise(raw) {
6200
- const kept = [];
6201
- for (const entry of raw) {
6202
- if (kept.length >= 100) break;
6203
- if (!entry || typeof entry !== "object") continue;
6204
- const { id, name, colour, createdAt, members } = entry;
6205
- const clean = typeof name === "string" ? cleanName(name) : "";
6206
- if (!isCollectionId(id) || clean === "" || clean.length > 60) continue;
6207
- if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) continue;
6208
- if (kept.some((other) => other.id === id || sameName(other.name, clean))) continue;
6209
- const keys = Array.isArray(members) ? members.filter((key) => typeof key === "string" && key !== "") : [];
6210
- kept.push({
6211
- id,
6212
- name: clean,
6213
- ...typeof colour === "string" && colour !== "" ? { colour } : {},
6214
- createdAt,
6215
- members: [...new Set(keys)].slice(0, 500)
6216
- });
6217
- }
6218
- return kept;
6219
- }
6220
- /** Spaces trimmed and runs of them collapsed, so " Release 0.82 " is "Release 0.82". */
6221
- function cleanName(name) {
6222
- return name.trim().replace(/\s+/g, " ");
6223
- }
6224
- /** Two names are one collection's name regardless of case: "refactor" is not a second Refactor. */
6225
- function sameName(a, b) {
6226
- return a.localeCompare(b, void 0, { sensitivity: "base" }) === 0;
6227
- }
6228
- function nameProblem(name, collections, except) {
6229
- const clean = cleanName(name);
6230
- if (clean === "") return "empty";
6231
- if (clean.length > 60) return "too-long";
6232
- if (collections.some((collection) => collection.id !== except && sameName(collection.name, clean))) return "duplicate";
6233
- }
6234
- /** What a refused change says, in the words the page shows. */
6235
- function nameProblemText(problem) {
6236
- if (problem === "empty") return "Give the collection a name.";
6237
- if (problem === "too-long") return `Keep the name under 61 characters.`;
6238
- return "A collection with that name already exists.";
6294
+ try {
6295
+ const parsed = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
6296
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
6297
+ const ids = parsed.ids;
6298
+ return Array.isArray(ids) ? normalise(ids) : [];
6299
+ } catch {
6300
+ return [];
6301
+ }
6239
6302
  }
6240
6303
  /**
6241
- * Change the collections as they are on disk now, under the lock: the only way they are written.
6242
- * Same contract as `updateQueue`: `change` returns what to store or null for nothing, and null from
6243
- * here means the write failed.
6304
+ * Change the queue as it is on disk now: read, change, write, with nothing able to land in between.
6305
+ *
6306
+ * **The only way the queue is written.** The workspace server and the CLI are separate processes
6307
+ * over this one file, and a `--queue` from the terminal can arrive while somebody is adding or
6308
+ * removing on the page. Each of them reading the queue, changing its copy and writing it back would
6309
+ * keep whichever wrote last and drop the other's change -- while both reported success. So the
6310
+ * whole read-change-write runs under the queue's cross-process lock, the same lock a review's own
6311
+ * writes take (see `write_lock.ts`).
6312
+ *
6313
+ * One lock, taken once, here. Every mutation below is one call to this and nothing inside `change`
6314
+ * writes, so the transaction is visibly this function's body and nothing depends on the lock being
6315
+ * re-entrant. `change` may read other files -- `queue_live.ts` asks which reviews still exist --
6316
+ * but must not write the queue or take another lock.
6317
+ *
6318
+ * `change` returns the queue to store, or null to store nothing, in which case the queue as it was
6319
+ * read is the answer. Null from this function means the write failed, including a lock held past
6320
+ * any real write, which is the same thing to the caller: it did not happen.
6244
6321
  */
6245
- function updateCollections(change) {
6322
+ function updateQueue(change) {
6246
6323
  try {
6247
- return withWriteLock(COLLECTIONS_LOCK, () => {
6248
- const current = readCollections();
6324
+ return withWriteLock(QUEUE_LOCK, () => {
6325
+ const current = readQueue();
6249
6326
  const next = change(current);
6250
- return next === null ? current : writeUnlocked(next);
6327
+ return next === null ? current : writeQueueUnlocked(next);
6251
6328
  });
6252
6329
  } catch {
6253
6330
  return null;
6254
6331
  }
6255
6332
  }
6256
- /** To a temporary file and renamed into place, for the reason `writeQueueUnlocked` gives. */
6257
- function writeUnlocked(collections) {
6258
- const next = normalise(collections);
6333
+ /**
6334
+ * The write itself, for `updateQueue` only: the caller holds the lock.
6335
+ *
6336
+ * To a temporary file and renamed into place, because a rename is atomic and a write is not.
6337
+ * `writeFileSync` empties the file before it fills it, and `readQueue` reads a file it cannot parse
6338
+ * as "nothing queued" -- so a reader in another process that caught it half-written would take the
6339
+ * queue to be empty and, on its next write, make it so. Found doing exactly that: four processes
6340
+ * adding twelve reviews each left one in the queue. The temporary name carries the pid so two
6341
+ * processes never share one, although under the lock they never write at once anyway.
6342
+ */
6343
+ function writeQueueUnlocked(ids) {
6344
+ const next = normalise(ids);
6259
6345
  mkdirSync(STORE_ROOT, { recursive: true });
6260
6346
  const stored = {
6261
6347
  version: VERSION$1,
6262
- collections: next
6348
+ ids: next
6263
6349
  };
6264
- const temporary = `${COLLECTIONS_PATH}.${process.pid}.tmp`;
6350
+ const temporary = `${QUEUE_PATH}.${process.pid}.tmp`;
6265
6351
  try {
6266
6352
  writeFileSync(temporary, `${JSON.stringify(stored, null, 2)}\n`);
6267
- renameSync(temporary, COLLECTIONS_PATH);
6353
+ renameSync(temporary, QUEUE_PATH);
6268
6354
  } catch (error) {
6269
6355
  try {
6270
6356
  unlinkSync(temporary);
@@ -6273,143 +6359,65 @@ function writeUnlocked(collections) {
6273
6359
  }
6274
6360
  return next;
6275
6361
  }
6276
- function run$1(change) {
6277
- const outcome = {};
6278
- const stored = updateCollections((current) => {
6279
- const result = change(current);
6280
- if ("refused" in result) {
6281
- outcome.refused = result.refused;
6282
- return null;
6283
- }
6284
- outcome.id = result.id;
6285
- return result.next;
6286
- });
6287
- if (outcome.refused !== void 0) return {
6288
- ok: false,
6289
- reason: outcome.refused
6290
- };
6291
- if (stored === null) return {
6292
- ok: false,
6293
- reason: "unsaved"
6294
- };
6295
- return {
6296
- ok: true,
6297
- collections: stored,
6298
- ...outcome.id === void 0 ? {} : { id: outcome.id }
6299
- };
6300
- }
6301
- function createCollection(name, now = Date.now()) {
6302
- return run$1((current) => {
6303
- const problem = nameProblem(name, current);
6304
- if (problem) return { refused: problem };
6305
- if (current.length >= 100) return { refused: "full" };
6306
- const id = randomUUID();
6307
- return {
6308
- next: [...current, {
6309
- id,
6310
- name: cleanName(name),
6311
- createdAt: now,
6312
- members: []
6313
- }],
6314
- id
6315
- };
6316
- });
6317
- }
6318
6362
  /**
6319
- * A new collection holding these lineages from the start: one transaction, so it is made with all of
6320
- * them or not at all. "Create Refactor and add these three" never leaves an empty Refactor behind a
6321
- * failure, and a retry never meets a duplicate name of its own making.
6363
+ * Append, and leave a review already queued exactly where it is.
6364
+ *
6365
+ * Adding something twice is a no-op rather than a move. Nothing in this UI reorders a queue, so a
6366
+ * second Add that silently promoted a review would be the only control in the product that did --
6367
+ * and it would do it without ever saying so.
6322
6368
  */
6323
- function createWithMembers(name, keys, now = Date.now()) {
6324
- const members = [...new Set(keys.filter((key) => key !== ""))];
6325
- return run$1((current) => {
6326
- const problem = nameProblem(name, current);
6327
- if (problem) return { refused: problem };
6328
- if (current.length >= 100 || members.length > 500) return { refused: "full" };
6329
- const id = randomUUID();
6330
- return {
6331
- next: [...current, {
6332
- id,
6333
- name: cleanName(name),
6334
- createdAt: now,
6335
- members
6336
- }],
6337
- id
6338
- };
6339
- });
6340
- }
6341
- /** A new name on the same collection: its id, its members and every fold kept against the id stay. */
6342
- function renameCollection(id, name) {
6343
- return run$1((current) => {
6344
- if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
6345
- const problem = nameProblem(name, current, id);
6346
- if (problem) return { refused: problem };
6347
- return {
6348
- next: current.map((collection) => collection.id === id ? {
6349
- ...collection,
6350
- name: cleanName(name)
6351
- } : collection),
6352
- id
6353
- };
6354
- });
6355
- }
6356
- /** The collection and its memberships go. No review is touched: they were never inside it. */
6357
- function deleteCollection(id) {
6358
- return run$1((current) => {
6359
- if (!current.some((collection) => collection.id === id)) return { refused: "missing" };
6360
- return { next: current.filter((collection) => collection.id !== id) };
6361
- });
6369
+ function queueAdd(id) {
6370
+ return updateQueue((current) => current.includes(id) ? null : [...current, id]);
6362
6371
  }
6363
6372
  /**
6364
- * Add the given lineages to one collection, or take them out of it.
6373
+ * Append several, in the order given, for the reviews index's bulk bar.
6365
6374
  *
6366
- * **Only the named collection is touched.** Assigning a selection to Refactor leaves every other
6367
- * membership those reviews have exactly as it was, which is what makes a bulk assignment additive.
6368
- * Adding a member already there and removing one that is not are both nothing, not errors: a mixed
6369
- * selection is the ordinary case.
6375
+ * The same rules as `queueAdd` for each one, in one write. The cap is where this differs: one Add
6376
+ * at a time never meets it in practice, but a selection can hold a hundred cards, and `normalise`
6377
+ * would drop everything past fifty without a word. So what does not fit is left out *here* and
6378
+ * named in `full`, and the caller has something true to say about it.
6370
6379
  */
6371
- function setMembership(keys, id, action) {
6372
- return run$1((current) => {
6373
- const target = current.find((collection) => collection.id === id);
6374
- if (!target) return { refused: "missing" };
6375
- const members = action === "add" ? [...target.members, ...keys.filter((key, at) => key !== "" && !target.members.includes(key) && keys.indexOf(key) === at)] : target.members.filter((key) => !keys.includes(key));
6376
- if (members.length === target.members.length && members.every((key, at) => key === target.members[at])) return {
6377
- next: null,
6378
- id
6379
- };
6380
- if (members.length > 500) return { refused: "full" };
6381
- return {
6382
- next: current.map((collection) => collection.id === id ? {
6383
- ...collection,
6384
- members
6385
- } : collection),
6386
- id
6387
- };
6380
+ function queueAddMany(ids) {
6381
+ let full = [];
6382
+ const queue = updateQueue((current) => {
6383
+ const fresh = ids.filter((id, at) => !current.includes(id) && ids.indexOf(id) === at);
6384
+ const room = Math.max(0, 50 - current.length);
6385
+ full = fresh.slice(room);
6386
+ return fresh.length <= full.length ? null : [...current, ...fresh.slice(0, room)];
6388
6387
  });
6388
+ return queue === null ? null : {
6389
+ queue,
6390
+ full
6391
+ };
6392
+ }
6393
+ function queueRemove(id) {
6394
+ return updateQueue((current) => current.includes(id) ? current.filter((queued) => queued !== id) : null);
6389
6395
  }
6390
6396
  /**
6391
- * Take lineages out of every collection, because their reviews are gone. Stores nothing when none
6392
- * of them was a member, so a deletion of an uncollected review never writes this file.
6397
+ * A token for the index's revision, equality only.
6398
+ *
6399
+ * The queue changes only when a person deliberately changes it, so unlike a running job's progress
6400
+ * this cannot make the page reload itself in a loop. Absent reads as a stable empty string rather
6401
+ * than as a change, for the reason `refreshAction` gives: a missing field that reads as "different"
6402
+ * is a reload that asks again and reloads again.
6393
6403
  */
6394
- function dropMembers(keys) {
6395
- const gone = new Set(keys);
6396
- return updateCollections((current) => current.some((collection) => collection.members.some((key) => gone.has(key))) ? current.map((collection) => ({
6397
- ...collection,
6398
- members: collection.members.filter((key) => !gone.has(key))
6399
- })) : null);
6400
- }
6401
- /** Every lineage in at least one collection. What retention asks: collected is kept. */
6402
- function collectedKeys(collections = readCollections()) {
6403
- return new Set(collections.flatMap((collection) => collection.members));
6404
- }
6405
- /** A token for the index's revision, as `queueStamp` is: it changes only when a person changes it. */
6406
- function collectionsStamp() {
6404
+ function queueStamp() {
6407
6405
  try {
6408
- return String(statSync(COLLECTIONS_PATH).mtimeMs);
6406
+ return String(statSync(QUEUE_PATH).mtimeMs);
6409
6407
  } catch {
6410
6408
  return "";
6411
6409
  }
6412
6410
  }
6411
+ /** Strings only, first position wins, and never more than the cap. */
6412
+ function normalise(ids) {
6413
+ const seen = [];
6414
+ for (const id of ids) {
6415
+ if (typeof id !== "string" || id === "" || seen.includes(id)) continue;
6416
+ seen.push(id);
6417
+ if (seen.length === 50) break;
6418
+ }
6419
+ return seen;
6420
+ }
6413
6421
  //#endregion
6414
6422
  //#region ../../packages/review-harness/src/workspace/lifecycle.ts
6415
6423
  /**
@@ -6458,7 +6466,7 @@ function lifecycleAction(lifecycle) {
6458
6466
  return LIFECYCLES.find((entry) => entry.id === lifecycle).action;
6459
6467
  }
6460
6468
  /** A lifecycle's name, which is what its pill says on the card and on the review page. */
6461
- function lifecycleName(lifecycle) {
6469
+ function lifecycleName$1(lifecycle) {
6462
6470
  return LIFECYCLES.find((entry) => entry.id === (lifecycle?.state ?? "active")).name;
6463
6471
  }
6464
6472
  /**
@@ -6475,7 +6483,7 @@ function lifecyclePillAction(lifecycle) {
6475
6483
  /** The pill's accessible name: the state it shows, then what pressing it does. */
6476
6484
  function lifecyclePillLabel(lifecycle) {
6477
6485
  const action = lifecyclePillAction(lifecycle);
6478
- return `${lifecycleName(lifecycle)}. ${action === "menu" ? "Choose what happens next" : lifecycleAction(action)}`;
6486
+ return `${lifecycleName$1(lifecycle)}. ${action === "menu" ? "Choose what happens next" : lifecycleAction(action)}`;
6479
6487
  }
6480
6488
  var ACTIVITY_LABELS = {
6481
6489
  created: "Created",
@@ -6523,29 +6531,182 @@ function lifecycleChip(lifecycle, activity) {
6523
6531
  const movedAt = activity.movedAt ?? (activity.kind === "updated" ? activity.at : void 0);
6524
6532
  return movedAt !== void 0 && movedAt > lifecycle.at ? "Changed since done" : void 0;
6525
6533
  }
6526
- function changedSince(lifecycle, activity) {
6527
- return activity !== void 0 && activity.kind !== "created" && activity.at > lifecycle.at;
6534
+ function changedSince(lifecycle, activity) {
6535
+ return activity !== void 0 && activity.kind !== "created" && activity.at > lifecycle.at;
6536
+ }
6537
+ /**
6538
+ * Which lifecycle items a card's menu offers.
6539
+ *
6540
+ * Every state but the one the card is already in, which would be an item that does nothing. A card
6541
+ * carrying a chip is the exception and offers all three: re-marking it is how the reviewer says
6542
+ * they have seen the change, so "Mark waiting" on a Waiting review that changed is the settle
6543
+ * action, not a no-op.
6544
+ */
6545
+ function offersLifecycle(target, lifecycle, activity) {
6546
+ if (lifecycleChip(lifecycle, activity) !== void 0) return true;
6547
+ return target !== (lifecycle?.state ?? "active");
6548
+ }
6549
+ /**
6550
+ * A lifecycle item's words. The settle item on a changed Done review is "Keep done": "Mark done" on
6551
+ * something already Done reads as a no-op. Waiting keeps "Mark waiting", because a changed Waiting
6552
+ * review is drawn in Active, where parking it again is what the words already say.
6553
+ */
6554
+ function lifecycleItemLabel(target, lifecycle, activity) {
6555
+ if (target === "done" && lifecycle?.state === "done" && lifecycleChip(lifecycle, activity) !== void 0) return "Keep done";
6556
+ return lifecycleAction(target);
6557
+ }
6558
+ //#endregion
6559
+ //#region ../../packages/review-harness/src/workspace/reviewer_outcome.ts
6560
+ var REVIEWER_OUTCOMES = [
6561
+ {
6562
+ id: "no-concerns",
6563
+ name: "Reviewed: no concerns",
6564
+ baseline: true
6565
+ },
6566
+ {
6567
+ id: "needs-changes",
6568
+ name: "Needs changes",
6569
+ baseline: true
6570
+ },
6571
+ {
6572
+ id: "needs-decision",
6573
+ name: "Needs a decision / answer",
6574
+ baseline: true
6575
+ },
6576
+ {
6577
+ id: "deeper-review",
6578
+ name: "Needs deeper review",
6579
+ baseline: false
6580
+ },
6581
+ {
6582
+ id: "later",
6583
+ name: "Later",
6584
+ baseline: false
6585
+ }
6586
+ ];
6587
+ function parseOutcomeSnapshot(value) {
6588
+ if (typeof value !== "object" || value === null) return null;
6589
+ const raw = value;
6590
+ if (typeof raw.sessionId !== "string" || typeof raw.commitSha !== "string" || typeof raw.analysisRevision !== "string") return null;
6591
+ return {
6592
+ sessionId: raw.sessionId,
6593
+ commitSha: raw.commitSha,
6594
+ analysisRevision: raw.analysisRevision
6595
+ };
6596
+ }
6597
+ function outcomeDefinition(kind) {
6598
+ return REVIEWER_OUTCOMES.find((outcome) => outcome.id === kind);
6599
+ }
6600
+ function isBaselineKind(kind) {
6601
+ return outcomeDefinition(kind)?.baseline === true;
6602
+ }
6603
+ function parseStoredOutcome(value) {
6604
+ if (typeof value !== "object" || value === null) return void 0;
6605
+ const raw = value;
6606
+ if (typeof raw.kind !== "string" || !outcomeDefinition(raw.kind)) return void 0;
6607
+ if (typeof raw.at !== "number" || !Number.isFinite(raw.at)) return void 0;
6608
+ if (typeof raw.commitSha !== "string" || typeof raw.sessionId !== "string") return void 0;
6609
+ return {
6610
+ kind: raw.kind,
6611
+ at: raw.at,
6612
+ commitSha: raw.commitSha,
6613
+ sessionId: raw.sessionId,
6614
+ analysisRevision: typeof raw.analysisRevision === "string" ? raw.analysisRevision : "",
6615
+ ...typeof raw.recordId === "string" ? { recordId: raw.recordId } : {},
6616
+ ...typeof raw.analysisJobId === "string" ? { analysisJobId: raw.analysisJobId } : {},
6617
+ ...Array.isArray(raw.answeredQuestions) ? { answeredQuestions: raw.answeredQuestions.filter((q) => typeof q === "string") } : {}
6618
+ };
6619
+ }
6620
+ function parseOutcomes(value) {
6621
+ if (!Array.isArray(value)) return [];
6622
+ return value.map(parseStoredOutcome).filter((outcome) => outcome !== void 0);
6623
+ }
6624
+ /** Newest last, capped. The baseline lives in its own field, so nothing here has to survive pruning. */
6625
+ function appendOutcome(history, outcome) {
6626
+ return [...history, outcome].slice(-20);
6627
+ }
6628
+ /** The newer of two baselines; a non-baseline outcome never replaces one. */
6629
+ function nextBaseline(current, outcome) {
6630
+ if (!isBaselineKind(outcome.kind)) return current;
6631
+ return !current || outcome.at >= current.at ? outcome : current;
6632
+ }
6633
+ /** The newest baseline across a lineage's records, so a re-analysis that made a new record keeps it. */
6634
+ function lastBaseline(baselines) {
6635
+ let newest;
6636
+ for (const baseline of baselines) if (baseline && (!newest || baseline.at > newest.at)) newest = baseline;
6637
+ return newest;
6638
+ }
6639
+ function sameSha(a, b) {
6640
+ if (!a || !b) return false;
6641
+ const length = Math.min(a.length, b.length);
6642
+ return length >= 7 && a.slice(0, length) === b.slice(0, length);
6643
+ }
6644
+ /** How an outcome relates to the analysis on screen. */
6645
+ function outcomeStanding(outcome, snapshot) {
6646
+ if (!sameSha(outcome.commitSha, snapshot.commitSha)) return "earlier-snapshot";
6647
+ if (outcome.sessionId !== snapshot.sessionId || outcome.analysisRevision !== snapshot.analysisRevision) return "earlier-analysis";
6648
+ return "current";
6649
+ }
6650
+ /**
6651
+ * Verdicts come from the history and from the stored baseline, which is never pruned: twenty
6652
+ * deferrals after a verdict push it out of the history, but not out of the reading.
6653
+ */
6654
+ function readOutcomes(history, snapshot, baseline) {
6655
+ const verdicts = history.filter((outcome) => isBaselineKind(outcome.kind));
6656
+ if (baseline && isBaselineKind(baseline.kind) && !verdicts.some((outcome) => outcome.at === baseline.at && outcome.kind === baseline.kind)) verdicts.push(baseline);
6657
+ verdicts.sort((a, b) => a.at - b.at);
6658
+ const newestVerdict = verdicts.at(-1);
6659
+ if (!snapshot) return newestVerdict ? { previous: {
6660
+ outcome: newestVerdict,
6661
+ standing: "earlier-snapshot"
6662
+ } } : {};
6663
+ const verdict = verdicts.filter((outcome) => outcomeStanding(outcome, snapshot) === "current").at(-1);
6664
+ const deferral = history.filter((outcome) => !isBaselineKind(outcome.kind) && outcomeStanding(outcome, snapshot) === "current" && (!verdict || outcome.at > verdict.at)).at(-1);
6665
+ const reading = {};
6666
+ if (verdict) reading.verdict = verdict;
6667
+ else if (newestVerdict) reading.previous = {
6668
+ outcome: newestVerdict,
6669
+ standing: outcomeStanding(newestVerdict, snapshot)
6670
+ };
6671
+ if (deferral) reading.deferral = deferral;
6672
+ return reading;
6673
+ }
6674
+ /** "Needs changes", or how an older one reads beside a newer analysis. */
6675
+ function outcomeLabel(outcome, standing) {
6676
+ const name = outcomeDefinition(outcome.kind)?.name ?? outcome.kind;
6677
+ if (standing === "current") return name;
6678
+ if (standing === "earlier-analysis") return `Outcome on an earlier analysis: ${name}`;
6679
+ return `${name} on ${outcome.commitSha.slice(0, 7)} (earlier snapshot)`;
6528
6680
  }
6681
+ //#endregion
6682
+ //#region ../../packages/review-harness/src/workspace/analysis_revision.ts
6529
6683
  /**
6530
- * Which lifecycle items a card's menu offers.
6684
+ * A short fingerprint of the analysis a reviewer is reading: its findings, statuses, notes and text.
6531
6685
  *
6532
- * Every state but the one the card is already in, which would be an item that does nothing. A card
6533
- * carrying a chip is the exception and offers all three: re-marking it is how the reviewer says
6534
- * they have seen the change, so "Mark waiting" on a Waiting review that changed is the settle
6535
- * action, not a no-op.
6686
+ * The session id and the commit do not move when a Re-check reconciles findings at the same commit,
6687
+ * so a verdict checked against those two alone would be accepted for findings the reviewer never saw.
6688
+ * The reviewer's own ticks are not part of it: those are the reviewer's acts, not the analysis.
6536
6689
  */
6537
- function offersLifecycle(target, lifecycle, activity) {
6538
- if (lifecycleChip(lifecycle, activity) !== void 0) return true;
6539
- return target !== (lifecycle?.state ?? "active");
6690
+ function analysisRevision(workspace) {
6691
+ const result = workspace.session.result;
6692
+ if (!result) return "";
6693
+ return createHash("sha256").update(JSON.stringify(result)).digest("hex").slice(0, 16);
6540
6694
  }
6541
- /**
6542
- * A lifecycle item's words. The settle item on a changed Done review is "Keep done": "Mark done" on
6543
- * something already Done reads as a no-op. Waiting keeps "Mark waiting", because a changed Waiting
6544
- * review is drawn in Active, where parking it again is what the words already say.
6545
- */
6546
- function lifecycleItemLabel(target, lifecycle, activity) {
6547
- if (target === "done" && lifecycle?.state === "done" && lifecycleChip(lifecycle, activity) !== void 0) return "Keep done";
6548
- return lifecycleAction(target);
6695
+ /** The analysed commit, falling back the way `reviewedCommit` does. */
6696
+ function analysedCommit(workspace) {
6697
+ return workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || "";
6698
+ }
6699
+ function analysisSnapshot(workspace) {
6700
+ if (!workspace.session.result) return null;
6701
+ return {
6702
+ sessionId: workspace.session.id,
6703
+ commitSha: analysedCommit(workspace),
6704
+ analysisRevision: analysisRevision(workspace)
6705
+ };
6706
+ }
6707
+ /** When the analysis on screen was last produced or reconciled: the upper time bound for its history. */
6708
+ function analysedAt(workspace) {
6709
+ return Math.max(workspace.session.metadata?.generatedAt ?? 0, workspace.lastUpdate?.at ?? 0, workspace.lastRecheck?.at ?? 0, workspace.createdAt);
6549
6710
  }
6550
6711
  //#endregion
6551
6712
  //#region ../../packages/review-harness/src/workspace/store.ts
@@ -6742,6 +6903,18 @@ function workspaceRevision(workspace) {
6742
6903
  }
6743
6904
  }
6744
6905
  /**
6906
+ * Moves whenever any review is written or deleted, by this process or another: every write stages a
6907
+ * file in the directory and renames it into place, and a delete unlinks one. One stat, where
6908
+ * `workspaceStamps` is one per review, for a cache to ask whether anything changed at all.
6909
+ */
6910
+ function workspacesRevision() {
6911
+ try {
6912
+ return statSync(WORKSPACES_DIR).mtimeMs;
6913
+ } catch {
6914
+ return 0;
6915
+ }
6916
+ }
6917
+ /**
6745
6918
  * Every stored review as an id and a revision token, without reading one of them.
6746
6919
  *
6747
6920
  * Here rather than in `reviews_revision.ts` because this module owns the layout of `workspaces/`,
@@ -7023,6 +7196,14 @@ function groupByLineage(reviews) {
7023
7196
  }
7024
7197
  return [...groups].map(([key, members]) => {
7025
7198
  const history = [...members].sort((a, b) => b.createdAt - a.createdAt);
7199
+ const [newest] = history;
7200
+ if (newest.outcomeInputs && history.length > 1) {
7201
+ const { outcome: _outcome, deferral: _deferral, ...rest } = newest;
7202
+ history[0] = {
7203
+ ...rest,
7204
+ ...lineageOutcome(newest.outcomeInputs, history.map((review) => review.outcomeInputs))
7205
+ };
7206
+ }
7026
7207
  return {
7027
7208
  key,
7028
7209
  latest: history[0],
@@ -7153,6 +7334,8 @@ function summarise(workspace) {
7153
7334
  })(),
7154
7335
  lastViewedAt: workspace.lastViewedAt,
7155
7336
  completedAt: workspace.completedAt,
7337
+ outcomeInputs: outcomeInputsOf(workspace),
7338
+ ...outcomeSummary(outcomeInputsOf(workspace)),
7156
7339
  pinned: workspace.pinned === true,
7157
7340
  lifecycle: parseLifecycle(workspace.lifecycle),
7158
7341
  paths: files.slice(0, MAX_INDEXED_PATHS).map((file) => file.name),
@@ -7163,6 +7346,42 @@ function summarise(workspace) {
7163
7346
  authorName: workspace.prContext?.pullRequest.author?.name || void 0
7164
7347
  };
7165
7348
  }
7349
+ function outcomeInputsOf(workspace) {
7350
+ const baseline = parseStoredOutcome(workspace.reviewedBaseline);
7351
+ return {
7352
+ history: parseOutcomes(workspace.reviewerOutcomes),
7353
+ snapshot: analysisSnapshot(workspace),
7354
+ ...baseline ? { baseline } : {}
7355
+ };
7356
+ }
7357
+ /**
7358
+ * The newest record's outcomes read with the newest baseline of its whole lineage, as Quick Review
7359
+ * reads them. A fresh analysis makes a record with no baseline of its own, and the verdict on the
7360
+ * one before must still show on the card, as an earlier one, instead of vanishing from it.
7361
+ */
7362
+ function lineageOutcome(newest, lineage) {
7363
+ return outcomeSummary(newest, lastBaseline([newest.baseline, ...lineage.map((inputs) => inputs?.baseline)]));
7364
+ }
7365
+ function outcomeSummary(inputs, baseline = inputs.baseline) {
7366
+ const reading = readOutcomes(inputs.history, inputs.snapshot, baseline);
7367
+ const verdict = reading.verdict ? {
7368
+ outcome: reading.verdict,
7369
+ standing: "current"
7370
+ } : reading.previous;
7371
+ return {
7372
+ ...verdict ? { outcome: {
7373
+ kind: verdict.outcome.kind,
7374
+ standing: verdict.standing,
7375
+ label: outcomeLabel(verdict.outcome, verdict.standing),
7376
+ at: verdict.outcome.at
7377
+ } } : {},
7378
+ ...reading.deferral ? { deferral: {
7379
+ kind: reading.deferral.kind,
7380
+ label: outcomeLabel(reading.deferral, "current"),
7381
+ at: reading.deferral.at
7382
+ } } : {}
7383
+ };
7384
+ }
7166
7385
  /**
7167
7386
  * The commit a review was made from, however the record spells it. Null when it recorded none.
7168
7387
  *
@@ -8378,6 +8597,14 @@ function sanitisePullRequest(value) {
8378
8597
  if (template) pr.fileUrlTemplate = template;
8379
8598
  const author = pullRequestAuthor(value.author);
8380
8599
  if (author) pr.author = author;
8600
+ if (isRecord$1(value.headRepository)) {
8601
+ const owner = label(value.headRepository.owner);
8602
+ const repo = label(value.headRepository.repo);
8603
+ if (owner && repo) pr.headRepository = {
8604
+ owner,
8605
+ repo
8606
+ };
8607
+ }
8381
8608
  return pr;
8382
8609
  }
8383
8610
  /**
@@ -9085,7 +9312,7 @@ function slug(repo) {
9085
9312
  * `baseRefOid` is a diff taken against the wrong *commit*, which is worse because the branch name
9086
9313
  * is right and everything reads as normal. A list nobody asserts is a list somebody tidies.
9087
9314
  */
9088
- var PR_FIELDS = "number,url,title,body,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
9315
+ var PR_FIELDS = "number,url,title,body,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author,headRepository,headRepositoryOwner";
9089
9316
  async function viewRequest(repo, reference, cwd, env) {
9090
9317
  return await ghJson([
9091
9318
  "pr",
@@ -9134,7 +9361,11 @@ function identity(raw) {
9134
9361
  author: author ? {
9135
9362
  name: text$2(author.name),
9136
9363
  handle: text$2(author.login)
9137
- } : null
9364
+ } : null,
9365
+ ...text$2(raw.headRepositoryOwner?.login) && text$2(raw.headRepository?.name) ? { headRepository: {
9366
+ owner: text$2(raw.headRepositoryOwner?.login),
9367
+ repo: text$2(raw.headRepository?.name)
9368
+ } } : {}
9138
9369
  };
9139
9370
  }
9140
9371
  /**
@@ -11329,6 +11560,32 @@ ${diff}`;
11329
11560
  };
11330
11561
  }
11331
11562
  //#endregion
11563
+ //#region ../../packages/review-harness/src/domain/ai_review/behaviour_change.ts
11564
+ /** Both sides present and non-empty, or nothing: half an explanation is not one. */
11565
+ function parseBehaviourChange(value) {
11566
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
11567
+ const raw = value;
11568
+ const before = typeof raw.before === "string" ? raw.before.trim() : "";
11569
+ const after = typeof raw.after === "string" ? raw.after.trim() : "";
11570
+ return before && after ? {
11571
+ before,
11572
+ after
11573
+ } : void 0;
11574
+ }
11575
+ function withBehaviourChange(change) {
11576
+ return change ? { behaviourChange: change } : {};
11577
+ }
11578
+ /**
11579
+ * The behaviourChange an update keeps.
11580
+ *
11581
+ * Restated: the new one. New code and none restated: dropped, because the old one describes a head
11582
+ * that no longer exists. A conversation-only pass read no new code, so the previous one still holds.
11583
+ */
11584
+ function mergeBehaviourChange(previous, restated, codeChanged) {
11585
+ if (restated) return restated;
11586
+ return codeChanged ? void 0 : previous;
11587
+ }
11588
+ //#endregion
11332
11589
  //#region ../../packages/review-harness/src/domain/utils/html.ts
11333
11590
  function escapeHtml(value) {
11334
11591
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -12644,6 +12901,10 @@ var CONSEQUENCE_GUIDANCE = `"consequence" on an issue: the same issue told as wh
12644
12901
  - An issue names a concrete consequence. A request for missing requirements with no concrete consequence is a question for the author, not an issue, and is never raised as both.`;
12645
12902
  /** What "orientation" is for, as the first analysis and an update ask for it. */
12646
12903
  var ORIENTATION_GUIDANCE = `"orientation": one sentence, at most about 25 words, naming the behaviour or product area this change touches, for a reviewer about to decide on its issues (e.g. "This change affects receipt extraction and how duplicate receipt details are combined."). Say what it touches, not whether it is safe, and do not summarise the issues.`;
12904
+ /** The behaviourChange shape, written out whole for every prompt that asks for it. */
12905
+ var BEHAVIOUR_CHANGE_FIELD = "\"behaviourChange\"?: { \"before\": string, \"after\": string }";
12906
+ /** What "behaviourChange" is for, as the first analysis and an update ask for it. */
12907
+ var BEHAVIOUR_CHANGE_GUIDANCE = `"behaviourChange": how the behaviour this pull request touches works today and how it works with the whole pull request applied, for a reviewer who knows the product. "before" is the behaviour at the base the pull request targets; "after" is the behaviour at the head you analysed. One to three plain sentences each, leading with what a user or the system experiences, not with the implementation (e.g. before: "A failed payment is shown as an error and the order is cancelled.", after: "A failed payment is retried twice before the order is cancelled, and the customer is emailed after the last attempt."). Describe the whole pull request, not one commit. Omit the field entirely when the code you read does not establish how it works today: never guess "before".`;
12647
12908
  /**
12648
12909
  * How an update keeps a prior finding's consequence and resolution true, beside its status.
12649
12910
  *
@@ -13450,6 +13711,7 @@ var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above
13450
13711
  },
13451
13712
  "overallRisk": "low" | "medium" | "high",
13452
13713
  "orientation": string,
13714
+ ${BEHAVIOUR_CHANGE_FIELD},
13453
13715
  "sections": [{
13454
13716
  "id": string,
13455
13717
  "order": number,
@@ -13487,6 +13749,7 @@ Field guidance:
13487
13749
  - ${CONSEQUENCE_GUIDANCE}
13488
13750
  - ${QUESTION_FRAMING_GUIDANCE}
13489
13751
  - ${ORIENTATION_GUIDANCE}
13752
+ - ${BEHAVIOUR_CHANGE_GUIDANCE}
13490
13753
  - "existingDiscussion" on an issue: set this only when a thread in the PR DISCUSSION CONTEXT block already raises this same concern, and you have independently confirmed from the diff that it still applies. "threadId" must be copied verbatim from that thread's [T_...] marker — never invent an id, never guess, and omit the field entirely if no thread matches. Set "relation" to "same" when the thread already makes this exact point, or "related" when it discusses this area without covering the finding — a reviewer acts differently on "someone said this already" than on "someone was looking near here". Still write the issue normally; this field only records that the point was raised before, so the reviewer is not shown it as a fresh discovery. Do not use it to pad the review with issues you would otherwise have left out.
13491
13754
  - "confidence" on an issue: how sure you are the issue is real, on the same high/medium/low scale as a question's "importance". This is a separate axis from "severity", which is how much it would matter if real — a critical issue you are unsure about and a nit you are certain of are different things to act on, so do not let one field stand in for the other. Use LOW when you are reasoning from a diff excerpt without the surrounding code, and say what would confirm it in the description. Omit the field rather than guessing a middle value.
13492
13755
  - "questions": each one is a genuine open item the reviewer needs answered before they can sign off, not a rhetorical prompt. "importance" is how much attention it deserves, on the same high/medium/low scale as a section's "reviewAttention" (e.g. a question about a possibly-exposed secret is "high"; a naming preference is "low"). "whyThisMatters" is 1-2 sentences explaining the concrete consequence of the question going unanswered — not a restatement of the question itself.
@@ -13545,6 +13808,7 @@ function parseUnchecked(raw) {
13545
13808
  summary,
13546
13809
  overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
13547
13810
  ...asString$1(obj.orientation).trim() ? { orientation: asString$1(obj.orientation).trim() } : {},
13811
+ ...withBehaviourChange(parseBehaviourChange(obj.behaviourChange)),
13548
13812
  sections: normalizeSections(obj.sections),
13549
13813
  issues: normalizeIssues(obj.issues),
13550
13814
  questions: normalizeQuestions(obj.questions),
@@ -17076,7 +17340,7 @@ function send(record) {
17076
17340
  * a yes it can trust, which is a 2xx and nothing else. Asks the same question `send` does before
17077
17341
  * anything goes over the wire.
17078
17342
  */
17079
- async function deliver(record) {
17343
+ async function deliver$1(record) {
17080
17344
  try {
17081
17345
  if (!telemetryUploadEnabled()) return false;
17082
17346
  const payload = project(record);
@@ -18218,6 +18482,7 @@ var RECONCILIATION_SHAPE = `{
18218
18482
  }],
18219
18483
  "summary": string,
18220
18484
  "orientation": string,
18485
+ ${BEHAVIOUR_CHANGE_FIELD},
18221
18486
  "overallRisk": "low" | "medium" | "high",
18222
18487
  "recommendation": string,
18223
18488
  "nextSteps": string[]
@@ -18325,6 +18590,12 @@ var GRADE_TASK = {
18325
18590
  * written to fix a finding is precisely where a regression gets introduced. A run that only
18326
18591
  * reassessed the list has done half the work, and it is the half that looks complete.
18327
18592
  */
18593
+ /** The earlier before-and-after, quoted so the model can restate it rather than start over. */
18594
+ function previousBehaviourChange(previous) {
18595
+ const change = previous.behaviourChange;
18596
+ if (!change) return "";
18597
+ return ` (the earlier analysis said: before: ${JSON.stringify(change.before)}; after: ${JSON.stringify(change.after)}. Keep what still holds, correct what the new changes alter)`;
18598
+ }
18328
18599
  function buildReconciliationInstructions(input, options = {}) {
18329
18600
  const { previous, totalCommits, changedFiles, possiblyTruncated } = input;
18330
18601
  const reassess = input.reassess ?? /* @__PURE__ */ new Map();
@@ -18358,7 +18629,7 @@ ${REASSESS_TASK}` : ""}
18358
18629
  ${GRADE_TASK.question}`] : [],
18359
18630
  conversationOnly ? `No commits were pushed, so there is no new code to review. Report a problem in "newIssues" only where the conversation points at something in the code the earlier analysis missed and you have confirmed it by reading the code. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.` : `Read the changed code above as a reviewer, not only as an auditor of the numbered list. These commits have not been reviewed by anyone. Report every genuinely NEW problem you find in them in "newIssues", whether or not it relates to anything on the list — code written to fix a finding is exactly where a regression gets introduced, and reassessing the list and stopping there is half the job. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.`,
18360
18631
  `If the new changes introduce an area the earlier analysis had no section for, add it to "newSections". Otherwise return an empty array. Do not restate or restructure the earlier analysis's existing sections.`,
18361
- `Give a fresh "summary", "overallRisk", "recommendation" and "nextSteps" for the pull request AS IT NOW STANDS, taking both the earlier findings that survive and the new changes into account. Unlike a partial review, you have been shown the earlier analysis in full and are expected to revise its verdict where the new changes justify it. ${SUMMARY_GUIDANCE} ${ORIENTATION_GUIDANCE}`
18632
+ `Give a fresh "summary", "overallRisk", "recommendation" and "nextSteps" for the pull request AS IT NOW STANDS, taking both the earlier findings that survive and the new changes into account. Unlike a partial review, you have been shown the earlier analysis in full and are expected to revise its verdict where the new changes justify it. ${SUMMARY_GUIDANCE} ${ORIENTATION_GUIDANCE} ${BEHAVIOUR_CHANGE_GUIDANCE} Restate "behaviourChange" for the whole pull request as it now stands${previousBehaviourChange(previous)}, or omit it when the evidence does not establish it.`
18362
18633
  ].map((task, index) => `${index + 1}. ${task}`).join("\n");
18363
18634
  return `${conversationOnly ? `You are RECONCILING an earlier analysis of this pull request against what has changed in its conversation since that analysis was made.
18364
18635
 
@@ -18532,6 +18803,7 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
18532
18803
  })),
18533
18804
  summary: asString$1(obj.summary).trim(),
18534
18805
  orientation: asString$1(obj.orientation).trim(),
18806
+ ...withBehaviourChange(parseBehaviourChange(obj.behaviourChange)),
18535
18807
  overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : null,
18536
18808
  recommendation: asString$1(obj.recommendation).trim(),
18537
18809
  nextSteps: asStringArray(obj.nextSteps)
@@ -18809,6 +19081,15 @@ function findingsChanged(counts) {
18809
19081
  }
18810
19082
  //#endregion
18811
19083
  //#region ../../packages/review-harness/src/domain/ai_review/update_reconciliation.ts
19084
+ /**
19085
+ * Folding an update's answer onto the analysis it brings up to date.
19086
+ *
19087
+ * One function for every client that updates a review, so the workspace and the extension cannot
19088
+ * come to read the same evidence differently: what a cited comment may settle, when a resolution is
19089
+ * marked for reassessment instead, which findings a changed comment reopens, what each status is
19090
+ * recorded against, and which threads a new finding may claim. A client differs only in how it
19091
+ * fetched the changes and ran the model.
19092
+ */
18812
19093
  function reconciledAgainstOf(basis) {
18813
19094
  return basis.code ? basis.discussion ? "commits-and-conversation" : "commits" : "conversation";
18814
19095
  }
@@ -18841,6 +19122,7 @@ function applyReconciliation(previous, parsed, context) {
18841
19122
  schemaVersion: 1,
18842
19123
  summary: parsed.summary || previous.summary,
18843
19124
  ...parsed.orientation || previous.orientation ? { orientation: parsed.orientation || previous.orientation } : {},
19125
+ ...withBehaviourChange(mergeBehaviourChange(previous.behaviourChange, parsed.behaviourChange, context.codeChanged)),
18844
19126
  ...motivation ? { motivation } : {},
18845
19127
  overallRisk: parsed.overallRisk ?? previous.overallRisk,
18846
19128
  recommendation: parsed.recommendation || previous.recommendation,
@@ -21009,6 +21291,557 @@ function livePart(queue) {
21009
21291
  return live.length === queue.length ? null : live;
21010
21292
  }
21011
21293
  //#endregion
21294
+ //#region ../../packages/review-harness/src/workspace/collections_live.ts
21295
+ /**
21296
+ * Collections as they relate to reviews that actually exist.
21297
+ *
21298
+ * `collections.ts` stores lineage keys and must not learn to read the review store, for the cycle
21299
+ * `queue_live.ts` describes: the prune in `store.ts` imports it. Anything that asks both questions
21300
+ * lives here, which imports both and is imported by neither.
21301
+ */
21302
+ /**
21303
+ * Drop members whose lineage has no review any more, deleted elsewhere or pruned by age, and store
21304
+ * the result if that changed anything. Read against every group, not the windowed list, so a branch
21305
+ * the recency window happens to hide keeps its place.
21306
+ *
21307
+ * Compacted again under the lock rather than written from what was read, so a member somebody added
21308
+ * since is kept. On a failed write the compacted copy still stands in, so the page draws what exists.
21309
+ */
21310
+ function settleCollections(groups) {
21311
+ const live = new Set(groups.map((group) => group.key));
21312
+ const compact = (collections) => collections.some((collection) => collection.members.some((key) => !live.has(key))) ? collections.map((collection) => ({
21313
+ ...collection,
21314
+ members: collection.members.filter((key) => live.has(key))
21315
+ })) : null;
21316
+ const read = readCollections();
21317
+ const compacted = compact(read);
21318
+ if (compacted === null) return read;
21319
+ return updateCollections((current) => compact(current)) ?? compacted;
21320
+ }
21321
+ /**
21322
+ * The lineage each review id belongs to, for the routes: the page sends the ids on its cards, and
21323
+ * the lineage is the server's to derive. An id with no review, or one that cannot be summarised, is
21324
+ * left out, and the caller says so.
21325
+ */
21326
+ function lineageKeysFor(ids) {
21327
+ const keys = /* @__PURE__ */ new Map();
21328
+ for (const id of new Set(ids)) {
21329
+ const workspace = loadWorkspace(id);
21330
+ if (!workspace) continue;
21331
+ try {
21332
+ keys.set(id, lineageKey(summarise(workspace)));
21333
+ } catch {}
21334
+ }
21335
+ return keys;
21336
+ }
21337
+ /**
21338
+ * Add one review's lineage to one collection, and say how that went. The membership check and the
21339
+ * add are one transaction, so `already` and `added` are told apart under the lock rather than
21340
+ * guessed from a read made before it. Touches no other collection and no review's lifecycle.
21341
+ */
21342
+ function collectForReview(reviewId, collectionId) {
21343
+ const key = lineageKeysFor([reviewId]).get(reviewId);
21344
+ if (key === void 0) return "gone";
21345
+ let result = "added";
21346
+ if (updateCollections((current) => {
21347
+ const target = current.find((collection) => collection.id === collectionId);
21348
+ if (!target) {
21349
+ result = "missing";
21350
+ return null;
21351
+ }
21352
+ if (target.members.includes(key)) {
21353
+ result = "already";
21354
+ return null;
21355
+ }
21356
+ if (target.members.length >= 500) {
21357
+ result = "full";
21358
+ return null;
21359
+ }
21360
+ return current.map((collection) => collection.id === collectionId ? {
21361
+ ...collection,
21362
+ members: [...collection.members, key]
21363
+ } : collection);
21364
+ }) === null) return "unsaved";
21365
+ return result;
21366
+ }
21367
+ //#endregion
21368
+ //#region ../../packages/review-harness/src/workspace/batch_destination.ts
21369
+ /**
21370
+ * Settle where the batch goes, before any review is started. An existing collection is found, and a
21371
+ * new one is made now, empty, through `createCollection` and so through the one name rule. Made up
21372
+ * front so a bad or taken name stops the batch before anything has been reviewed, and so a batch
21373
+ * that then partly fails has a collection to point its retry at.
21374
+ */
21375
+ function openDestination(request) {
21376
+ const queue = request.queue;
21377
+ if ("collectionId" in request) {
21378
+ const collections = readCollections();
21379
+ const found = collections.find((collection) => collection.id === request.collectionId);
21380
+ if (!found) return {
21381
+ ok: false,
21382
+ reason: "missing",
21383
+ requested: request.collectionId,
21384
+ by: "id",
21385
+ candidates: collections.map((one) => one.name)
21386
+ };
21387
+ return {
21388
+ ok: true,
21389
+ destination: {
21390
+ queue,
21391
+ collection: {
21392
+ id: found.id,
21393
+ name: found.name,
21394
+ created: false
21395
+ }
21396
+ }
21397
+ };
21398
+ }
21399
+ if ("collectionName" in request) {
21400
+ const collections = readCollections();
21401
+ const found = collectionNamed(request.collectionName, collections);
21402
+ if (!found) return {
21403
+ ok: false,
21404
+ reason: "missing",
21405
+ requested: request.collectionName,
21406
+ by: "name",
21407
+ candidates: collections.map((one) => one.name)
21408
+ };
21409
+ return {
21410
+ ok: true,
21411
+ destination: {
21412
+ queue,
21413
+ collection: {
21414
+ id: found.id,
21415
+ name: found.name,
21416
+ created: false
21417
+ }
21418
+ }
21419
+ };
21420
+ }
21421
+ if ("newCollection" in request) {
21422
+ const made = createCollection(request.newCollection);
21423
+ if (!made.ok) return {
21424
+ ok: false,
21425
+ reason: made.reason === "missing" ? "unsaved" : made.reason,
21426
+ requested: request.newCollection
21427
+ };
21428
+ const created = made.collections.find((collection) => collection.id === made.id);
21429
+ if (!created) return {
21430
+ ok: false,
21431
+ reason: "unsaved",
21432
+ requested: request.newCollection
21433
+ };
21434
+ return {
21435
+ ok: true,
21436
+ destination: {
21437
+ queue,
21438
+ collection: {
21439
+ id: created.id,
21440
+ name: created.name,
21441
+ created: true
21442
+ }
21443
+ }
21444
+ };
21445
+ }
21446
+ return {
21447
+ ok: true,
21448
+ destination: { queue }
21449
+ };
21450
+ }
21451
+ /**
21452
+ * Put one review where the batch goes, as soon as it exists. Idempotent: a membership or a queue
21453
+ * entry already there is `already`, which counts as delivered, and anything missing is added, so a
21454
+ * retry of a partly delivered batch fills in exactly what is missing. Synchronous, like
21455
+ * `queueForReview`, so two lanes of one batch cannot interleave a read and a write.
21456
+ */
21457
+ function deliver(reviewId, destination) {
21458
+ return {
21459
+ ...destination.collection ? { collection: collectForReview(reviewId, destination.collection.id) } : {},
21460
+ ...destination.queue ? { queue: queueForReview(reviewId) } : {}
21461
+ };
21462
+ }
21463
+ /** Whether a destination has the review: it went in now, or it was there already. */
21464
+ function isDelivered(result) {
21465
+ return result === "added" || result === "already";
21466
+ }
21467
+ /**
21468
+ * A miss that trying again could fix: the destination is still there, and it was full or its write
21469
+ * failed. A deleted collection (`missing`) or a deleted review (`gone`) fails the same way every time.
21470
+ */
21471
+ function canTryAgain(result) {
21472
+ return result === "full" || result === "unsaved";
21473
+ }
21474
+ /**
21475
+ * The part of a destination that is worth trying again for one review, or null when none is. Only
21476
+ * what missed, and only what can still succeed: a retry never re-adds what is already there, and
21477
+ * never asks a deleted collection again.
21478
+ */
21479
+ function retryableMiss(delivered, destination) {
21480
+ const collection = destination.collection && canTryAgain(delivered?.collection) ? destination.collection : void 0;
21481
+ const queue = destination.queue && canTryAgain(delivered?.queue);
21482
+ if (!collection && !queue) return null;
21483
+ return {
21484
+ queue,
21485
+ ...collection ? { collection } : {}
21486
+ };
21487
+ }
21488
+ /** Whether a destination that was asked for does not have the review. */
21489
+ function missedDelivery(delivered, destination) {
21490
+ if (destination.collection && !isDelivered(delivered?.collection)) return true;
21491
+ return destination.queue && !isDelivered(delivered?.queue);
21492
+ }
21493
+ //#endregion
21494
+ //#region ../../packages/review-harness/src/workspace/reviews_index.ts
21495
+ /**
21496
+ * The sections of the list, top to bottom.
21497
+ *
21498
+ * Pinned is not a lifecycle state. It is where a card is drawn, on top of a lifecycle it keeps: a
21499
+ * pinned review can be Active, Waiting or Done, and unpinning it puts it back in the section that
21500
+ * says. That is why it lives here, beside the other placement rules, and not in `LIFECYCLES`.
21501
+ */
21502
+ var INDEX_SECTIONS = [
21503
+ "pinned",
21504
+ "active",
21505
+ "waiting",
21506
+ "done"
21507
+ ];
21508
+ /**
21509
+ * Every section the list draws, top to bottom: In progress, then the card sections.
21510
+ *
21511
+ * In progress is not in `INDEX_SECTIONS` because no card is ever placed there. It lists work, one
21512
+ * entry per running job or waiting row (`deriveOperations`), and a review being updated stays a
21513
+ * card in its own section while its update is listed here. Windows, Load more, the selection and
21514
+ * the counts are all about cards, so they keep reading `INDEX_SECTIONS`; folding, its memory and its
21515
+ * keyboard commands are about headings, so they read this.
21516
+ */
21517
+ var LIST_SECTIONS = ["in-progress", ...INDEX_SECTIONS];
21518
+ /**
21519
+ * Each section's name, as its heading says it, and the key that folds it from the keyboard
21520
+ * (⌘K L and this key). The commands are made from this table, so a section added here is a section
21521
+ * the keyboard can fold, with no second list to remember.
21522
+ */
21523
+ var lifecycleName = (id) => LIFECYCLES.find((lifecycle) => lifecycle.id === id).name;
21524
+ var INDEX_SECTION_NAMES = {
21525
+ "in-progress": {
21526
+ title: "In progress",
21527
+ key: "i"
21528
+ },
21529
+ pinned: {
21530
+ title: "Pinned",
21531
+ key: "p"
21532
+ },
21533
+ active: {
21534
+ title: lifecycleName("active"),
21535
+ key: "a"
21536
+ },
21537
+ waiting: {
21538
+ title: lifecycleName("waiting"),
21539
+ key: "w"
21540
+ },
21541
+ done: {
21542
+ title: lifecycleName("done"),
21543
+ key: "d"
21544
+ }
21545
+ };
21546
+ /**
21547
+ * Whether a section starts open. The reviewer's choice wins, and otherwise everything is open but
21548
+ * the archives: Done, and each collection's completed reviews. A new collection starts open. The
21549
+ * one place that default is written.
21550
+ */
21551
+ function sectionOpen(section, collapsed) {
21552
+ return !(collapsed?.[section] ?? (section === "done" || section.startsWith("collection-done:")));
21553
+ }
21554
+ /** A section the list draws a heading for, and so one whose fold can be stored. */
21555
+ function isListSection(value) {
21556
+ return typeof value === "string" && LIST_SECTIONS.includes(value);
21557
+ }
21558
+ /** A fold the store accepts: a list section, or a collection's, by an id shaped like one. */
21559
+ function isFoldKey(value) {
21560
+ if (isListSection(value)) return true;
21561
+ if (typeof value !== "string") return false;
21562
+ const match = /^collection(?:-done)?:(.*)$/.exec(value);
21563
+ return match !== null && isCollectionId(match[1]);
21564
+ }
21565
+ function collectionFold(id) {
21566
+ return `collection:${id}`;
21567
+ }
21568
+ function completedFold(id) {
21569
+ return `collection-done:${id}`;
21570
+ }
21571
+ var COLLECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21572
+ /**
21573
+ * Whether a value is shaped like a collection's id. Here, in the module both halves share, because
21574
+ * the hash and the fold store check it in the browser and on the server alike.
21575
+ */
21576
+ function isCollectionId(value) {
21577
+ return typeof value === "string" && COLLECTION_ID.test(value);
21578
+ }
21579
+ /**
21580
+ * Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
21581
+ *
21582
+ * Pinning controls prominence in the general list and never overrides an ordering the reviewer
21583
+ * asked for. Under such a filter a pinned card stays in its lifecycle section, in the order the
21584
+ * filter gives, and only its star says it is pinned.
21585
+ */
21586
+ function indexSectionOf(pinned, lifecycle, activity, filter) {
21587
+ return pinned && !ordersExplicitly(filter) ? "pinned" : sectionOf(lifecycle, activity);
21588
+ }
21589
+ /**
21590
+ * Where a review's own card is drawn, or absent when it is drawn only inside its collections.
21591
+ *
21592
+ * Pinned first and exclusive, as it always was: a pinned review is drawn in Pinned and nowhere else,
21593
+ * collected or not, and unpinning it puts it back where the rest of these rules say. A review in a
21594
+ * collection is drawn there instead of in Active, Waiting or Done, so those three list what nobody
21595
+ * has grouped. Under the Queue filter neither applies: the list is the queue in its order, each card
21596
+ * in its lifecycle section, the same reason `indexSectionOf` gives for Pinned.
21597
+ */
21598
+ function homeSection(pinned, lifecycle, activity, collected, filter) {
21599
+ if (collected && !pinned && !ordersExplicitly(filter)) return void 0;
21600
+ return indexSectionOf(pinned, lifecycle, activity, filter);
21601
+ }
21602
+ /**
21603
+ * Where a review is drawn inside each collection it belongs to: with the collection's reviews still
21604
+ * needing attention, under its completed reviews, or not at all.
21605
+ *
21606
+ * Completed means Done, by the same derivation that places the card everywhere else, so Waiting is
21607
+ * still in play and the Reviewed mark has nothing to do with it. One state for every collection the
21608
+ * review is in: marking it Done moves it in all of them at once. Not drawn while pinned (Pinned is
21609
+ * exclusive) or under the Queue filter (see `homeSection`).
21610
+ */
21611
+ function collectionPlacement(pinned, lifecycle, activity, filter) {
21612
+ if (pinned || ordersExplicitly(filter)) return void 0;
21613
+ return sectionOf(lifecycle, activity) === "done" ? "completed" : "active";
21614
+ }
21615
+ /** A collection heading's count: what still needs attention, and the members drawn in Pinned. */
21616
+ function collectionCountLabel(active, pinned) {
21617
+ const label = `${active} active`;
21618
+ return pinned === 0 ? label : `${label} · ${pinned} pinned`;
21619
+ }
21620
+ /**
21621
+ * The quiet line on a card naming its collections, or absent when it would say nothing new.
21622
+ *
21623
+ * Inside a collection the heading already names that one, so the line says only where else the
21624
+ * review is ("Also in Release 0.82"), and nothing for a review in one collection. Outside every
21625
+ * collection heading, which is Pinned and the Queue filter, it names them all.
21626
+ */
21627
+ function collectionsLine(memberOf, collections, current) {
21628
+ const names = collections.filter((collection) => collection.id !== current && memberOf.includes(collection.id)).map((collection) => collection.name);
21629
+ if (names.length === 0) return void 0;
21630
+ return `${current === void 0 ? "In" : "Also in"} ${names.join(", ")}`;
21631
+ }
21632
+ /**
21633
+ * What a collection says when its main list is empty, or absent when it is not. A collection is
21634
+ * never hidden for having nothing active: it is still somewhere its finished reviews live, and
21635
+ * somewhere to put the next one.
21636
+ */
21637
+ function collectionEmptyText(counts, filtered) {
21638
+ if (counts.active > 0) return void 0;
21639
+ if (filtered) return "Nothing in this collection matches the filters.";
21640
+ if (counts.members === 0) return "Nothing here yet. Add reviews from a card’s menu, or select several and choose Manage collections.";
21641
+ if (counts.pinned > 0 && counts.completed === 0) return "Its reviews are pinned, so they are listed under Pinned.";
21642
+ return "No active reviews";
21643
+ }
21644
+ /** Said under a collection whose oldest members are not drawn, so it never looks smaller than it is. */
21645
+ function omittedText(omitted) {
21646
+ return omitted === 1 ? "1 older review in this collection is not listed." : `${omitted} older reviews in this collection are not listed.`;
21647
+ }
21648
+ /** The disclosure holding a collection's Done reviews. */
21649
+ function completedLabel(completed) {
21650
+ return `Show completed (${completed})`;
21651
+ }
21652
+ /**
21653
+ * Whether a filter replaces the activity order with one the reviewer set. Queue is the only one:
21654
+ * the list is the queue, in its order.
21655
+ * `orderIndex` and `indexSectionOf` both ask this, so the ordering and the placement cannot disagree.
21656
+ */
21657
+ function ordersExplicitly(filter) {
21658
+ return filter === "queue";
21659
+ }
21660
+ /** Keys this page reads, and `page`, which it once wrote and now deliberately forgets. */
21661
+ var KNOWN_KEYS = [
21662
+ "q",
21663
+ "f",
21664
+ "by",
21665
+ "pr",
21666
+ "col",
21667
+ "page"
21668
+ ];
21669
+ /**
21670
+ * "45 reviews · 13 branches", and "13 reviews" when those are the same thirteen things.
21671
+ *
21672
+ * The two numbers are different questions and only both worth asking once they differ. A page of
21673
+ * thirteen branches reviewed once each would read "13 reviews · 13 branches", which invites the
21674
+ * reader to work out what the distinction is on the one page where there isn't one.
21675
+ *
21676
+ * `of` is every review on the page, given while the search or a filter is narrowing the list:
21677
+ * "12 of 71 reviews" says the list is filtered and by how much, where "12 reviews" reads as all
21678
+ * there are.
21679
+ */
21680
+ function countLabel(reviews, branches, of) {
21681
+ const label = of === void 0 ? `${reviews} review${reviews === 1 ? "" : "s"}` : `${reviews} of ${of} review${of === 1 ? "" : "s"}`;
21682
+ if (reviews === branches) return label;
21683
+ return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
21684
+ }
21685
+ /**
21686
+ * The URL for a state, and nothing at all for the state a visitor arrives in.
21687
+ *
21688
+ * An empty hash for the defaults keeps `http://localhost:7726/` the address of the index rather
21689
+ * than of one particular way of looking at it, and keeps a link somebody copies as short as what
21690
+ * they actually did.
21691
+ */
21692
+ function formatIndexHash(state) {
21693
+ const params = new URLSearchParams();
21694
+ if (state.query !== "") params.set("q", state.query);
21695
+ if (state.filter !== "all") params.set("f", state.filter);
21696
+ if (state.author !== "") params.set("by", state.author);
21697
+ if (state.pr !== "any") params.set("pr", state.pr);
21698
+ if (state.collections.length > 0) params.set("col", state.collections.join(","));
21699
+ for (const [key, value] of new URLSearchParams(state.extra ?? "")) if (!KNOWN_KEYS.includes(key)) params.append(key, value);
21700
+ const encoded = params.toString();
21701
+ return encoded === "" ? "" : `#${encoded}`;
21702
+ }
21703
+ /**
21704
+ * The Reviews page showing one collection: what a batch's Open collection link opens, from the
21705
+ * workspace and from the CLI's summary alike.
21706
+ */
21707
+ function collectionHash(id) {
21708
+ return formatIndexHash({
21709
+ query: "",
21710
+ filter: "all",
21711
+ author: "",
21712
+ pr: "any",
21713
+ collections: [id]
21714
+ });
21715
+ }
21716
+ /**
21717
+ * Every login the author dropdown offers, once each, in alphabetical order of what the reader sees:
21718
+ * the name where there is one, the login otherwise, regardless of case. Read from the cards the page
21719
+ * is showing, so it never lists somebody with nothing to show.
21720
+ *
21721
+ * A login's name is the one on its newest review that has a name, chosen here by `createdAt` rather
21722
+ * than by the order the caller happens to pass, so an account renamed upstream reads as it does now.
21723
+ */
21724
+ function authorOptions(cards) {
21725
+ const newest = /* @__PURE__ */ new Map();
21726
+ for (const card of cards) {
21727
+ if (!card.handle) continue;
21728
+ const seen = newest.get(card.handle);
21729
+ if (!seen) newest.set(card.handle, {
21730
+ name: card.name || void 0,
21731
+ createdAt: card.createdAt
21732
+ });
21733
+ else if (card.name && (!seen.name || card.createdAt > seen.createdAt)) newest.set(card.handle, {
21734
+ name: card.name,
21735
+ createdAt: card.createdAt
21736
+ });
21737
+ }
21738
+ return [...newest].map(([handle, { name }]) => name && name !== handle ? {
21739
+ handle,
21740
+ name
21741
+ } : { handle }).sort((a, b) => (a.name ?? a.handle).localeCompare(b.name ?? b.handle, void 0, { sensitivity: "base" }));
21742
+ }
21743
+ /** An author option as the dropdown spells it: the name and the login, or the login alone. */
21744
+ function authorOptionLabel(option) {
21745
+ return option.name ? `${option.name} · @${option.handle}` : `@${option.handle}`;
21746
+ }
21747
+ /**
21748
+ * A card menu's status item, which sets the state it names. The bulk bar's words for the same two
21749
+ * actions, so a card and a selection never disagree. Here rather than in the page renderer because
21750
+ * the browser relabels the item after a change, and that renderer reaches the store.
21751
+ */
21752
+ function markItemLabel(mark) {
21753
+ return mark === "complete" ? "Mark reviewed" : "Mark not reviewed";
21754
+ }
21755
+ /**
21756
+ * The review page's Pin item, which says what pressing it will do. The words of the index's star,
21757
+ * so pinning from either place reads as the same act. Here because the browser relabels the item
21758
+ * after the change, for the reason `markItemLabel` is here.
21759
+ */
21760
+ function pinItemLabel(pinned) {
21761
+ return pinned ? "Unpin review" : "Pin review";
21762
+ }
21763
+ /**
21764
+ * The queue item, on a card and on the review's own page. "review queue" rather than "queue",
21765
+ * because a menu is where somebody meets the idea for the first time. Here because the browser
21766
+ * relabels it after a change, on both pages.
21767
+ */
21768
+ function queueItemLabel(queued) {
21769
+ return queued ? "Remove from review queue" : "Add to review queue";
21770
+ }
21771
+ /**
21772
+ * The Queue pill, which carries a number only once there is one.
21773
+ *
21774
+ * "Queue 0" is a count of something that is not there, and reads as a fault. Bare "Queue" reads as
21775
+ * the place it is -- empty, and still the thing you would click to put something in it.
21776
+ */
21777
+ function queueOptionLabel(queued) {
21778
+ return queued === 0 ? "Queue" : `Queue (${queued})`;
21779
+ }
21780
+ /**
21781
+ * The words on the status chip.
21782
+ *
21783
+ * Here rather than in the renderer because the browser now writes this chip too: marking a
21784
+ * selection reviewed repaints it without asking the server for the page again, and two copies of
21785
+ * these three words is how a page ends up saying "Reviewed" in one place and "Complete" in the
21786
+ * other about the same review.
21787
+ */
21788
+ function statusLabel(status) {
21789
+ if (status === "complete") return "Reviewed";
21790
+ return status === "in-progress" ? "Not reviewed" : "New";
21791
+ }
21792
+ /**
21793
+ * The status pill's accessible name: where the reviewer is, then what pressing it does. The visible
21794
+ * words are the state, so a screen reader hears both rather than only a state it cannot act on.
21795
+ */
21796
+ function statusPillLabel(status) {
21797
+ return `${statusLabel(status)}. Mark as ${status === "complete" ? "not reviewed" : "reviewed"}`;
21798
+ }
21799
+ /**
21800
+ * The bulk bar's way to tick everything the filters show, across pages.
21801
+ *
21802
+ * Counted ("Select all 7") only while the headline is not already saying "of 7", so the number is
21803
+ * on the bar once. With PR set to Merged this is how a reviewer clears the merged reviews in one go,
21804
+ * by way of the existing Mark reviewed.
21805
+ */
21806
+ function selectAllLabel(inView, scopeShown = false) {
21807
+ return inView === 0 || scopeShown ? "Select all" : `Select all ${inView}`;
21808
+ }
21809
+ /** The PR filter's options, in order, as the select shows them. */
21810
+ var PR_FILTER_OPTIONS = [
21811
+ {
21812
+ value: "any",
21813
+ label: "Any"
21814
+ },
21815
+ {
21816
+ value: "open",
21817
+ label: "Open"
21818
+ },
21819
+ {
21820
+ value: "merged",
21821
+ label: "Merged"
21822
+ },
21823
+ {
21824
+ value: "closed",
21825
+ label: "Closed"
21826
+ }
21827
+ ];
21828
+ /**
21829
+ * Whether any of that is work still going on, which is what sets the polling cadence.
21830
+ *
21831
+ * Here rather than beside `analysisStates` because this module is the DOM-free, filesystem-free
21832
+ * arithmetic both halves of the index share, and the renderer needs this one answer without
21833
+ * needing an import that reads the jobs directory to get it.
21834
+ *
21835
+ * A stopped analysis is a state, not an activity. Counting it as live would leave the index asking
21836
+ * once a second about a review that finished failing an hour ago, for as long as the tab stayed
21837
+ * open.
21838
+ */
21839
+ function anythingRunning(states, operations = []) {
21840
+ if (operations.length > 0) return true;
21841
+ for (const state of states.values()) if (state.phase !== "failed") return true;
21842
+ return false;
21843
+ }
21844
+ //#endregion
21012
21845
  //#region ../../packages/review-harness/src/workspace/relative_time.ts
21013
21846
  /**
21014
21847
  * Relative time, in the one wording this product uses.
@@ -21616,4 +22449,4 @@ var MCP_TOOL_NAMES = [
21616
22449
  "ask_buddy"
21617
22450
  ];
21618
22451
  //#endregion
21619
- export { readAskRequest as $, versionFrom as $a, isExplanationAction as $i, hasAuthorContent as $n, latestKissRun as $r, waitingHistory as $t, settleQueue as A, isCommitReview as Aa, deleteCollection as Ai, markRead as An, endedAt as Ar, askCheckout as At, humanizeModelId as B, clearAgentPreference as Ba, readQueue as Bi, unreadCount as Bn, progressSteps as Br, runJob as Bt, parsePullRequestLink as C, describeAuthorship as Ca, lifecyclePillLabel as Ci, categoryOf as Cn, reviewedShaOf as Cr, RefreshUnavailableError as Ct, activateQueued as D, originRemote as Da, collectionsStamp as Di, eventName as Dn, RUN_RECORD_RETENTION_MS as Dr, parkedUpdateRefusal as Dt, relativeTime as E, resolveTarget as Ea, sectionOf as Ei, createPrUpdate as En, fillFileUrlTemplate as Er, mergeUpdateInto as Et, spawnRunner as F, git as Fa, updateCollections as Fi, readStateOf as Fn, saveJob as Fr, failureOf as Ft, reviewTag as G, AGENT_IDS as Ga, questionsOutstanding as Gi, saveRefresh as Gn, CLAIM_LEASE_MS as Gr, stamp as Gt, findingRef as H, writeAgentPreference as Ha, isIssueOutstanding as Hi, existingPaths as Hn, clearClaim as Hr, append as Ht, applyUpdate as I, refExists as Ia, queueAdd as Ii, removeNotification as In, PHASES as Ir, observeReview as It, forgetAskRequest as J, agentIdentityOf as Ja, verdictClosure as Ji, agentEnvOf as Jn, groupByLineage as Jr, telemetryUploadConsent as Jt, reviewTagsIn as K, agentById as Ka, resolvedByConversation as Ki, storedConversation as Kn, decideKissRecommendation as Kr, deliver as Kt, modelProvenanceOf as L, resolveAgent as La, queueAddMany as Li, retireNotification as Ln, isLiveJob as Lr, prepareReview as Lt, RUNNER_MARKER as M, AmbiguousRefError as Ma, readCollections as Mi, moveIds as Mn, isTerminal as Mr, UpdateAlreadyRunningError as Mt, cliEntry as N, shortNameOf as Na, renameCollection as Ni, pruneNotifications as Nn, loadJob as Nr, liveUpdateFor as Nt, compactQueue as O, DetachedHeadError as Oa, createCollection as Oi, listNotifications as On, allJobs as Or, recheckConversation as Ot, runDetached as P, shorthandOf as Pa, setMembership as Pi, readNotification as Pn, pruneJobResidue as Pr, runningJobs as Pt, markAskRunning as Q, runnableAgents as Qa, explanationOf as Qi, authorPreview as Qn, kissDecisionsOf as Qr, feedbackUrl as Qt, provenanceDetailLines as R, resolveModel as Ra, queueRemove as Ri, toneOf as Rn, isTerminalJobPhase as Rr, settleReview as Rt, extractPullRequestLinks as S, objectWithAnyOf as Sa, lifecyclePillAction as Si, subjectOf as Sn, followedBranch as Sr, FETCH_TIMEOUT_MS as St, runBatch as T, kissOutcome as Ta, parseLifecycle as Ti, createPrState as Tn, forgeResolver as Tr, cancellableHere as Tt, findingRefs as U, CONFIG_PATH as Ua, isQuestionOutstanding as Ui, ASK_REREAD_CEILING_MS as Un, driverGone as Ur, readEvents as Ut, findingAtRef as V, readAgentPreference as Va, doneVerb as Vi, withUsageRecorded as Vn, purposeOf as Vr, startJob as Vt, refOf as W, STORE_ROOT as Wa, issuesOutstanding as Wi, prepareConversationWithin as Wn, isClaimed as Wr, record as Wt, isRequestId as X, isAgentId as Xa, recommendationsOf as Xi, chatConversationSection as Xn, indexReviewGroups as Xr, BUILD_VERSION as Xt, interruption as Y, detectAgents as Ya, processDiscussion as Yi, EXPLAIN_SIMPLY_PROMPT as Yn, indexKeptIds as Yr, telemetryUploadEnabled as Yt, isTerminalAsk as Z, parkedReason as Za, explanationActionOf as Zi, UNESTABLISHED_MOTIVATION as Zn, kissDecisionRefusal as Zr, PACKAGE_NAME as Zt, endRun as _, wasReassessed as _a, activityLabel as _i, ancestorsOf as _n, describeMovement as _r, parseWorkspacePort as _t, lineageIds as a, closesFinding as aa, positionInLineage as ai, ARCHITECTURE_DIR as an, renderInlineText as ar, healthyPort as at, stopRun as b, asString$1 as ba, lifecycleItemLabel as bi, announceJob as bn, checkCodeFreshness as br, writePortPreference as bt, discardJob as c, findingIn as ca, recentWorkspaces as ci, partFiles as cn, isBusy as cr, serverEntryExists as ct, startKissJob as d, isGradeFor as da, summarise as di, storeArchitecture as dn, createWorktree as dr, stopServer as dt, withExplanation as ea, lineageKey as ei, mapRuntimes as en, AGENT_RUN_MARKER as eo, GITHUB_MARKDOWN as er, recordAskAccepted as et, isDriving as f, isOffered as fa, touchWorkspace as fi, storedScopes as fn, holdWorktree as fr, workspaceUrl as ft, completeRun as g, statusLookup as ga, activityAttributes as gi, REPOSITORY_SCOPE as gn, requestJobStop as gr, MIN_WORKSPACE_PORT as gt, beginRun as h, sourcePhrase as ha, LIFECYCLES as hi, QUOTE_LIMITS as hn, removeWorktree as hr, MAX_WORKSPACE_PORT as ht, deleteReview as i, assessmentLookup as ia, mutateWorkspace as ii, formatDuration as in, escapeHtml as ir, ensureServer as it, RUNNER_KINDS as j, resolveBase as ja, nameProblemText as ji, markReadForReview as jn, fail as jr, findingsChanged as jt, queueForReview as k, TargetError as ka, createWithMembers as ki, markAllRead as kn, attemptIdOf as kr, updateReview as kt, kissAgentId as l, findingStatus as la, reviewedCommit as li, readArchitecture as ln, withReviewLock as lr, spawnServer as lt, RUN_KINDS as m, reassessIn as ma, workspaceStamps as mi, ownersOf as mn, readMarker as mr, DEFAULT_WORKSPACE_PORT as mt, MCP_TOOL_NAMES as n, NO_STATUS as na, loadWorkspace as ni, RUNTIME_WORDS as nn, AgentUnavailableError as no, CODE_BLOCK_CLASS as nr, bootstrapUrl as nt, liveJobsFor as o, currentGradeIn as oa, presentedKissRunOf as oi, checkArchitecture as on, modelHelpLines as or, readServerRecord as ot, track as p, promptGrade as pa, workspaceRevision as pi, trackedFiles as pn, isWorktreeHeld as pr, writeServerRecord as pt, endAskRequest as q, agentFor as qa, reviewerDispositions as qi, isUnchanged as qn, followedRefName as qr, recordTelemetryUploadConsent as qt, deleteLineage as r, analysisRefOf as ra, matchingWorkspaceIds as ri, describeRuntimes as rn, CODE_COPY_CLASS as rr, clearServerRecordOwnedBy as rt, wasBlocked as s, findingAssessment as sa, previousKissResult as si, loadArchitecture as sn, ReviewBeingDeletedError as sr, reviewsUrl as st, MCP_SERVER_INFO_NAME as t, ANALYSIS_ONLY as ta, lineagePosition as ti, recordRuntimeBestEffort as tn, AgentCancelledError as to, renderMarkdown as tr, readIndexToken as tt, recordKissRun as u, gradeScale as ua, reviewedRepositories as ui, repositoryKey as un, MANAGED_ROOT as ur, stopChild as ut, heldRuns as v, followUpFindingId as va, isLifecycle as vi, isArchitectureId as vn, describeSubject as vr, resolveWorkspacePort as vt, unsupportedReason as w, WITHHELD_REASON as wa, offersLifecycle as wi, createArchitectureNotification as wn, fetchHead as wr, cancelUpdate as wt, batchIdentity as x, extractJsonText as xa, lifecycleName as xi, branchOf as xn, checkReview as xr, COMMIT_REVIEW_CANNOT_UPDATE as xt, reportRun as y, SEVERITIES as ya, lifecycleChip as yi, writeJsonAtomically as yn, findExistingReview as yr, statedWorkspacePort as yt, agentDisplayName as z, agentIdOf as za, queueStamp as zi, unreadAround as zn, phasesFor as zr, reanalyseReview as zt };
22452
+ export { statusLabel as $, gradeScale as $a, workspacesRevision as $i, isArchitectureId as $n, setMembership as $o, describeSubject as $r, resolveWorkspacePort as $t, PR_FILTER_OPTIONS as A, doneVerb as Aa, indexKeptIds as Ai, telemetryUploadEnabled as An, readAgentPreference as Ao, EXPLAIN_SIMPLY_PROMPT as Ar, interruption as At, completedFold as B, explanationActionOf as Ba, matchingWorkspaceIds as Bi, ARCHITECTURE_DIR as Bn, runnableAgents as Bo, renderInlineText as Br, healthyPort as Bt, parsePullRequestLink as C, parseLifecycle as Ca, clearClaim as Ci, append as Cn, shorthandOf as Co, existingPaths as Cr, findingRef as Ct, INDEX_SECTIONS as D, queueRemove as Da, decideKissRecommendation as Di, deliver$1 as Dn, resolveModel as Do, storedConversation as Dr, reviewTagsIn as Dt, relativeTime as E, queueAddMany as Ea, CLAIM_LEASE_MS as Ei, stamp as En, resolveAgent as Eo, saveRefresh as Er, reviewTag as Et, collectionEmptyText as F, resolvedByConversation as Fa, lineageKey as Fi, mapRuntimes as Fn, agentFor as Fo, GITHUB_MARKDOWN as Fr, recordAskAccepted as Ft, isFoldKey as G, NO_STATUS as Ga, previousKissResult as Gi, repositoryKey as Gn, cleanName as Go, MANAGED_ROOT as Gr, stopChild as Gt, countLabel as H, isExplanationAction as Ha, outcomeInputsOf as Hi, loadArchitecture as Hn, AGENT_RUN_MARKER as Ho, ReviewBeingDeletedError as Hr, reviewsUrl as Ht, collectionFold as I, reviewerDispositions as Ia, lineageKeyFor as Ii, recordRuntimeBestEffort as In, agentIdentityOf as Io, renderMarkdown as Ir, readIndexToken as It, pinItemLabel as J, closesFinding as Ja, reviewedRepositories as Ji, trackedFiles as Jn, createWithMembers as Jo, isWorktreeHeld as Jr, writeServerRecord as Jt, markItemLabel as K, analysisRefOf as Ka, recentWorkspaces as Ki, storeArchitecture as Kn, collectionsStamp as Ko, createWorktree as Kr, stopServer as Kt, collectionHash as L, verdictClosure as La, lineageOutcome as Li, RUNTIME_WORDS as Ln, detectAgents as Lo, CODE_BLOCK_CLASS as Lr, bootstrapUrl as Lt, authorOptionLabel as M, isQuestionOutstanding as Ma, kissDecisionRefusal as Mi, PACKAGE_NAME as Mn, CONFIG_PATH as Mo, UNESTABLISHED_MOTIVATION as Mr, isTerminalAsk as Mt, authorOptions as N, issuesOutstanding as Na, kissDecisionsOf as Ni, feedbackUrl as Nn, AGENT_IDS as No, authorPreview as Nr, markAskRunning as Nt, INDEX_SECTION_NAMES as O, queueStamp as Oa, followedRefName as Oi, recordTelemetryUploadConsent as On, agentIdOf as Oo, isUnchanged as Or, endAskRequest as Ot, collectionCountLabel as P, questionsOutstanding as Pa, latestKissRun as Pi, waitingHistory as Pn, agentById as Po, hasAuthorContent as Pr, readAskRequest as Pt, selectAllLabel as Q, findingStatus as Qa, workspaceStamps as Qi, ancestorsOf as Qn, renameCollection as Qo, describeMovement as Qr, parseWorkspacePort as Qt, collectionPlacement as R, processDiscussion as Ra, lineagePosition as Ri, describeRuntimes as Rn, isAgentId as Ro, CODE_COPY_CLASS as Rr, clearServerRecordOwnedBy as Rt, extractPullRequestLinks as S, offersLifecycle as Sa, purposeOf as Si, startJob as Sn, shortNameOf as So, withUsageRecorded as Sr, findingAtRef as St, runBatch as T, queueAdd as Ta, isClaimed as Ti, record as Tn, refExists as To, prepareConversationWithin as Tr, refOf as Tt, homeSection as U, withExplanation as Ua, positionInLineage as Ui, partFiles as Un, AgentCancelledError as Uo, isBusy as Ur, serverEntryExists as Ut, completedLabel as V, explanationOf as Va, mutateWorkspace as Vi, checkArchitecture as Vn, versionFrom as Vo, modelHelpLines as Vr, readServerRecord as Vt, isCollectionId as W, ANALYSIS_ONLY as Wa, presentedKissRunOf as Wi, readArchitecture as Wn, AgentUnavailableError as Wo, withReviewLock as Wr, spawnServer as Wt, queueOptionLabel as X, findingAssessment as Xa, touchWorkspace as Xi, QUOTE_LIMITS as Xn, nameProblemText as Xo, removeWorktree as Xr, MAX_WORKSPACE_PORT as Xt, queueItemLabel as Y, currentGradeIn as Ya, summarise as Yi, ownersOf as Yn, deleteCollection as Yo, readMarker as Yr, DEFAULT_WORKSPACE_PORT as Yt, sectionOpen as Z, findingIn as Za, workspaceRevision as Zi, REPOSITORY_SCOPE as Zn, readCollections as Zo, requestJobStop as Zr, MIN_WORKSPACE_PORT as Zt, endRun as _, lifecycleChip as _a, PHASES as _i, observeReview as _n, DetachedHeadError as _o, removeNotification as _r, applyUpdate as _t, lineageIds as a, nextBaseline as aa, fetchHead as ai, cancelUpdate as an, statusLookup as ao, createArchitectureNotification as ar, retryableMiss as at, stopRun as b, lifecyclePillAction as ba, phasesFor as bi, reanalyseReview as bn, resolveBase as bo, unreadAround as br, agentDisplayName as bt, discardJob as c, outcomeStanding as ca, RUN_RECORD_RETENTION_MS as ci, parkedUpdateRefusal as cn, SEVERITIES as co, eventName as cr, activateQueued as ct, startKissJob as d, readOutcomes as da, endedAt as di, askCheckout as dn, objectWithAnyOf as do, markRead as dr, settleQueue as dt, analysedAt as ea, findExistingReview as ei, statedWorkspacePort as en, isGradeFor as eo, writeJsonAtomically as er, withWriteLock as es, statusPillLabel as et, isDriving as f, sameSha as fa, fail as fi, findingsChanged as fn, describeAuthorship as fo, markReadForReview as fr, RUNNER_KINDS as ft, completeRun as g, isLifecycle as ga, saveJob as gi, failureOf as gn, originRemote as go, readStateOf as gr, spawnRunner as gt, beginRun as h, activityLabel as ha, pruneJobResidue as hi, runningJobs as hn, resolveTarget as ho, readNotification as hr, runDetached as ht, deleteReview as i, lastBaseline as ia, reviewedShaOf as ii, RefreshUnavailableError as in, sourcePhrase as io, categoryOf as ir, openDestination as it, anythingRunning as j, isIssueOutstanding as ja, indexReviewGroups as ji, BUILD_VERSION as jn, writeAgentPreference as jo, chatConversationSection as jr, isRequestId as jt, LIST_SECTIONS as k, readQueue as ka, groupByLineage as ki, telemetryUploadConsent as kn, clearAgentPreference as ko, agentEnvOf as kr, forgetAskRequest as kt, kissAgentId as l, parseOutcomeSnapshot as la, allJobs as li, recheckConversation as ln, asString$1 as lo, listNotifications as lr, compactQueue as lt, RUN_KINDS as m, activityAttributes as ma, loadJob as mi, liveUpdateFor as mn, kissOutcome as mo, pruneNotifications as mr, cliEntry as mt, MCP_TOOL_NAMES as n, REVIEWER_OUTCOMES as na, checkReview as ni, COMMIT_REVIEW_CANNOT_UPDATE as nn, promptGrade as no, branchOf as nr, isDelivered as nt, liveJobsFor as o, outcomeDefinition as oa, forgeResolver as oi, cancellableHere as on, wasReassessed as oo, createPrState as or, lineageKeysFor as ot, track as p, LIFECYCLES as pa, isTerminal as pi, UpdateAlreadyRunningError as pn, WITHHELD_REASON as po, moveIds as pr, RUNNER_MARKER as pt, omittedText as q, assessmentLookup as qa, reviewedCommit as qi, storedScopes as qn, createCollection as qo, holdWorktree as qr, workspaceUrl as qt, deleteLineage as r, appendOutcome as ra, followedBranch as ri, FETCH_TIMEOUT_MS as rn, reassessIn as ro, subjectOf as rr, missedDelivery as rt, wasBlocked as s, outcomeLabel as sa, fillFileUrlTemplate as si, mergeUpdateInto as sn, followUpFindingId as so, createPrUpdate as sr, settleCollections as st, MCP_SERVER_INFO_NAME as t, analysisSnapshot as ta, checkCodeFreshness as ti, writePortPreference as tn, isOffered as to, announceJob as tr, STORE_ROOT as ts, deliver as tt, recordKissRun as u, parseOutcomes as ua, attemptIdOf as ui, updateReview as un, extractJsonText as uo, markAllRead as ur, queueForReview as ut, heldRuns as v, lifecycleItemLabel as va, isLiveJob as vi, prepareReview as vn, TargetError as vo, retireNotification as vr, modelProvenanceOf as vt, unsupportedReason as w, sectionOf as wa, driverGone as wi, readEvents as wn, git as wo, ASK_REREAD_CEILING_MS as wr, findingRefs as wt, batchIdentity as x, lifecyclePillLabel as xa, progressSteps as xi, runJob as xn, AmbiguousRefError as xo, unreadCount as xr, humanizeModelId as xt, reportRun as y, lifecycleName$1 as ya, isTerminalJobPhase as yi, settleReview as yn, isCommitReview as yo, toneOf as yr, provenanceDetailLines as yt, collectionsLine as z, recommendationsOf as za, loadWorkspace as zi, formatDuration as zn, parkedReason as zo, escapeHtml as zr, ensureServer as zt };