prreviewbuddy 0.56.0 → 0.59.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/{delete_review-C5bz0Pts.js → delete_review-D59T6NBn.js} +523 -71
  3. package/dist/main.js +1 -1
  4. package/dist/server.js +2632 -253
  5. package/package.json +1 -1
  6. package/static/chunk-4nFoFuBF.js +1 -0
  7. package/static/{chunk-BFC1myXj.js → chunk-6Hm8gwfc.js} +1 -1
  8. package/static/chunk-6olMbSDC.js +1 -0
  9. package/static/chunk-7g6hehZR.js +1 -0
  10. package/static/{chunk-DwZ_OKDV.js → chunk-9OYdRhXM.js} +1 -1
  11. package/static/{chunk-BOGxj-MG.js → chunk-9QJSvyT-.js} +1 -1
  12. package/static/{chunk-I02WWcy5.js → chunk-A2MXL8RD.js} +1 -1
  13. package/static/{chunk-CUxm3YXF.js → chunk-B-Up-Dum.js} +1 -1
  14. package/static/{chunk-Dlx_va8c.js → chunk-B1WAu5pS.js} +1 -1
  15. package/static/{chunk-BLYKMRgX.js → chunk-B3KwWIxA.js} +1 -1
  16. package/static/{chunk-CVKo9lqM.js → chunk-BH2wqCle.js} +1 -1
  17. package/static/chunk-BIhcpq89.js +1 -0
  18. package/static/chunk-BJt-3Igo.js +1 -0
  19. package/static/{chunk-Cuz7gAE5.js → chunk-BQ5h3ZKa.js} +1 -1
  20. package/static/chunk-BV3co1iE.js +1 -0
  21. package/static/{chunk-CNECTSY3.js → chunk-BW1ANL1o.js} +1 -1
  22. package/static/{chunk-DN473Oml.js → chunk-BXful7fw.js} +1 -1
  23. package/static/{chunk-nU5620mN.js → chunk-Baon_ihZ.js} +1 -1
  24. package/static/chunk-BcyJP52O.js +1 -0
  25. package/static/{chunk-DdkvfxwB.js → chunk-BfJ5btxI.js} +1 -1
  26. package/static/{chunk-DOebnhRz.js → chunk-BoKYaT6d.js} +1 -1
  27. package/static/chunk-Bqz19UgW.js +1 -0
  28. package/static/chunk-BvKoviOH.js +8 -0
  29. package/static/{chunk-DFxPlKZ4.js → chunk-C6TLHpBY.js} +1 -1
  30. package/static/chunk-C8f8HA4G.js +1 -0
  31. package/static/{chunk-tz4nb_gp.js → chunk-CCkeMk9N.js} +1 -1
  32. package/static/chunk-CGgxik56.js +1 -0
  33. package/static/{chunk-DmCmlw77.js → chunk-CIfNEzlc.js} +1 -1
  34. package/static/{chunk-gyMXflOg.js → chunk-CPbLHMZi.js} +1 -1
  35. package/static/{chunk-C3zyi6X6.js → chunk-CUEta9tE.js} +1 -1
  36. package/static/chunk-CU_pLmXg.js +1 -0
  37. package/static/{chunk-BveIbUwm.js → chunk-CV0YoHS0.js} +1 -1
  38. package/static/{chunk-Dx2-dNbd.js → chunk-CYEqf7aI.js} +1 -1
  39. package/static/{chunk-IT8Wx1rP.js → chunk-C_6e3UXl.js} +1 -1
  40. package/static/chunk-Caya2m0E.js +1 -0
  41. package/static/{chunk-2cPIwDyu.js → chunk-CkFz1wgR.js} +1 -1
  42. package/static/chunk-Co7Cgu9F.js +1 -0
  43. package/static/{chunk-CNG5uajc.js → chunk-CqXFWjSQ.js} +1 -1
  44. package/static/chunk-CvSN2dS5.js +1 -0
  45. package/static/{chunk-COmCkMOw.js → chunk-CwCL5WnJ.js} +1 -1
  46. package/static/chunk-D5bmztPJ.js +1 -0
  47. package/static/chunk-D6EJPfS7.js +1 -0
  48. package/static/{chunk-BeqgAIrQ.js → chunk-D6oLtkSC.js} +4 -4
  49. package/static/{chunk-Wc3Sd0cy.js → chunk-D71XVZIg.js} +1 -1
  50. package/static/{chunk-DJ4Pz6CG.js → chunk-D7hFEgee.js} +1 -1
  51. package/static/{chunk-BKQNYMf0.js → chunk-D9Rf_Mu5.js} +1 -1
  52. package/static/{chunk-CWJTY3LS.js → chunk-DF3g9oko.js} +1 -1
  53. package/static/{chunk-CN9Gw02i.js → chunk-DIEkEPeW.js} +1 -1
  54. package/static/{chunk-BhOdd-9U.js → chunk-DInBXUWf.js} +1 -1
  55. package/static/chunk-DJqLf38C.js +1 -0
  56. package/static/chunk-D_CdBIAq.js +1 -0
  57. package/static/{chunk-i2ycf8WY.js → chunk-Dbx-B0iA.js} +1 -1
  58. package/static/{chunk-B6QMLbPb.js → chunk-DcXKkJre.js} +1 -1
  59. package/static/{chunk-BwpXVz85.js → chunk-DgcdmK42.js} +1 -1
  60. package/static/chunk-DmTwixkl.js +1 -0
  61. package/static/chunk-DmZq9LP2.js +1 -0
  62. package/static/{chunk-CU_yc7c0.js → chunk-DqxlbQOu.js} +3 -3
  63. package/static/{chunk-CVHEykQZ.js → chunk-DrNGVTkX.js} +1 -1
  64. package/static/chunk-Ds3YROGC.js +1 -0
  65. package/static/{chunk-DecH1K7C.js → chunk-DuX-olw5.js} +1 -1
  66. package/static/{chunk-BsaS8BTP.js → chunk-DwHSAwhM.js} +1 -1
  67. package/static/{chunk-CFLfawWY.js → chunk-DyBGMc4d.js} +1 -1
  68. package/static/{chunk-B-z83rju.js → chunk-G3ZceoN1.js} +1 -1
  69. package/static/{chunk-Bh1X0--a.js → chunk-Gu4zXr01.js} +1 -1
  70. package/static/{chunk-DxnpLefu.js → chunk-IkExJtku.js} +1 -1
  71. package/static/{chunk-Dgyv2z85.js → chunk-J8FddCDs.js} +1 -1
  72. package/static/{chunk-DYi0PdOM.js → chunk-M1_E44jC.js} +1 -1
  73. package/static/{chunk-B0sWk9pb.js → chunk-Mg4TtgDp.js} +1 -1
  74. package/static/chunk-cyhKpRPg.js +1 -0
  75. package/static/{chunk-xyBkudn9.js → chunk-fE1xdnsY.js} +1 -1
  76. package/static/{chunk-BgAz67x8.js → chunk-lIN4-wAm.js} +1 -1
  77. package/static/{chunk-PhC9V0kv.js → chunk-msOfdoxN.js} +1 -1
  78. package/static/{chunk-Tk2BGjOK.js → chunk-n2czfdCl.js} +1 -1
  79. package/static/{chunk-CqXMuRFL.js → chunk-rzRU0blB.js} +1 -1
  80. package/static/{chunk-745C7DWL.js → chunk-zZBc5XuD.js} +1 -1
  81. package/static/help.js +1 -1
  82. package/static/notifications.js +1 -1
  83. package/static/reviews.js +1 -1
  84. package/static/settings.js +1 -1
  85. package/static/shell.css +215 -14
  86. package/static/sidebar.css +2 -7
  87. package/static/workspace.js +66 -58
  88. package/static/chunk-5rwdhcVY.js +0 -1
  89. package/static/chunk-BFRx3GcN.js +0 -1
  90. package/static/chunk-BNE4vRAl.js +0 -1
  91. package/static/chunk-BVqE4wlY.js +0 -1
  92. package/static/chunk-BYPUwVJd.js +0 -8
  93. package/static/chunk-BZFc_oT5.js +0 -1
  94. package/static/chunk-BfpeM2_Y.js +0 -1
  95. package/static/chunk-Bz6J7zHc.js +0 -1
  96. package/static/chunk-CyJ4bWgC.js +0 -1
  97. package/static/chunk-D7FarRGf.js +0 -1
  98. package/static/chunk-DDhk861e.js +0 -1
  99. package/static/chunk-DTTB1Rjf.js +0 -1
  100. package/static/chunk-DVXDUcQZ.js +0 -1
  101. package/static/chunk-DYSi9-67.js +0 -1
  102. package/static/chunk-DdmKGZXf.js +0 -1
  103. package/static/chunk-DpHRgEqx.js +0 -1
  104. package/static/chunk-DufL1VKq.js +0 -1
  105. package/static/chunk-SWs44m7G.js +0 -1
  106. package/static/chunk-qMjAlJP9.js +0 -1
  107. package/static/chunk-qnagBFlM.js +0 -1
  108. package/static/chunk-yqeNYXbH.js +0 -1
@@ -1,5 +1,5 @@
1
- import { basename, delimiter, join, resolve, sep } from "node:path";
2
- import { execFile, spawn } from "node:child_process";
1
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
2
+ import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { accessSync, appendFileSync, closeSync, constants, existsSync, linkSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { homedir, hostname, tmpdir } from "node:os";
@@ -1797,7 +1797,8 @@ async function git(cwd, args, options = {}) {
1797
1797
  cwd,
1798
1798
  encoding: "utf8",
1799
1799
  maxBuffer: MAX_BUFFER$1,
1800
- ...options.timeoutMs ? { timeout: options.timeoutMs } : {}
1800
+ ...options.timeoutMs ? { timeout: options.timeoutMs } : {},
1801
+ ...options.signal ? { signal: options.signal } : {}
1801
1802
  });
1802
1803
  return stdout;
1803
1804
  } catch (error) {
@@ -1806,6 +1807,30 @@ async function git(cwd, args, options = {}) {
1806
1807
  throw new GitError(args, err.stderr ?? String(error));
1807
1808
  }
1808
1809
  }
1810
+ /**
1811
+ * The same, blocking, for the few local questions asked where nothing can wait: which repository a
1812
+ * path belongs to, asked while naming where its records live. Read-only plumbing only; what it may
1813
+ * run is pinned by `git_sync.test.ts`.
1814
+ */
1815
+ function gitSync(cwd, args) {
1816
+ try {
1817
+ return execFileSync("git", [
1818
+ "-c",
1819
+ "core.quotePath=false",
1820
+ ...args
1821
+ ], {
1822
+ cwd,
1823
+ encoding: "utf8",
1824
+ stdio: [
1825
+ "ignore",
1826
+ "pipe",
1827
+ "pipe"
1828
+ ]
1829
+ });
1830
+ } catch (error) {
1831
+ throw new GitError(args, String(error.stderr ?? error));
1832
+ }
1833
+ }
1809
1834
  /** True when the ref resolves. Used to check a base candidate without treating absence as failure. */
1810
1835
  async function refExists(cwd, ref) {
1811
1836
  try {
@@ -2805,19 +2830,16 @@ async function peel(repoPath, named) {
2805
2830
  async function resolveTarget(input) {
2806
2831
  const root = await repositoryRoot(input.repoPath);
2807
2832
  if (input.commitRef) return commitTarget(root, input.commitRef);
2808
- try {
2809
- if (input.base) await verifyBase(root, input.base);
2810
- else await verifyDefaultBase(root);
2811
- } catch (error) {
2812
- if (!(error instanceof AmbiguousRefError)) throw error;
2813
- const head = await headToMeasure(root, input);
2814
- throw head ? await withReviewRanges(error, root, head) : error;
2815
- }
2833
+ const base = input.base;
2834
+ const head = () => headToMeasure(root, input);
2835
+ if (base) await verifyOrMeasure(root, () => verifyBase(root, base), head);
2836
+ else if (!input.prUrl) await verifyOrMeasure(root, () => verifyDefaultBase(root), head);
2816
2837
  const named = input.base ? { base: input.base } : {};
2817
2838
  if (input.prUrl) {
2818
2839
  if (!input.resolveRequest) throw new TargetError(`${input.prUrl} is a pull request, but nothing here can look one up. Check the request out locally and review the branch instead.`);
2819
2840
  const request = await input.resolveRequest(root, input.prUrl);
2820
2841
  await ensureCommitPresent(root, request.headSha, request.headRef);
2842
+ if (!input.base && !request.baseRef) await verifyOrMeasure(root, () => verifyDefaultBase(root), async () => request.headSha);
2821
2843
  const resolvedBase = input.base ? void 0 : await resolvePullRequestBase(root, request);
2822
2844
  return {
2823
2845
  originRepoPath: root,
@@ -2968,6 +2990,21 @@ async function verifyBase(root, base) {
2968
2990
  * than handed to git as typed, because a name that is itself ambiguous would be settled by git's own
2969
2991
  * preference, which is the guess this module refuses to make.
2970
2992
  */
2993
+ /**
2994
+ * Run a base check, and give an ambiguity refusal the ranges each choice would review.
2995
+ *
2996
+ * Measured only for the refusal: which commits each choice would review is the one thing that
2997
+ * turns "name the one you mean" into a decision the reviewer can make.
2998
+ */
2999
+ async function verifyOrMeasure(root, check, head) {
3000
+ try {
3001
+ await check();
3002
+ } catch (error) {
3003
+ if (!(error instanceof AmbiguousRefError)) throw error;
3004
+ const measured = await head();
3005
+ throw measured ? await withReviewRanges(error, root, measured) : error;
3006
+ }
3007
+ }
2971
3008
  async function headToMeasure(root, input) {
2972
3009
  if (input.prUrl) return void 0;
2973
3010
  if (!input.branchRef) return "HEAD";
@@ -6299,7 +6336,7 @@ function sanitisePullRequest(value) {
6299
6336
  };
6300
6337
  const title = typeof value.title === "string" ? value.title.trim() : "";
6301
6338
  if (title) pr.title = title.slice(0, 200);
6302
- if (typeof value.body === "string" && value.body.trim()) pr.body = value.body;
6339
+ if (typeof value.body === "string") pr.body = value.body.trim() ? value.body : "";
6303
6340
  const headSha = label(value.headSha);
6304
6341
  if (headSha) pr.headSha = headSha;
6305
6342
  const headRef = label(value.headRef);
@@ -6471,8 +6508,17 @@ function authStatusArgs(host) {
6471
6508
  *
6472
6509
  * `host` defaults for the one caller that has no repository in hand yet. Both callers that are
6473
6510
  * about to make a real request pass the host they are about to make it against.
6511
+ *
6512
+ * **A directory that is not there is not a missing `gh`, and is thrown rather than answered.** Node
6513
+ * reports a spawn into a missing `cwd` as `ENOENT`, exactly as it reports a missing executable, so
6514
+ * without this check a review whose checkout had been deleted read as "`gh` is not installed". The
6515
+ * watcher takes that verdict as true of every request reached the same way, and one stale checkout
6516
+ * then silenced every other check for the rest of the pass. Neither verdict this function returns is
6517
+ * about one directory, so it says neither: the caller gets a failure that belongs to the one
6518
+ * review whose checkout it is.
6474
6519
  */
6475
6520
  async function ghAvailable(cwd, env, host = "github.com") {
6521
+ if (!existsSync(cwd)) throw new GhError(`The checkout at ${cwd} no longer exists.`);
6476
6522
  try {
6477
6523
  await run(["--version"], cwd, env);
6478
6524
  } catch {
@@ -6947,7 +6993,15 @@ function readHeadSha(raw) {
6947
6993
  };
6948
6994
  }
6949
6995
  async function headOf(repo, number, env, cwd) {
6950
- const availability = await ghAvailable(cwd, env, repo.host);
6996
+ let availability;
6997
+ try {
6998
+ availability = await ghAvailable(cwd, env, repo.host);
6999
+ } catch (error) {
7000
+ return {
7001
+ kind: "failed",
7002
+ detail: error.message
7003
+ };
7004
+ }
6951
7005
  if (!availability.ok) return {
6952
7006
  kind: availability.kind,
6953
7007
  cli: availability.cli
@@ -7542,6 +7596,15 @@ function stopServer() {
7542
7596
  detail: `Stopped the workspace server (pid ${record.pid}, port ${record.port}). Open reviews are no longer served; running a review starts it again.`
7543
7597
  };
7544
7598
  }
7599
+ /**
7600
+ * Remove the record only if it is this process's. A server that is shutting down must not delete
7601
+ * the record of the one that is serving: a replacement stopped during a failed handover is signalled
7602
+ * just after the old server has written its own record back, and an unconditional delete there left
7603
+ * a live server with no record, which the next CLI start then could not find.
7604
+ */
7605
+ function clearServerRecordOwnedBy(pid) {
7606
+ if (readServerRecord()?.pid === pid) clearServerRecord();
7607
+ }
7545
7608
  function clearServerRecord() {
7546
7609
  try {
7547
7610
  unlinkSync(SERVER_RECORD);
@@ -7713,7 +7776,11 @@ async function ensureServer() {
7713
7776
  * the new file at the same path, which is the main reason anybody wants one.
7714
7777
  */
7715
7778
  function spawnServer() {
7716
- const child = spawn(process.execPath, [serverEntry()], {
7779
+ return spawnDetached([serverEntry()]);
7780
+ }
7781
+ /** How `spawnServer` starts node, apart so a test can start something else exactly the same way. */
7782
+ function spawnDetached(args) {
7783
+ const child = spawn(process.execPath, args, {
7717
7784
  detached: true,
7718
7785
  stdio: "ignore",
7719
7786
  cwd: STORE_ROOT
@@ -7721,6 +7788,24 @@ function spawnServer() {
7721
7788
  child.unref();
7722
7789
  return child;
7723
7790
  }
7791
+ /**
7792
+ * Stop a replacement that never answered, and return only once it has gone.
7793
+ *
7794
+ * Waiting is the point. A replacement that bound but answered too slowly is a server like any other:
7795
+ * on SIGTERM it clears the record if the record is its own. Checking and deleting are two steps, so
7796
+ * a server that wrote its record back while that handler ran could lose it. Writing only after the
7797
+ * child has exited leaves nothing to race. SIGKILL after the grace, so a child that ignores the
7798
+ * signal cannot hold the restart open, or the port, for ever.
7799
+ */
7800
+ async function stopChild(child, graceMs = 2e3) {
7801
+ if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) return;
7802
+ child.ref();
7803
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
7804
+ child.kill("SIGTERM");
7805
+ if (await Promise.race([exited.then(() => true), setTimeout$1(graceMs).then(() => false)])) return;
7806
+ child.kill("SIGKILL");
7807
+ await exited;
7808
+ }
7724
7809
  /** Whether there is anything to start. The one foreseeable reason a restart cannot be attempted. */
7725
7810
  function serverEntryExists() {
7726
7811
  return existsSync(serverEntry());
@@ -8007,6 +8092,65 @@ function explainAgentFailure(agent, detail, env, requestedModel) {
8007
8092
  const hint = modelFailureHint(agent, detail, requestedModel);
8008
8093
  return hint ? `${replaced} ${hint}` : replaced;
8009
8094
  }
8095
+ function endingPath(jobId) {
8096
+ return jobPath(jobId).replace(/\.json$/, ".ending");
8097
+ }
8098
+ /**
8099
+ * Publish `wanted` unless an ending is already published, and say which ending stands, or null when
8100
+ * none can be read. Null is never a win for either side.
8101
+ */
8102
+ function claimEnding(jobId, wanted) {
8103
+ const path = endingPath(jobId);
8104
+ mkdirSync(dirname(path), { recursive: true });
8105
+ const draft = `${path}.${randomUUID()}.draft`;
8106
+ writeFileSync(draft, wanted, { mode: 384 });
8107
+ try {
8108
+ linkSync(draft, path);
8109
+ return wanted;
8110
+ } catch (error) {
8111
+ if (error.code !== "EEXIST") throw error;
8112
+ return readEnding(jobId);
8113
+ } finally {
8114
+ unlinkSync(draft);
8115
+ }
8116
+ }
8117
+ function readEnding(jobId) {
8118
+ try {
8119
+ const text = readFileSync(endingPath(jobId), "utf8");
8120
+ return text === "stop" || text === "commit" ? text : null;
8121
+ } catch {
8122
+ return null;
8123
+ }
8124
+ }
8125
+ /** The page's half. True when the run will stop; false when it had already committed to saving. */
8126
+ function requestJobStop(jobId) {
8127
+ return claimEnding(jobId, "stop") === "stop";
8128
+ }
8129
+ /** The driver's half, before anything is saved. False means a stop got there first. */
8130
+ function commitJob(jobId) {
8131
+ return claimEnding(jobId, "commit") === "commit";
8132
+ }
8133
+ function jobStopRequested(jobId) {
8134
+ return readEnding(jobId) === "stop";
8135
+ }
8136
+ function clearJobEnding(jobId) {
8137
+ try {
8138
+ unlinkSync(endingPath(jobId));
8139
+ } catch {}
8140
+ }
8141
+ /**
8142
+ * Abort `controller` once a stop is requested, so the agent is killed while it works rather than
8143
+ * only refused at the commit. Returns the way to stop watching.
8144
+ *
8145
+ * Unref'd, so a terminal command is never kept alive by the watch alone once its review is over.
8146
+ */
8147
+ function watchForStop(jobId, controller, intervalMs = 500) {
8148
+ const timer = setInterval(() => {
8149
+ if (jobStopRequested(jobId)) controller.abort();
8150
+ }, intervalMs);
8151
+ timer.unref();
8152
+ return () => clearInterval(timer);
8153
+ }
8010
8154
  //#endregion
8011
8155
  //#region ../../packages/review-harness/src/workspace/worktree.ts
8012
8156
  /**
@@ -8096,6 +8240,21 @@ function readMarker(worktreePath) {
8096
8240
  }
8097
8241
  }
8098
8242
  /**
8243
+ * Worktrees this process is using with no job record behind them: a mapping run's disposable
8244
+ * checkout. The sweep removes an unclaimed worktree git owns at once, whatever its age, so without
8245
+ * this it took one from under a run that lasted past the next sweep. Held in memory on purpose: a
8246
+ * server that dies releases every hold, and what it left behind is residue again.
8247
+ */
8248
+ var held = /* @__PURE__ */ new Set();
8249
+ /** Hold `path` until the returned release is called. Take it before the worktree is created. */
8250
+ function holdWorktree(path) {
8251
+ held.add(path);
8252
+ return () => held.delete(path);
8253
+ }
8254
+ function isWorktreeHeld(path) {
8255
+ return held.has(path);
8256
+ }
8257
+ /**
8099
8258
  * Delete one managed worktree, or explain why not.
8100
8259
  *
8101
8260
  * Two paths, and the difference between them is where the authority comes from.
@@ -9219,51 +9378,87 @@ var REVIEW_ATTENTIONS = [
9219
9378
  * 17KB of perfectly good JSON to 583 characters of prose in front of it, and there was nothing
9220
9379
  * wrong with that answer this function could not have read.
9221
9380
  *
9222
- * Nothing found leaves the reply exactly as it came, so the parser above still reports on what it
9223
- * was actually given rather than on an empty string this function invented.
9381
+ * The answer is the first value `isAnswer` accepts, which each caller says in the terms of its own
9382
+ * schema. When none is accepted the first value that parsed comes back, so the parser above still
9383
+ * reports the wrong shape in its own words. Nothing found at all leaves the reply exactly as it
9384
+ * came, so the parser reports on what it was actually given rather than on an empty string this
9385
+ * function invented.
9224
9386
  */
9225
- function extractJsonText(raw) {
9387
+ function extractJsonText(raw, isAnswer) {
9226
9388
  const text = raw.trim();
9227
- for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
9228
- const inner = match[1].trim();
9229
- if ((inner.startsWith("{") || inner.startsWith("[")) && parses(inner)) return inner;
9389
+ let first;
9390
+ for (const candidate of jsonCandidates(text)) {
9391
+ first ??= candidate.text;
9392
+ if (isAnswer(candidate.value)) return candidate.text;
9230
9393
  }
9231
- return firstJsonValue(text) ?? text;
9394
+ return first ?? text;
9232
9395
  }
9233
- /** Whether this is a whole JSON value, which is the only thing that makes a candidate an answer. */
9234
- function parses(candidate) {
9235
- try {
9236
- JSON.parse(candidate);
9237
- return true;
9238
- } catch {
9239
- return false;
9240
- }
9396
+ /**
9397
+ * The shape check most callers want: an object carrying at least one of the fields its schema asks
9398
+ * for, holding something a reply could use. Every field these schemas name is text or a list, so
9399
+ * that is what counts: words, or an array. `{"summary": null}` or `{"summary": 42}` is not an answer,
9400
+ * and a complete one later in the reply still wins. Deliberately no deeper than that, because each
9401
+ * parser makes its own decision about a thin answer and reports it in its own words; this only has
9402
+ * to tell an answer from an example quoted before it.
9403
+ */
9404
+ function objectWithAnyOf(...fields) {
9405
+ return (value) => typeof value === "object" && value !== null && !Array.isArray(value) && fields.some((field) => usable(value[field]));
9406
+ }
9407
+ function usable(value) {
9408
+ return typeof value === "string" && value.trim() !== "" || Array.isArray(value);
9241
9409
  }
9242
9410
  /**
9243
- * The first slice of this text that is a whole JSON value.
9411
+ * Every whole JSON value in this text, in the order an answer is looked for: fenced ones first,
9412
+ * then each slice the scanner reads whole.
9244
9413
  *
9245
9414
  * Every `{` and `[` is tried in turn, because a brace in a sentence about code is not a rare thing
9246
9415
  * to find in a paragraph a model wrote before the answer. Balance alone would accept one, so each
9247
- * candidate is parsed and the first that survives is the answer. For a reply that is only the
9248
- * object, that is the first character and the loop ends immediately.
9416
+ * candidate is parsed and only one that survives is offered. For a reply that is only the object,
9417
+ * that is the first character.
9418
+ *
9419
+ * Parsing is not enough to be the answer, which is why the caller judges each one by its schema.
9420
+ * Prose that quotes `{"example": true}` before the real reply parses perfectly well, and taking it
9421
+ * made a usable answer fail downstream as the wrong shape. A value that was offered is skipped
9422
+ * whole, so an object nested inside a quoted example is not offered in its place.
9249
9423
  *
9250
9424
  * Strings are tracked because a brace inside one is a character rather than a nesting level, and
9251
9425
  * counting it would end the value early or never end it at all. Escapes are tracked because a
9252
9426
  * quote inside a string is the same problem one level down.
9253
9427
  */
9254
- function firstJsonValue(text) {
9428
+ function* jsonCandidates(text) {
9429
+ for (const match of text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
9430
+ const inner = match[1].trim();
9431
+ if (!inner.startsWith("{") && !inner.startsWith("[")) continue;
9432
+ const value = parsed(inner);
9433
+ if (value.ok) yield {
9434
+ text: inner,
9435
+ value: value.value
9436
+ };
9437
+ }
9255
9438
  for (let start = 0; start < text.length; start += 1) {
9256
9439
  const opener = text[start];
9257
9440
  if (opener !== "{" && opener !== "[") continue;
9258
9441
  const end = closingIndex(text, start, opener);
9259
9442
  if (end === -1) continue;
9260
9443
  const candidate = text.slice(start, end + 1);
9261
- try {
9262
- JSON.parse(candidate);
9263
- return candidate;
9264
- } catch {}
9444
+ const value = parsed(candidate);
9445
+ if (!value.ok) continue;
9446
+ yield {
9447
+ text: candidate,
9448
+ value: value.value
9449
+ };
9450
+ start = end;
9451
+ }
9452
+ }
9453
+ function parsed(candidate) {
9454
+ try {
9455
+ return {
9456
+ ok: true,
9457
+ value: JSON.parse(candidate)
9458
+ };
9459
+ } catch {
9460
+ return { ok: false };
9265
9461
  }
9266
- return null;
9267
9462
  }
9268
9463
  /** Where the value opened at `start` closes, or -1 if it never does. */
9269
9464
  function closingIndex(text, start, opener) {
@@ -10374,6 +10569,8 @@ var MOTIVATION_EVIDENCE_KINDS = [
10374
10569
  ];
10375
10570
  /** Ours, not the model's: what an unsupported claim is replaced with when a question remains. */
10376
10571
  var UNESTABLISHED_MOTIVATION = "It isn't clear why this change is needed.";
10572
+ /** Ours, not the model's: asked when a failed or unclear motive came with no question of its own. */
10573
+ var FALLBACK_INTENT_QUESTION = "What problem does this change solve, or what goal does it serve?";
10377
10574
  /** Stable, so "Mark answered" on it survives a reload like any other question's. */
10378
10575
  var INTENT_QUESTION_ID = "intent-motivation";
10379
10576
  /**
@@ -10561,6 +10758,26 @@ function withIntentQuestion(questions, motivation) {
10561
10758
  category: "intent"
10562
10759
  }];
10563
10760
  }
10761
+ /**
10762
+ * A finished analysis's motive, carried onto the description an update will show beside it.
10763
+ *
10764
+ * Commits pushed since show what changed, not why, so a motive is carried rather than re-asked.
10765
+ * But a `stated` one is a quote, and it is only true of the description it was found in. When the
10766
+ * author has edited that description since, the quote is looked for again, in the same words and by
10767
+ * the same check as at analysis time; one that is gone is no longer the author's stated reason and
10768
+ * becomes unclear, asking our question. An unchanged description, or a motive that never rested on
10769
+ * it, is carried exactly as it was.
10770
+ */
10771
+ function carryMotivation(motivation, checkedAgainst, describedNow) {
10772
+ if (motivation?.status !== "stated" || checkedAgainst === describedNow) return motivation;
10773
+ if (isAuthorQuote(motivation.sourceQuote, describedNow)) return motivation;
10774
+ return {
10775
+ contractVersion: 1,
10776
+ status: "unclear",
10777
+ summary: UNESTABLISHED_MOTIVATION,
10778
+ authorQuestion: FALLBACK_INTENT_QUESTION
10779
+ };
10780
+ }
10564
10781
  /** The candidate checked and, when it ends unclear, its question filed. */
10565
10782
  function applyMotivation(result, candidate, authorDescription) {
10566
10783
  const { motivation, correction } = verifyMotivation(candidate, authorDescription);
@@ -10666,8 +10883,10 @@ function parseReviewResponse(raw, authorDescription) {
10666
10883
  ...applied.correction ? { motivationCorrection: applied.correction } : {}
10667
10884
  };
10668
10885
  }
10886
+ /** What tells this reply's answer from a JSON example quoted in the prose before it. */
10887
+ var REVIEW_ANSWER = objectWithAnyOf("summary", "recommendation", "issues", "nextSteps");
10669
10888
  function parseUnchecked(raw) {
10670
- const jsonText = extractJsonText(raw);
10889
+ const jsonText = extractJsonText(raw, REVIEW_ANSWER);
10671
10890
  let parsed;
10672
10891
  try {
10673
10892
  parsed = JSON.parse(jsonText);
@@ -11137,8 +11356,10 @@ var WORTHINESSES = [
11137
11356
  "consider",
11138
11357
  "leave"
11139
11358
  ];
11359
+ /** What tells this reply's answer from a JSON example quoted in the prose before it. */
11360
+ var KISS_ANSWER = objectWithAnyOf("assessment", "summary", "recommendations", "justifiedComplexity");
11140
11361
  function parseKissAnswer(raw) {
11141
- const jsonText = extractJsonText(raw);
11362
+ const jsonText = extractJsonText(raw, KISS_ANSWER);
11142
11363
  let parsed;
11143
11364
  try {
11144
11365
  parsed = JSON.parse(jsonText);
@@ -11175,14 +11396,24 @@ function optional(key, value) {
11175
11396
  * what makes it auditable. A polished summary with no basis a reader can check is a claim rather
11176
11397
  * than a recommendation. The trade-offs stay optional: some recommendations genuinely cost nothing. What fails is withheld and counted,
11177
11398
  * never padded or truncated, so the page can say that something came back incomplete.
11399
+ *
11400
+ * Counted against what came back, not against what survived the first filter: a recommendation
11401
+ * written as a bare string is still a recommendation the reader is not shown, and counting from the
11402
+ * filtered list made `["Remove the wrapper"]` read as a change with nothing to simplify. A
11403
+ * collection that is there but is not a list is one unreadable answer, counted as one, for the same
11404
+ * reason. Absent is not withheld: an answer with nothing to recommend may leave the field out, and
11405
+ * `null` is JSON's way of saying the same, so it is read as absent rather than as unreadable.
11178
11406
  */
11179
11407
  function normalizeRecommendations(value) {
11180
- if (!Array.isArray(value)) return {
11408
+ if (value === void 0 || value === null) return {
11181
11409
  kept: [],
11182
11410
  withheld: 0
11183
11411
  };
11184
- const entries = value.filter((item) => typeof item === "object" && item !== null);
11185
- const kept = entries.map((item, index) => {
11412
+ if (!Array.isArray(value)) return {
11413
+ kept: [],
11414
+ withheld: 1
11415
+ };
11416
+ const kept = value.filter((item) => typeof item === "object" && item !== null).map((item, index) => {
11186
11417
  const worthiness = WORTHINESSES.includes(item.commentWorthiness) ? item.commentWorthiness : void 0;
11187
11418
  const gains = asStringArray(item.gains).map((gain) => gain.trim()).filter((gain) => gain !== "");
11188
11419
  return {
@@ -11204,7 +11435,7 @@ function normalizeRecommendations(value) {
11204
11435
  }).filter((rec) => rec.title !== "" && rec.gist !== "" && rec.simplerDirection !== "" && rec.evidence !== "" && rec.whyNotJustified !== "" && rec.locations.length > 0);
11205
11436
  return {
11206
11437
  kept,
11207
- withheld: entries.length - kept.length
11438
+ withheld: value.length - kept.length
11208
11439
  };
11209
11440
  }
11210
11441
  /**
@@ -11647,6 +11878,7 @@ var OPERATION_NAMES = {
11647
11878
  function eventName(notification) {
11648
11879
  if (notification.kind === "pr-updated") return notification.source === "branch" ? "Branch updated" : "PR updated";
11649
11880
  if (notification.kind === "pr-state") return notification.state === "merged" ? "PR merged" : "PR closed";
11881
+ if (notification.kind === "architecture") return notification.outcome === "ready" ? "Map ready" : "Mapping failed";
11650
11882
  return `${OPERATION_NAMES[notification.operation]} ${notification.outcome}`;
11651
11883
  }
11652
11884
  /**
@@ -11667,7 +11899,7 @@ function headline(notification) {
11667
11899
  * so adding the next kind is a compiler error here instead of a silently untinted toast there.
11668
11900
  */
11669
11901
  function toneOf(notification) {
11670
- return notification.kind === "analysis" ? notification.outcome : "updated";
11902
+ return notification.kind === "analysis" || notification.kind === "architecture" ? notification.outcome : "updated";
11671
11903
  }
11672
11904
  var OPERATIONS = [
11673
11905
  "review",
@@ -11721,6 +11953,15 @@ function createPrState(input, now = Date.now()) {
11721
11953
  createdAt: now
11722
11954
  });
11723
11955
  }
11956
+ /** Record that mapping a repository or a part ended. Same `wx` contract: the id names the run. */
11957
+ function createArchitectureNotification(input, now = Date.now()) {
11958
+ return create({
11959
+ version: 1,
11960
+ kind: "architecture",
11961
+ ...input,
11962
+ createdAt: now
11963
+ });
11964
+ }
11724
11965
  function create(record) {
11725
11966
  if (!ID.test(record.id)) return null;
11726
11967
  try {
@@ -11785,8 +12026,9 @@ function unreadCount() {
11785
12026
  /**
11786
12027
  * Mark these read, and say how many changed.
11787
12028
  *
11788
- * Only ever by something the reviewer did to the notification itself: opening it, or one of the two
11789
- * mark buttons. Opening the review it points at is not the same act and does not come through here.
12029
+ * Read means the reviewer has been where it points, not that they have dealt with what it said: by
12030
+ * opening it, by one of the two mark buttons, or by opening its review any other way (see
12031
+ * `markReadForReview`). Only `readAt` changes; the record and its history stay as they were.
11790
12032
  */
11791
12033
  function markRead(ids, now = Date.now()) {
11792
12034
  let changed = 0;
@@ -11803,6 +12045,16 @@ function markRead(ids, now = Date.now()) {
11803
12045
  }
11804
12046
  return changed;
11805
12047
  }
12048
+ /**
12049
+ * Every unread notification about this review, marked read because the review has been opened.
12050
+ *
12051
+ * An unread notification is a pointer back to a review, and once the reviewer is looking at that
12052
+ * review the pointer has done its job; left unread it is badge noise. Idempotent, so two tabs opening
12053
+ * the same review end with it read and nothing else, and it creates nothing.
12054
+ */
12055
+ function markReadForReview(reviewId, now = Date.now()) {
12056
+ return markRead(listNotifications().filter((notification) => notification.reviewId === reviewId && notification.readAt === void 0).map((notification) => notification.id), now);
12057
+ }
11806
12058
  function markAllRead(now = Date.now()) {
11807
12059
  return markRead(listNotifications().filter((notification) => notification.readAt === void 0).map((notification) => notification.id), now);
11808
12060
  }
@@ -11848,6 +12100,7 @@ function isNotification(value) {
11848
12100
  if (record.kind === "analysis") return OPERATIONS.includes(record.operation) && OUTCOMES.includes(record.outcome);
11849
12101
  if (record.kind === "pr-updated") return typeof record.headSha === "string" && typeof record.detail === "string" && (record.source === void 0 || record.source === "branch");
11850
12102
  if (record.kind === "pr-state") return (record.state === "merged" || record.state === "closed") && typeof record.headSha === "string";
12103
+ if (record.kind === "architecture") return OUTCOMES.includes(record.outcome) && (record.part === void 0 || record.part === true);
11851
12104
  return false;
11852
12105
  }
11853
12106
  //#endregion
@@ -11864,8 +12117,7 @@ function isNotification(value) {
11864
12117
  *
11865
12118
  * The record is the event. The Notifications page and the workspace's toasts both read it. Any
11866
12119
  * delivery that has to be pushed rather than read belongs here, after `createNotification`, and only
11867
- * for a record this call created, which carries the once-only guarantee over to it for free. Desktop
11868
- * delivery is written that way and parked outside the product: see `desktop_preference.ts`.
12120
+ * for a record this call created, which carries the once-only guarantee over to it for free.
11869
12121
  *
11870
12122
  * Never throws. This runs on the success path of a review that has already been saved, and the
11871
12123
  * accounting must not break the thing it accounts for.
@@ -12294,9 +12546,17 @@ async function runJob(workspaceId, runners = {}, reviewType = "standard", option
12294
12546
  };
12295
12547
  });
12296
12548
  if (isBusy(started)) throw busyReason(workspaceId, reviewType);
12549
+ clearJobEnding(started.jobId);
12550
+ const controller = new AbortController();
12551
+ const outer = options.signal;
12552
+ if (outer?.aborted) controller.abort();
12553
+ outer?.addEventListener("abort", () => controller.abort(), { once: true });
12554
+ const unwatch = watchForStop(started.jobId, controller);
12297
12555
  try {
12298
- return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, options.signal);
12556
+ return await drive(started.jobId, workspaceId, runners, started.claim.claim.holder, controller.signal);
12299
12557
  } finally {
12558
+ unwatch();
12559
+ clearJobEnding(started.jobId);
12300
12560
  started.claim.release();
12301
12561
  }
12302
12562
  }
@@ -12458,6 +12718,8 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
12458
12718
  const payload = await runAnalysisFor(job, workspaceId, runners, signal, (model) => {
12459
12719
  observedModel = model;
12460
12720
  });
12721
+ signal?.throwIfAborted();
12722
+ if (!commitJob(job.id)) throw new AgentCancelledError("Cancelled from the workspace page.");
12461
12723
  if (job.reviewType === "kiss") await applyKiss(job, workspaceId, payload, observedModel);
12462
12724
  else await applyGuide(job, workspaceId, payload, observedModel);
12463
12725
  job = update(job, "done");
@@ -12752,6 +13014,126 @@ async function runProvenanceOf(job, observedModel) {
12752
13014
  };
12753
13015
  }
12754
13016
  //#endregion
13017
+ //#region ../../packages/review-harness/src/workspace/assistant_runs.ts
13018
+ var RUN_KINDS = ["ask", "diagram"];
13019
+ var runs = /* @__PURE__ */ new Map();
13020
+ function keyOf(workspaceId, kind) {
13021
+ return `${workspaceId}:${kind}`;
13022
+ }
13023
+ /**
13024
+ * Start a run, or learn that one is already going.
13025
+ *
13026
+ * One call rather than a check followed by a start, so there is no gap between them for a second
13027
+ * request to fall through. The existing run comes back rather than a bare refusal because the
13028
+ * caller's next move is to show it, not to report an error.
13029
+ *
13030
+ * A stopped run gives up its place at once, though its handler may still be unwinding. Stop is
13031
+ * over for the reviewer when it answers, and the page reopens Ask then. A stopped run can keep
13032
+ * nothing, and `endRun` leaves a newer run alone, so the two cannot collide. An answered run still
13033
+ * holds its place until it is saved, or the next question would be asked without it.
13034
+ */
13035
+ function beginRun(workspaceId, kind, detail, now = Date.now()) {
13036
+ const key = keyOf(workspaceId, kind);
13037
+ const existing = runs.get(key);
13038
+ if (existing && existing.state !== "stopped") return { alreadyRunning: existing };
13039
+ const controller = new AbortController();
13040
+ const run = {
13041
+ workspaceId,
13042
+ kind,
13043
+ question: detail.question ?? null,
13044
+ sectionId: detail.sectionId,
13045
+ ...detail.drawing ? { drawing: detail.drawing } : {},
13046
+ startedAt: now,
13047
+ signal: controller.signal,
13048
+ state: "running",
13049
+ controller
13050
+ };
13051
+ runs.set(key, run);
13052
+ return { run };
13053
+ }
13054
+ /** Null for a stopped run as well: it is over for the page, whatever its handler is still doing. */
13055
+ function reportRun(workspaceId, kind) {
13056
+ const run = runs.get(keyOf(workspaceId, kind));
13057
+ if (!run || run.state === "stopped") return null;
13058
+ return {
13059
+ question: run.question,
13060
+ sectionId: run.sectionId,
13061
+ ...run.drawing ? { drawing: run.drawing } : {},
13062
+ startedAt: run.startedAt
13063
+ };
13064
+ }
13065
+ /**
13066
+ * The reviewer pressed Stop.
13067
+ *
13068
+ * Idempotent: a second press, a press after the answer landed, and a press with nothing running are
13069
+ * all harmless and all answer false. True only when this call is the one that stopped the run.
13070
+ */
13071
+ function stopRun(workspaceId, kind) {
13072
+ const run = runs.get(keyOf(workspaceId, kind));
13073
+ if (!run || run.state !== "running") return false;
13074
+ run.state = "stopped";
13075
+ run.controller.abort();
13076
+ return true;
13077
+ }
13078
+ /**
13079
+ * The agent answered. Call this in the same continuation that received the answer, before anything
13080
+ * else is awaited, so a Stop handled in between cannot slip ahead of an answer that had already won.
13081
+ *
13082
+ * False when Stop got there first, and then the answer must not be kept however complete it is.
13083
+ */
13084
+ function completeRun(run) {
13085
+ const held = run;
13086
+ if (held.state !== "running") return false;
13087
+ held.state = "completed";
13088
+ return true;
13089
+ }
13090
+ /**
13091
+ * The run is over, whichever way. Removes this run and only this one: by the time a slow handler
13092
+ * gets here a newer run of the same kind may have begun, and it is not this handler's to end.
13093
+ */
13094
+ function endRun(run) {
13095
+ const key = keyOf(run.workspaceId, run.kind);
13096
+ if (runs.get(key) === run) runs.delete(key);
13097
+ }
13098
+ /**
13099
+ * Whether any question or drawing is still going here, or has answered and is being saved.
13100
+ *
13101
+ * A stopped run does not count: it can keep nothing, so a restart while its handler unwinds loses
13102
+ * nothing either. `server_work` asks this, because a restart ends the agent child with the process.
13103
+ */
13104
+ function holdsLiveRun() {
13105
+ for (const run of runs.values()) if (run.state !== "stopped") return true;
13106
+ return false;
13107
+ }
13108
+ //#endregion
13109
+ //#region ../../packages/review-harness/src/workspace/server_work.ts
13110
+ /**
13111
+ * Whether this server process is driving any review work of its own.
13112
+ *
13113
+ * A restart ends this process, and everything a page asks an agent for runs *inside* it: a review,
13114
+ * an update, a KISS review, a map, a Buddy AI answer or drawing. A restart during any of them loses
13115
+ * that work, so each is counted here: the background ones through `track`, and the assistant's runs
13116
+ * through the registry that already holds each one until its answer is saved. A review driven from a terminal has its own
13117
+ * process and is untouched, which is exactly why this is a count kept here rather than a look at
13118
+ * the jobs on disk: `/revision`'s `live` is machine-wide, and refusing a restart because somebody
13119
+ * else's terminal is busy would be the control lying about what it costs.
13120
+ *
13121
+ * In memory on purpose. The question is "is this process busy", and a process cannot outlive its
13122
+ * own answer, so there is nothing to keep in a file and nothing that can go stale. A restart or a
13123
+ * crash resets it to the truth.
13124
+ */
13125
+ var driving = 0;
13126
+ /** Count one piece of work for as long as it runs, however it ends. */
13127
+ function track(work) {
13128
+ driving += 1;
13129
+ return work.finally(() => {
13130
+ driving -= 1;
13131
+ });
13132
+ }
13133
+ function isDriving() {
13134
+ return driving > 0 || holdsLiveRun();
13135
+ }
13136
+ //#endregion
12755
13137
  //#region ../../packages/review-harness/src/workspace/kiss_run.ts
12756
13138
  /**
12757
13139
  * Starting a KISS run on a workspace that already exists.
@@ -12784,6 +13166,8 @@ async function recordKissRun(input) {
12784
13166
  const sha = reviewedCommit(workspace);
12785
13167
  if (!sha) throw new Error("This review does not record which commit it describes, so KISS cannot run on it.");
12786
13168
  const agent = input.agentId ? agentById(input.agentId) : workspace.jobId ? agentById(agentIdOf(workspace.session)) : defaultAgent();
13169
+ const sameAgent = !workspace.jobId || agent.id === agentIdOf(workspace.session);
13170
+ const requestedModel = input.requestedModel ?? (sameAgent ? resolveModel({ review: workspace }) : null);
12787
13171
  const started = await withReviewLock(workspace.id, () => {
12788
13172
  const target = {
12789
13173
  originRepoPath: workspace.repoPath,
@@ -12798,7 +13182,7 @@ async function recordKissRun(input) {
12798
13182
  purpose: "review",
12799
13183
  reviewType: "kiss",
12800
13184
  audience: input.audience,
12801
- ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
13185
+ ...requestedModel ? { requestedModel } : {}
12802
13186
  });
12803
13187
  saveJob(job);
12804
13188
  const run = startKissRun(workspace, {
@@ -12807,7 +13191,7 @@ async function recordKissRun(input) {
12807
13191
  reviewedSha: sha,
12808
13192
  promptVersion: "3",
12809
13193
  agentId: agent.id,
12810
- ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
13194
+ ...requestedModel ? { requestedModel } : {}
12811
13195
  });
12812
13196
  mutateWorkspace(workspace.id, (current) => {
12813
13197
  current.kissRuns = [...current.kissRuns ?? [], run];
@@ -12824,9 +13208,13 @@ async function recordKissRun(input) {
12824
13208
  * The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
12825
13209
  * during the twenty minutes that follow is refused by the live job claim rather than made to wait
12826
13210
  * for it.
13211
+ *
13212
+ * Counted by `track` from the first await, so a restart is refused for the whole of the run rather
13213
+ * than only once a caller remembered to count it. Only the server calls this; the CLI records the
13214
+ * run and drives it in its own process.
12827
13215
  */
12828
- async function startKissJob(input, runners = {}) {
12829
- return runJob((await recordKissRun(input)).workspaceId, runners, "kiss");
13216
+ function startKissJob(input, runners = {}) {
13217
+ return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
12830
13218
  }
12831
13219
  //#endregion
12832
13220
  //#region ../../packages/review-harness/src/workspace/agent_env.ts
@@ -13045,10 +13433,12 @@ var QUESTION_STATUSES = [
13045
13433
  "still-open",
13046
13434
  "affected"
13047
13435
  ];
13436
+ /** What tells this reply's answer from a JSON example quoted in the prose before it. */
13437
+ var RECONCILIATION_ANSWER = objectWithAnyOf("findings", "newIssues", "newSections", "questions", "summary", "recommendation", "nextSteps");
13048
13438
  function parseReconciliationResult(raw, priorCount, options = {}) {
13049
13439
  let parsed;
13050
13440
  try {
13051
- parsed = JSON.parse(extractJsonText(raw));
13441
+ parsed = JSON.parse(extractJsonText(raw, RECONCILIATION_ANSWER));
13052
13442
  } catch {
13053
13443
  return null;
13054
13444
  }
@@ -13567,7 +13957,7 @@ function movable(fetched) {
13567
13957
  };
13568
13958
  copy("headSha");
13569
13959
  copy("title");
13570
- copy("body");
13960
+ if (typeof fetched.body === "string") updates.body = fetched.body;
13571
13961
  copy("filesUrl");
13572
13962
  copy("fileUrlTemplate");
13573
13963
  copy("author");
@@ -13683,6 +14073,34 @@ var RefreshUnavailableError = class extends Error {};
13683
14073
  var FETCH_TIMEOUT_MS = 6e4;
13684
14074
  /** Said wherever an update is asked of a commit review. One sentence, so both surfaces agree. */
13685
14075
  var COMMIT_REVIEW_CANNOT_UPDATE = "This review is of one commit, and a commit cannot move, so there is nothing to bring it up to date with. To analyse the same commit again, reanalyse it instead.";
14076
+ /** What the notice says after a cancel, and what the terminal would print if it could cancel. */
14077
+ var UPDATE_CANCELLED = "The review is exactly as it was before the update began.";
14078
+ /**
14079
+ * The updates this process is driving, by job id, each with the means to end it.
14080
+ *
14081
+ * In memory, because only the process running the agent can stop it. An update started in a
14082
+ * terminal is in that process's map, not the server's, and Ctrl-C is how it ends; the page offers
14083
+ * Cancel only for an update this server holds (`cancellableHere`). An entry leaves the map the
14084
+ * moment the update commits to writing, so a cancel either stops it before anything is written or
14085
+ * finds nothing to stop. It can never land halfway through the write.
14086
+ */
14087
+ var cancellers = /* @__PURE__ */ new Map();
14088
+ /** Whether this process can cancel the update with this job id. */
14089
+ function cancellableHere(jobId) {
14090
+ return cancellers.has(jobId);
14091
+ }
14092
+ /**
14093
+ * Stop the update with this job id, if this process is still able to.
14094
+ *
14095
+ * Named by job rather than by review, so a press aimed at one update cannot end the next one.
14096
+ * False when it has finished, has begun writing, or runs somewhere else.
14097
+ */
14098
+ function cancelUpdate(jobId) {
14099
+ const controller = cancellers.get(jobId);
14100
+ if (!controller || controller.signal.aborted) return false;
14101
+ controller.abort();
14102
+ return true;
14103
+ }
13686
14104
  /**
13687
14105
  * Computes and mutates the workspace in memory; does not persist.
13688
14106
  *
@@ -13700,30 +14118,55 @@ async function updateReview(workspace, runners = {}) {
13700
14118
  } } : {}
13701
14119
  });
13702
14120
  runners.onStarted?.(held.job.id);
14121
+ const controller = new AbortController();
14122
+ cancellers.set(held.job.id, controller);
14123
+ const { signal } = controller;
13703
14124
  const current = () => loadJob(held.job.id) ?? held.job;
13704
14125
  const step = (phase) => saveJob(advance(current(), phase));
13705
14126
  try {
13706
14127
  const fetchStartedAt = Date.now();
13707
- const outcome = await bringForward(workspace, runners, reviewedSha, held, step);
14128
+ const outcome = await bringForward(workspace, runners, reviewedSha, held, step, signal);
14129
+ if (signal.aborted) return cancelled(held, reviewedSha);
13708
14130
  if (outcome.ok && !outcome.fetchFailed && outcome.toSha) workspace.headSeenAt = fetchStartedAt;
13709
14131
  const ended = outcome.stop === "analysis-failed" ? fail(current(), outcome.message) : advance(current(), "done", outcome.message);
13710
14132
  saveJob(ended);
13711
14133
  announceJob(ended);
13712
14134
  return outcome;
13713
14135
  } catch (error) {
14136
+ if (signal.aborted) return cancelled(held, reviewedSha);
13714
14137
  const failed = fail(current(), error instanceof Error ? error.message : String(error));
13715
14138
  saveJob(failed);
13716
14139
  announceJob(failed);
13717
14140
  throw error;
13718
14141
  } finally {
14142
+ cancellers.delete(held.job.id);
13719
14143
  held.claim.release();
13720
14144
  }
13721
14145
  }
13722
- async function bringForward(workspace, runners, reviewedSha, held, step) {
13723
- const remote = await remoteHead(workspace);
14146
+ /**
14147
+ * The reviewer cancelled. Recorded as a stop, not a failure, so nothing announces it or offers a
14148
+ * retry, and answered as an outcome the page reports on the review it left untouched.
14149
+ */
14150
+ function cancelled(held, reviewedSha) {
14151
+ recordStop(loadJob(held.job.id) ?? held.job, held.claim.claim.holder);
14152
+ return {
14153
+ at: Date.now(),
14154
+ ok: false,
14155
+ message: UPDATE_CANCELLED,
14156
+ stop: "cancelled",
14157
+ fetchFailed: false,
14158
+ moved: false,
14159
+ fromSha: reviewedSha,
14160
+ toSha: reviewedSha
14161
+ };
14162
+ }
14163
+ async function bringForward(workspace, runners, reviewedSha, held, step, signal) {
14164
+ const remote = await remoteHead(workspace, signal);
14165
+ signal.throwIfAborted();
13724
14166
  if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, noRemoteHeadMessage(workspace));
13725
14167
  if (workspace.prContext) step("conversation");
13726
14168
  const conversationOk = await reReadConversation(workspace, runners.runRefresh);
14169
+ signal.throwIfAborted();
13727
14170
  if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
13728
14171
  if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
13729
14172
  step("context");
@@ -13734,9 +14177,10 @@ async function bringForward(workspace, runners, reviewedSha, held, step) {
13734
14177
  if (error instanceof ReviewBeingDeletedError) throw error;
13735
14178
  return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the new commits could not be prepared, so nothing was reassessed.");
13736
14179
  }
13737
- return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id);
14180
+ signal.throwIfAborted();
14181
+ return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal);
13738
14182
  }
13739
- async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId) {
14183
+ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal) {
13740
14184
  const newHead = remote.sha;
13741
14185
  let delta;
13742
14186
  try {
@@ -13767,24 +14211,28 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
13767
14211
  let raw;
13768
14212
  try {
13769
14213
  const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
13770
- raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress, onObservedModel) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress, requestedModel, onObservedModel);
14214
+ raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress, requestedModel, onObservedModel, signal);
13771
14215
  } catch (error) {
13772
14216
  return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, agentEnvOf(workspace), requestedModel));
13773
14217
  }
13774
14218
  const parsed = parseReconciliationResult(raw, previous.issues.length, { priorQuestionCount: previous.questions.length });
13775
14219
  if (!parsed) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", "The reassessment came back in a form this could not read, so nothing was changed.");
13776
14220
  const issues = [...reconcileIssues(previous.issues, parsed.findings), ...markNewIssues(parsed.newIssues)];
14221
+ const motivation = carryMotivation(previous.motivation, workspace.session.metadata?.authorDescription, workspace.prContext?.pullRequest.body);
14222
+ const questions = reconcileQuestions(previous.questions, parsed.questions);
13777
14223
  const result = {
13778
14224
  schemaVersion: 1,
13779
14225
  summary: parsed.summary || previous.summary,
13780
- ...previous.motivation ? { motivation: previous.motivation } : {},
14226
+ ...motivation ? { motivation } : {},
13781
14227
  overallRisk: parsed.overallRisk ?? previous.overallRisk,
13782
14228
  recommendation: parsed.recommendation || previous.recommendation,
13783
14229
  nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
13784
14230
  sections: composeSections(previous.sections, parsed.newSections),
13785
14231
  issues,
13786
- questions: reconcileQuestions(previous.questions, parsed.questions)
14232
+ questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
13787
14233
  };
14234
+ signal.throwIfAborted();
14235
+ cancellers.delete(jobId);
13788
14236
  await applyUpdate(workspace, result, previous, newHead, worktreePath, {
13789
14237
  ...requestedModel ? { requestedModel } : {},
13790
14238
  ...observedModel ? { observedModel } : {},
@@ -13818,8 +14266,8 @@ var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer i
13818
14266
  * is allowed to reach the network and that panel is not, so it looks at a newer copy of the same
13819
14267
  * refs.
13820
14268
  */
13821
- async function remoteHead(workspace) {
13822
- const fetchFailed = !await fetchOrigin(workspace.repoPath);
14269
+ async function remoteHead(workspace, signal) {
14270
+ const fetchFailed = !await fetchOrigin(workspace.repoPath, signal);
13823
14271
  return {
13824
14272
  sha: (await followedBranch(workspace))?.sha ?? null,
13825
14273
  fetchFailed
@@ -13836,9 +14284,12 @@ function noRemoteHeadMessage(workspace) {
13836
14284
  * back to origin. Naming one ourselves would mean parsing a remote out of an upstream ref, which
13837
14285
  * is guesswork on any remote whose name contains a slash.
13838
14286
  */
13839
- async function fetchOrigin(repoPath) {
14287
+ async function fetchOrigin(repoPath, signal) {
13840
14288
  try {
13841
- await git(repoPath, ["fetch"], { timeoutMs: FETCH_TIMEOUT_MS });
14289
+ await git(repoPath, ["fetch"], {
14290
+ timeoutMs: FETCH_TIMEOUT_MS,
14291
+ signal
14292
+ });
13842
14293
  return true;
13843
14294
  } catch {
13844
14295
  return false;
@@ -14131,7 +14582,7 @@ ${buildReconciliationInstructions({
14131
14582
  * that captured the daemon's environment instead was reconciling one review's findings with
14132
14583
  * whichever agent and account the workspace server happened to be started under.
14133
14584
  */
14134
- async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgress, requestedModel, onObservedModel) {
14585
+ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgress, requestedModel, onObservedModel, signal) {
14135
14586
  try {
14136
14587
  return await withUsageRecorded(workspaceId, "update", env, (onUsage) => agentFor(env).run({
14137
14588
  capability: "read-code",
@@ -14143,7 +14594,8 @@ async function runUpdate(workspaceId, prompt, cwd, originRepoPath, env, onProgre
14143
14594
  timeoutMs: UPDATE_TIMEOUT_MS,
14144
14595
  onProgress,
14145
14596
  onUsage,
14146
- ...onObservedModel ? { onObservedModel } : {}
14597
+ ...onObservedModel ? { onObservedModel } : {},
14598
+ ...signal ? { signal } : {}
14147
14599
  }), void 0, requestedModel);
14148
14600
  } catch (error) {
14149
14601
  if (error instanceof AgentUnavailableError) throw new RefreshUnavailableError(`${error.message} It is what this review was made with, so this review cannot be brought up to date.`);
@@ -14336,4 +14788,4 @@ function lineageIds(id) {
14336
14788
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
14337
14789
  }
14338
14790
  //#endregion
14339
- export { asString as $, resolveAgent as $n, driverGone as $t, createPrState as A, offersLifecycle as An, statedWorkspacePort as At, toneOf as B, isQuestionOutstanding as Bn, reviewedShaOf as Bt, readEvents as C, workspaceStamps as Cn, PACKAGE_NAME as Ct, announceJob as D, lifecycleAction as Dn, MIN_WORKSPACE_PORT as Dt, telemetryUploadConsent as E, isLifecycle as En, MAX_WORKSPACE_PORT as Et, markAllRead as F, queueRemove as Fn, remoteHost as Ft, authorPreview as G, recommendationsOf as Gn, loadJob as Gt, withUsageRecorded as H, questionsOutstanding as Hn, endedAt as Ht, markRead as I, queueStamp as In, fillFileUrlTemplate as It, renderMarkdown as J, kissOutcome as Jn, isLiveJob as Jt, hasAuthorContent as K, describeAuthorship as Kn, saveJob as Kt, pruneNotifications as L, readQueue as Ln, checkCodeFreshness as Lt, eventName as M, sectionOf as Mn, claimRequestUrl as Mt, headline as N, queueAdd as Nn, forgeResolver as Nt, branchOf as O, lifecycleChip as On, parseWorkspacePort as Ot, listNotifications as P, queueAddMany as Pn, forgeFor as Pt, renderInlineText as Q, git as Qn, clearClaim as Qt, readNotification as R, doneVerb as Rn, checkReview as Rt, startJob as S, workspaceRevision as Sn, BUILD_VERSION as St, recordTelemetryUploadConsent as T, activityLabel as Tn, DEFAULT_WORKSPACE_PORT as Tt, EXPLAIN_SIMPLY_PROMPT as U, reviewerDispositions as Un, fail as Ut, unreadCount as V, issuesOutstanding as Vn, allJobs as Vt, UNESTABLISHED_MOTIVATION as W, processDiscussion as Wn, isTerminal as Wt, CODE_COPY_CLASS as X, displayRef as Xn, progressSteps as Xt, CODE_BLOCK_CLASS as Y, resolveTarget as Yn, phasesFor as Yt, escapeHtml as Z, isCommitReview as Zn, purposeOf as Zt, agentEnvOf as _, recentWorkspaces as _n, serverEntryExists as _t, wasBlocked as a, indexReviewGroups as an, STORE_ROOT as ar, modelHelpLines as at, reanalyseReview as b, summarise as bn, workspaceUrl as bt, RefreshUnavailableError as c, latestKissRun as cn, agentById as cr, compactQueue as ct, refreshPrContext as d, loadWorkspace as dn, AgentCancelledError as dr, readIndexToken as dt, isClaimed as en, resolveModel as er, extractJsonText as et, isUnchanged as f, matchingWorkspaceIds as fn, AgentUnavailableError as fr, bootstrapUrl as ft, runningJobs as g, previousKissResult as gn, reviewsUrl as gt, liveUpdateFor as h, presentedKissRunOf as hn, readServerRecord as ht, liveJobsFor as i, indexKeptIds as in, CONFIG_PATH as ir, removeWorktree as it, createPrUpdate as j, parseLifecycle as jn, writePortPreference as jt, subjectOf as k, lifecyclePill as kn, resolveWorkspacePort as kt, mergeUpdateInto as l, lineageKeyFor as ln, agentFor as lr, queueForReview as lt, UpdateAlreadyRunningError as m, positionInLineage as mn, ensureServer as mt, deleteReview as n, followedRefName as nn, readAgentPreference as nr, MANAGED_ROOT as nt, discardJob as o, kissDecisionRefusal as on, AGENT_IDS as or, relativeTime as ot, askCheckout as p, mutateWorkspace as pn, clearServerRecord as pt, GITHUB_MARKDOWN as q, WITHHELD_REASON as qn, PHASES as qt, lineageIds as r, groupByLineage as rn, writeAgentPreference as rr, readMarker as rt, FETCH_TIMEOUT_MS as s, kissDecisionsOf as sn, DEFAULT_AGENT_ID as sr, activateQueued as st, deleteLineage as t, decideKissRecommendation as tn, clearAgentPreference as tr, ReviewBeingDeletedError as tt, updateReview as u, lineagePosition as un, detectAgents as ur, settleQueue as ut, recordKissRun as v, reviewedCommit as vn, spawnServer as vt, record as w, LIFECYCLES as wn, feedbackUrl as wt, runJob as x, touchWorkspace as xn, writeServerRecord as xt, startKissJob as y, reviewedRepositories as yn, stopServer as yt, removeNotification as z, isIssueOutstanding as zn, followedBranch as zt };
14791
+ export { unreadCount as $, queueRemove as $n, remoteHost as $t, reanalyseReview as A, matchingWorkspaceIds as An, AgentCancelledError as Ar, clearServerRecordOwnedBy as At, createArchitectureNotification as B, workspaceRevision as Bn, BUILD_VERSION as Bt, track as C, indexReviewGroups as Cn, CONFIG_PATH as Cr, relativeTime as Ct, endRun as D, lineageKeyFor as Dn, agentById as Dr, settleQueue as Dt, completeRun as E, latestKissRun as En, DEFAULT_AGENT_ID as Er, queueForReview as Et, recordTelemetryUploadConsent as F, recentWorkspaces as Fn, spawnServer as Ft, listNotifications as G, lifecycleAction as Gn, MIN_WORKSPACE_PORT as Gt, createPrUpdate as H, LIFECYCLES as Hn, feedbackUrl as Ht, telemetryUploadConsent as I, reviewedCommit as In, stopChild as It, markReadForReview as J, offersLifecycle as Jn, statedWorkspacePort as Jt, markAllRead as K, lifecycleChip as Kn, parseWorkspacePort as Kt, announceJob as L, reviewedRepositories as Ln, stopServer as Lt, startJob as M, positionInLineage as Mn, readServerRecord as Mt, readEvents as N, presentedKissRunOf as Nn, reviewsUrl as Nt, reportRun as O, lineagePosition as On, agentFor as Or, readIndexToken as Ot, record as P, previousKissResult as Pn, serverEntryExists as Pt, toneOf as Q, queueAddMany as Qn, forgeFor as Qt, branchOf as R, summarise as Rn, workspaceUrl as Rt, isDriving as S, indexKeptIds as Sn, writeAgentPreference as Sr, modelHelpLines as St, beginRun as T, kissDecisionsOf as Tn, AGENT_IDS as Tr, compactQueue as Tt, eventName as U, activityLabel as Un, DEFAULT_WORKSPACE_PORT as Ut, createPrState as V, workspaceStamps as Vn, PACKAGE_NAME as Vt, headline as W, isLifecycle as Wn, MAX_WORKSPACE_PORT as Wt, readNotification as X, sectionOf as Xn, claimRequestUrl as Xt, pruneNotifications as Y, parseLifecycle as Yn, writePortPreference as Yt, removeNotification as Z, queueAdd as Zn, forgeResolver as Zt, liveUpdateFor as _, driverGone as _n, gitSync as _r, holdWorktree as _t, wasBlocked as a, allJobs as an, issuesOutstanding as ar, GITHUB_MARKDOWN as at, recordKissRun as b, followedRefName as bn, clearAgentPreference as br, removeWorktree as bt, RefreshUnavailableError as c, isTerminal as cn, processDiscussion as cr, CODE_COPY_CLASS as ct, mergeUpdateInto as d, PHASES as dn, WITHHELD_REASON as dr, asString as dt, fillFileUrlTemplate as en, queueStamp as er, withUsageRecorded as et, updateReview as f, isLiveJob as fn, kissOutcome as fr, extractJsonText as ft, UpdateAlreadyRunningError as g, clearClaim as gn, git as gr, createWorktree as gt, askCheckout as h, purposeOf as hn, isCommitReview as hr, MANAGED_ROOT as ht, liveJobsFor as i, reviewedShaOf as in, isQuestionOutstanding as ir, hasAuthorContent as it, runJob as j, mutateWorkspace as jn, AgentUnavailableError as jr, ensureServer as jt, stopRun as k, loadWorkspace as kn, detectAgents as kr, bootstrapUrl as kt, cancelUpdate as l, loadJob as ln, recommendationsOf as lr, escapeHtml as lt, isUnchanged as m, progressSteps as mn, displayRef as mr, ReviewBeingDeletedError as mt, deleteReview as n, checkReview as nn, doneVerb as nr, UNESTABLISHED_MOTIVATION as nt, discardJob as o, endedAt as on, questionsOutstanding as or, renderMarkdown as ot, refreshPrContext as p, phasesFor as pn, resolveTarget as pr, objectWithAnyOf as pt, markRead as q, lifecyclePill as qn, resolveWorkspacePort as qt, lineageIds as r, followedBranch as rn, isIssueOutstanding as rr, authorPreview as rt, FETCH_TIMEOUT_MS as s, fail as sn, reviewerDispositions as sr, CODE_BLOCK_CLASS as st, deleteLineage as t, checkCodeFreshness as tn, readQueue as tr, EXPLAIN_SIMPLY_PROMPT as tt, cancellableHere as u, saveJob as un, describeAuthorship as ur, renderInlineText as ut, runningJobs as v, isClaimed as vn, resolveAgent as vr, isWorktreeHeld as vt, RUN_KINDS as w, kissDecisionRefusal as wn, STORE_ROOT as wr, activateQueued as wt, startKissJob as x, groupByLineage as xn, readAgentPreference as xr, requestJobStop as xt, agentEnvOf as y, decideKissRecommendation as yn, resolveModel as yr, readMarker as yt, subjectOf as z, touchWorkspace as zn, writeServerRecord as zt };