prreviewbuddy 0.45.0 → 0.46.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.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $t as recentWorkspaces, A as createPrState, An as STORE_ROOT, At as fail, B as toneOf, C as readEvents, Cn as git, Ct as fillFileUrlTemplate, D as announceJob, Dn as readAgentPreference, Dt as reviewedShaOf, E as telemetryUploadConsent, En as clearAgentPreference, Et as followedBranch, F as markAllRead, Ft as isLiveJob, G as MANAGED_ROOT, Gt as latestKissRun, H as withUsageRecorded, Ht as followedRefName, I as markRead, In as AgentCancelledError, It as phasesFor, Jt as loadWorkspace, K as readMarker, L as pruneNotifications, Ln as AgentUnavailableError, Lt as progressSteps, M as eventName, Mt as loadJob, N as headline, Nt as saveJob, O as branchOf, On as writeAgentPreference, Ot as allJobs, P as listNotifications, Pn as agentFor, Q as readIndexToken, Qt as previousKissResult, R as readNotification, Rt as purposeOf, Sn as isCommitReview, St as remoteHost, T as recordTelemetryUploadConsent, Tn as resolveModel, Tt as checkReview, U as EXPLAIN_SIMPLY_PROMPT, V as unreadCount$1, Vt as isClaimed, W as ReviewBeingDeletedError, Wt as indexReviewGroups, X as compactQueue, Xt as mutateWorkspace, Y as relativeTime$1, _ as agentEnvOf, _n as reviewerDispositions, _t as statedWorkspacePort, a as wasBlocked, an as workspaceStamps, at as spawnServer, c as RefreshUnavailableError, cn as queueAddMany, ct as writeServerRecord, d as refreshPrContext, dn as readQueue, dt as feedbackUrl, en as reviewedCommit, et as clearServerRecord, f as isUnchanged, fn as doneVerb, ft as DEFAULT_WORKSPACE_PORT, gn as questionsOutstanding, gt as resolveWorkspacePort, h as liveUpdateFor, hn as issuesOutstanding, ht as parseWorkspacePort, i as liveJobsFor, in as workspaceRevision, it as serverEntryExists, j as createPrUpdate, jn as AGENT_IDS, jt as isTerminal, k as subjectOf, kt as endedAt, l as mergeUpdateInto, ln as queueRemove, lt as BUILD_VERSION, m as UpdateAlreadyRunningError, mn as isQuestionOutstanding, mt as MIN_WORKSPACE_PORT, n as deleteReview, nt as readServerRecord, o as discardJob, on as dequeueOnComplete, p as askCheckout, pn as isIssueOutstanding, pt as MAX_WORKSPACE_PORT, q as removeWorktree, qt as lineagePosition, r as lineageIds, rn as touchWorkspace, s as FETCH_TIMEOUT_MS, sn as queueAdd, t as deleteLineage, u as updateReview, un as queueStamp, vn as processDiscussion, vt as writePortPreference, w as record, wt as checkCodeFreshness, x as runJob, xt as forgeFor, y as startKissJob, yn as describeAuthorship, yt as claimRequestUrl, z as removeNotification } from "./delete_review-BWMmO81Q.js";
1
+ import { A as createPrState, An as writeAgentPreference, At as allJobs, B as toneOf, Bt as purposeOf, C as readEvents, Ct as forgeFor, D as announceJob, Dn as resolveModel, Dt as checkReview, E as telemetryUploadConsent, Et as checkCodeFreshness, F as markAllRead, Ft as saveJob, G as extractJsonText, H as withUsageRecorded, I as markRead, In as agentFor, J as readMarker, K as ReviewBeingDeletedError, Kt as indexReviewGroups, L as pruneNotifications, Lt as isLiveJob, M as eventName, Mn as STORE_ROOT, Mt as fail, N as headline, Nn as AGENT_IDS, Nt as isTerminal, O as branchOf, On as clearAgentPreference, Ot as followedBranch, P as listNotifications, Pt as loadJob, Q as compactQueue, Qt as mutateWorkspace, R as readNotification, Rn as AgentCancelledError, Rt as phasesFor, T as recordTelemetryUploadConsent, Tn as git, Tt as fillFileUrlTemplate, U as EXPLAIN_SIMPLY_PROMPT, Ut as isClaimed, V as unreadCount$1, W as asString, Wt as followedRefName, Xt as loadWorkspace, Y as removeWorktree, Yt as lineagePosition, Z as relativeTime$1, _ as agentEnvOf, _n as issuesOutstanding, _t as parseWorkspacePort, a as wasBlocked, an as touchWorkspace, bn as processDiscussion, bt as writePortPreference, c as RefreshUnavailableError, cn as dequeueOnComplete, d as refreshPrContext, dn as queueRemove, dt as BUILD_VERSION, en as previousKissResult, et as readIndexToken, f as isUnchanged, fn as queueStamp, gn as isQuestionOutstanding, gt as MIN_WORKSPACE_PORT, h as liveUpdateFor, hn as isIssueOutstanding, ht as MAX_WORKSPACE_PORT, i as liveJobsFor, it as readServerRecord, j as createPrUpdate, jt as endedAt, k as subjectOf, kn as readAgentPreference, kt as reviewedShaOf, l as mergeUpdateInto, ln as queueAdd, m as UpdateAlreadyRunningError, mn as doneVerb, mt as DEFAULT_WORKSPACE_PORT, n as deleteReview, nn as reviewedCommit, nt as clearServerRecord, o as discardJob, on as workspaceRevision, ot as serverEntryExists, p as askCheckout, pn as readQueue, pt as feedbackUrl, q as MANAGED_ROOT, qt as latestKissRun, r as lineageIds, s as FETCH_TIMEOUT_MS, sn as workspaceStamps, st as spawnServer, t as deleteLineage, tn as recentWorkspaces, u as updateReview, un as queueAddMany, ut as writeServerRecord, vn as questionsOutstanding, vt as resolveWorkspacePort, w as record, wn as isCommitReview, wt as remoteHost, x as runJob, xn as describeAuthorship, xt as claimRequestUrl, y as startKissJob, yn as reviewerDispositions, yt as statedWorkspacePort, z as removeNotification, zn as AgentUnavailableError, zt as progressSteps } from "./delete_review-lmVdoybL.js";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -1657,7 +1657,7 @@ function renderProvisionalReason(session) {
1657
1657
  function renderOverviewActions(session, options) {
1658
1658
  const explain = hasExplainedSimply(session) ? `<p class="pr-helper-explain-simply-done">Explained in simple terms. See the Assistant tab.</p>` : `<button type="button" class="pr-helper-explain-simply-button">Explain in simple terms</button>`;
1659
1659
  if (!options?.diagrams) return explain;
1660
- return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-diagram-button" data-diagram-request="">Show implementation diagram</button></div>`;
1660
+ return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-map-button" data-view-nav="map">Diagrams</button></div>`;
1661
1661
  }
1662
1662
  function renderOverviewTab(session, iconUrl = "", options) {
1663
1663
  if (!session) return renderNoReviewState(iconUrl);
@@ -1901,7 +1901,7 @@ function renderGuideSectionCard(section, counts, activeGuidedSectionId, status,
1901
1901
  <button type="button" class="pr-helper-guide-start-cta" data-guide-start="${escapeHtml$1(section.id)}">
1902
1902
  ${isActive ? "Resume guided review →" : isCompleted ? "Review again →" : "Start guided review →"}
1903
1903
  </button>
1904
- ${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">Diagram this theme</button>` : ""}
1904
+ ${options?.diagrams ? `<button type="button" class="pr-helper-diagram-button" data-diagram-request="${escapeHtml$1(section.id)}">Generate theme diagram</button>` : ""}
1905
1905
  ${undo}
1906
1906
  </div>
1907
1907
  </div>
@@ -2609,6 +2609,458 @@ function renderDrawnFrom(diagram, reviewedSha) {
2609
2609
  return `<p class="prb-diagram-stale">Drawn from <code>${escapeHtml$1(diagram.commitSha.slice(0, 7))}</code>, before this review was brought up to date. Regenerate to draw it from the commit the review now describes.</p>`;
2610
2610
  }
2611
2611
  //#endregion
2612
+ //#region ../../packages/review-harness/src/domain/change_map/types.ts
2613
+ var MAP_ROLES = [
2614
+ "intent",
2615
+ "actor",
2616
+ "concept",
2617
+ "state",
2618
+ "constraint",
2619
+ "risk",
2620
+ "outcome"
2621
+ ];
2622
+ var MAP_STAGES = [
2623
+ "Intent",
2624
+ "Behaviour",
2625
+ "Shared state",
2626
+ "Risks & constraints",
2627
+ "Outcome"
2628
+ ];
2629
+ //#endregion
2630
+ //#region ../../packages/review-harness/src/domain/change_map/parse.ts
2631
+ /**
2632
+ * A model's answer, turned into a change map.
2633
+ *
2634
+ * Tolerant in the same way `kiss/parse.ts` is. Fields go missing, arrive as the wrong type or
2635
+ * run long, and all of that degrades to something honest. What it will not do is invent a map: a
2636
+ * JSON object with no nodes in it throws, because an empty picture presented as "this change has no
2637
+ * ideas in it" is a confident wrong claim.
2638
+ *
2639
+ * Only shape is settled here. Whether the edges point at real nodes, whether a theme id is one this
2640
+ * review has, and whether what is left is still worth drawing are `validate.ts`'s questions, because
2641
+ * they need the review to answer.
2642
+ */
2643
+ /**
2644
+ * Lengths past which text stops fitting the medium.
2645
+ *
2646
+ * A node is read at a glance, beside eleven others. The prompt asks for this brevity; these are
2647
+ * what happens when it is ignored, and they cut at a word rather than mid-word.
2648
+ */
2649
+ var LIMITS = {
2650
+ mapTitle: 100,
2651
+ intent: 320,
2652
+ title: 80,
2653
+ body: 220,
2654
+ why: 160,
2655
+ anchor: 80,
2656
+ label: 48,
2657
+ note: 200
2658
+ };
2659
+ /** Where a role sits when the model forgot to say. The usual place, not the only one. */
2660
+ var STAGE_OF_ROLE = {
2661
+ intent: 0,
2662
+ concept: 1,
2663
+ actor: 2,
2664
+ state: 2,
2665
+ constraint: 3,
2666
+ risk: 3,
2667
+ outcome: 4
2668
+ };
2669
+ function parseChangeMap(raw) {
2670
+ let parsed;
2671
+ try {
2672
+ parsed = JSON.parse(extractJsonText(raw));
2673
+ } catch {
2674
+ throw new Error("Could not parse the change map as JSON.");
2675
+ }
2676
+ return normalizeChangeMap(parsed);
2677
+ }
2678
+ /**
2679
+ * Any value, made into a change map or refused.
2680
+ *
2681
+ * Exported because a stored map is read back through it too. A review file is JSON on disk that a
2682
+ * previous version wrote, or someone edited, and the page drawing it deserves the same guarantees
2683
+ * as the page drawing a map that has just arrived. Running it twice changes nothing.
2684
+ */
2685
+ function normalizeChangeMap(value) {
2686
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("The change map was not a JSON object.");
2687
+ const obj = value;
2688
+ if (!Array.isArray(obj.nodes) || obj.nodes.length === 0) throw new Error("The change map had no nodes. This usually means the model ignored the requested shape.");
2689
+ const { nodes, ids } = normalizeNodes(obj.nodes);
2690
+ return {
2691
+ schemaVersion: 1,
2692
+ title: clip(asString(obj.title).trim(), LIMITS.mapTitle) || "Change map",
2693
+ intent: register(obj.intent, LIMITS.intent) ?? {
2694
+ simple: "",
2695
+ technical: ""
2696
+ },
2697
+ nodes,
2698
+ edges: normalizeEdges(obj.edges, ids)
2699
+ };
2700
+ }
2701
+ /**
2702
+ * Nodes, with ids made safe and unique.
2703
+ *
2704
+ * The model's own ids are what its edges refer to, so they are remembered alongside the safe ones;
2705
+ * `ids` maps one to the other. A second node claiming an id already taken is dropped rather than
2706
+ * renamed: the edges naming that id cannot say which of the two they meant.
2707
+ */
2708
+ function normalizeNodes(value) {
2709
+ const ids = /* @__PURE__ */ new Map();
2710
+ const taken = /* @__PURE__ */ new Set();
2711
+ const nodes = [];
2712
+ for (const item of value) {
2713
+ if (typeof item !== "object" || item === null) continue;
2714
+ const entry = item;
2715
+ const title = register(entry.title, LIMITS.title);
2716
+ if (!title) continue;
2717
+ const given = asString(entry.id).trim() || title.technical;
2718
+ if (ids.has(given)) continue;
2719
+ const id = uniqueId(slug(given) || `n${nodes.length + 1}`, taken);
2720
+ ids.set(given, id);
2721
+ taken.add(id);
2722
+ const role = MAP_ROLES.includes(entry.role) ? entry.role : "concept";
2723
+ const why = register(entry.why, LIMITS.why);
2724
+ const anchor = clip(asString(entry.anchor).trim(), LIMITS.anchor);
2725
+ const sectionId = asString(entry.sectionId).trim();
2726
+ nodes.push({
2727
+ id,
2728
+ role,
2729
+ stage: stageOf(entry.stage, role),
2730
+ weight: entry.weight === "primary" ? "primary" : "supporting",
2731
+ title,
2732
+ body: register(entry.body, LIMITS.body) ?? {
2733
+ simple: "",
2734
+ technical: ""
2735
+ },
2736
+ ...why ? { why } : {},
2737
+ ...anchor ? { anchor } : {},
2738
+ ...sectionId ? { sectionId } : {}
2739
+ });
2740
+ }
2741
+ return {
2742
+ nodes,
2743
+ ids
2744
+ };
2745
+ }
2746
+ function normalizeEdges(value, ids) {
2747
+ if (!Array.isArray(value)) return [];
2748
+ const edges = [];
2749
+ for (const item of value) {
2750
+ if (typeof item !== "object" || item === null) continue;
2751
+ const entry = item;
2752
+ const label = register(entry.label, LIMITS.label);
2753
+ const from = ids.get(asString(entry.from).trim()) ?? asString(entry.from).trim();
2754
+ const to = ids.get(asString(entry.to).trim()) ?? asString(entry.to).trim();
2755
+ if (!label || !from || !to) continue;
2756
+ const note = register(entry.note, LIMITS.note);
2757
+ edges.push({
2758
+ from,
2759
+ to,
2760
+ label,
2761
+ ...note ? { note } : {}
2762
+ });
2763
+ }
2764
+ return edges;
2765
+ }
2766
+ /**
2767
+ * Both registers, or null when there is nothing in either.
2768
+ *
2769
+ * A missing register borrows the other rather than rendering blank: the reviewer who chose Simple
2770
+ * and gets the technical sentence has lost some ease, where one who gets nothing has lost the node.
2771
+ * A bare string is taken as both, which is what a model means when it forgets the shape.
2772
+ */
2773
+ function register(value, limit) {
2774
+ if (typeof value === "string") {
2775
+ const text = clip(value.trim(), limit);
2776
+ return text ? {
2777
+ simple: text,
2778
+ technical: text
2779
+ } : null;
2780
+ }
2781
+ if (typeof value !== "object" || value === null) return null;
2782
+ const entry = value;
2783
+ const simple = clip(asString(entry.simple).trim(), limit);
2784
+ const technical = clip(asString(entry.technical).trim(), limit);
2785
+ if (!simple && !technical) return null;
2786
+ return {
2787
+ simple: simple || technical,
2788
+ technical: technical || simple
2789
+ };
2790
+ }
2791
+ function stageOf(value, role) {
2792
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4 ? value : STAGE_OF_ROLE[role];
2793
+ }
2794
+ /** Short, lowercase, and safe in an attribute, a selector and a URL. */
2795
+ function slug(value) {
2796
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2797
+ }
2798
+ function uniqueId(base, taken) {
2799
+ if (!taken.has(base)) return base;
2800
+ let n = 2;
2801
+ while (taken.has(`${base}-${n}`)) n += 1;
2802
+ return `${base}-${n}`;
2803
+ }
2804
+ /** Cut at the last word boundary under the limit, and say that something was cut. */
2805
+ function clip(text, limit) {
2806
+ if (text.length <= limit) return text;
2807
+ const cut = text.slice(0, limit - 1);
2808
+ const space = cut.lastIndexOf(" ");
2809
+ return `${(space > limit / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
2810
+ }
2811
+ //#endregion
2812
+ //#region ../../packages/review-harness/src/domain/change_map/validate.ts
2813
+ /**
2814
+ * Checking a change map against the review it claims to describe, and against what can be drawn.
2815
+ *
2816
+ * The enforcement boundary, in the spirit of `kiss/validate.ts`: what the model got slightly wrong
2817
+ * is repaired and counted, and what cannot be repaired is refused. An edge to a node that does not
2818
+ * exist is dropped rather than drawn into nowhere. A theme id this review never had is withdrawn,
2819
+ * because "Open in Review Path" on it would open nothing. A map with more than a reader can hold is
2820
+ * cut back in reading order, keeping the ideas the model said matter most.
2821
+ *
2822
+ * What it refuses is a map with nothing left to connect. Two ideas and one relationship is the
2823
+ * least that is a map rather than a list, and a list is what the written guide already is.
2824
+ */
2825
+ /** A map that could not be used. Carries the reply, as `KissRejected` does, for the same reason. */
2826
+ var ChangeMapRejected = class extends Error {
2827
+ answer;
2828
+ constructor(message, answer) {
2829
+ super(message);
2830
+ this.answer = answer;
2831
+ }
2832
+ };
2833
+ function validateChangeMap(raw, sectionIds) {
2834
+ try {
2835
+ return checkChangeMap(parseChangeMap(raw), sectionIds);
2836
+ } catch (error) {
2837
+ throw new ChangeMapRejected(error instanceof Error ? error.message : String(error), raw);
2838
+ }
2839
+ }
2840
+ /**
2841
+ * A stored map, or null if it is no longer one this page can draw.
2842
+ *
2843
+ * The same rules a fresh map passes, applied to what is on disk. A review file written by an older
2844
+ * version, edited by hand or cut short is a page that shows the ordinary empty state and a way to
2845
+ * generate a new map, never a page that throws.
2846
+ */
2847
+ function readStoredChangeMap(value, sectionIds) {
2848
+ try {
2849
+ return checkChangeMap(normalizeChangeMap(value), sectionIds).map;
2850
+ } catch {
2851
+ return null;
2852
+ }
2853
+ }
2854
+ function checkChangeMap(map, sectionIds) {
2855
+ const audit = {
2856
+ droppedNodes: 0,
2857
+ droppedEdges: 0,
2858
+ withdrawnSections: 0,
2859
+ demotedPrimaries: 0
2860
+ };
2861
+ const nodes = demoteExtraPrimaries(capNodes(map.nodes, audit).map((node) => withKnownSection(node, sectionIds, audit)), audit);
2862
+ const known = new Set(nodes.map((node) => node.id));
2863
+ const seen = /* @__PURE__ */ new Set();
2864
+ const edges = [];
2865
+ for (const edge of map.edges) {
2866
+ const key = `${edge.from}\u0000${edge.to}`;
2867
+ if (!known.has(edge.from) || !known.has(edge.to) || edge.from === edge.to || seen.has(key) || edges.length >= 16) {
2868
+ audit.droppedEdges += 1;
2869
+ continue;
2870
+ }
2871
+ seen.add(key);
2872
+ edges.push(edge);
2873
+ }
2874
+ if (nodes.length < 2 || edges.length === 0) throw new Error("The change map had too little in it to draw: it needs at least two ideas and one relationship.");
2875
+ return {
2876
+ map: {
2877
+ ...map,
2878
+ nodes: inReadingOrder(nodes),
2879
+ edges
2880
+ },
2881
+ audit
2882
+ };
2883
+ }
2884
+ /**
2885
+ * Down to `MAX_NODES`, keeping the primaries and then the earliest of the rest.
2886
+ *
2887
+ * The model lists nodes in reading order, so "earliest" is "the part of the story it reached
2888
+ * first". Cutting the tail loses detail at the end; cutting a primary would lose the point.
2889
+ */
2890
+ function capNodes(nodes, audit) {
2891
+ if (nodes.length <= 12) return nodes;
2892
+ const primaries = nodes.filter((node) => node.weight === "primary").slice(0, 3);
2893
+ const rest = nodes.filter((node) => !primaries.includes(node)).slice(0, 12 - primaries.length);
2894
+ const kept = /* @__PURE__ */ new Set([...primaries, ...rest]);
2895
+ audit.droppedNodes += nodes.length - kept.size;
2896
+ return nodes.filter((node) => kept.has(node));
2897
+ }
2898
+ function withKnownSection(node, sectionIds, audit) {
2899
+ if (node.sectionId === void 0 || sectionIds.includes(node.sectionId)) return node;
2900
+ audit.withdrawnSections += 1;
2901
+ const { sectionId: _withdrawn, ...rest } = node;
2902
+ return rest;
2903
+ }
2904
+ /** Everything is not the point. Past three, "primary" has stopped meaning anything. */
2905
+ function demoteExtraPrimaries(nodes, audit) {
2906
+ let primaries = 0;
2907
+ return nodes.map((node) => {
2908
+ if (node.weight !== "primary") return node;
2909
+ primaries += 1;
2910
+ if (primaries <= 3) return node;
2911
+ audit.demotedPrimaries += 1;
2912
+ return {
2913
+ ...node,
2914
+ weight: "supporting"
2915
+ };
2916
+ });
2917
+ }
2918
+ /** By stage, keeping the model's own order within one. A stable sort does exactly that. */
2919
+ function inReadingOrder(nodes) {
2920
+ return [...nodes].sort((a, b) => a.stage - b.stage);
2921
+ }
2922
+ //#endregion
2923
+ //#region ../../packages/review-harness/src/workspace/change_map_view.ts
2924
+ /** The body of the map pane: the map, or the way to get one. The pane's frame is `page.ts`'s. */
2925
+ function renderChangeMapBody(input) {
2926
+ const { stored, sections, reviewedSha, register } = input;
2927
+ const map = stored ? readStoredChangeMap(stored, sections.map((section) => section.id)) : null;
2928
+ const stale = Boolean(map && stored?.commitSha && reviewedSha && stored.commitSha !== reviewedSha);
2929
+ if (!map || !stored || stale) return renderEmpty(stale);
2930
+ const titles = new Map(sections.map((section) => [section.id, section.title]));
2931
+ return `<div class="prb-map" data-change-map data-register="${register}">
2932
+ <header class="prb-map-head">
2933
+ <div class="prb-map-heading">
2934
+ <h4 class="prb-map-title">${escapeHtml$1(map.title)}</h4>
2935
+ ${map.intent.simple || map.intent.technical ? `<p class="prb-map-intent">${both(map.intent)}</p>` : ""}
2936
+ </div>
2937
+ <div class="prb-map-tools">
2938
+ ${renderRegisterSwitch(register)}
2939
+ <button type="button" class="prb-diagram-redraw" data-diagram-request="" data-diagram-kind="map">Regenerate</button>
2940
+ </div>
2941
+ </header>
2942
+ <p class="prb-map-hint">Read from the goal to the outcome. Select an idea to see what it connects to.</p>
2943
+ ${renderBoard(map, titles)}
2944
+ ${renderList(map)}
2945
+ </div>`;
2946
+ }
2947
+ function renderEmpty(stale) {
2948
+ return `<div class="prb-map-empty" data-change-map-empty>
2949
+ ${stale ? "<p class=\"prb-map-stale\">The review has moved on since the last map was drawn.</p>" : ""}
2950
+ <h4 class="prb-map-empty-title">See the change as a map</h4>
2951
+ <p>See how the main ideas in this change connect, what depends on what, and where the important risks are. Each idea links back to its theme in the Review Path.</p>
2952
+ <p class="prb-map-empty-note">Buddy AI builds it from this review, in plain words and technical ones. It takes a minute or two.</p>
2953
+ <button type="button" class="prb-map-generate" data-diagram-request="" data-diagram-kind="map">Generate change map</button>
2954
+ </div>`;
2955
+ }
2956
+ /** Two pressed-state buttons rather than a checkbox: both choices are named, and neither is "off". */
2957
+ function renderRegisterSwitch(register) {
2958
+ const option = (value, label) => `<button type="button" class="prb-map-register" data-map-register-option="${value}" aria-pressed="${register === value}">${label}</button>`;
2959
+ return `<div class="prb-map-registers" role="group" aria-label="Wording">${option("simple", "Simple")}${option("technical", "Technical")}</div>`;
2960
+ }
2961
+ /**
2962
+ * The board: one column per stage the map uses, in reading order, and the lines between them.
2963
+ *
2964
+ * Only the stages that have something in them get a column. A map of a small change with no shared
2965
+ * state should not carry an empty "Shared state" column that asks the reviewer to wonder what is
2966
+ * missing from it.
2967
+ */
2968
+ function renderBoard(map, titles) {
2969
+ const stages = [...new Set(map.nodes.map((node) => node.stage))].sort((a, b) => a - b);
2970
+ const columns = stages.map((stage) => `<div class="prb-map-stage" data-map-stage="${stage}">
2971
+ <h5 class="prb-map-stage-title">${escapeHtml$1(MAP_STAGES[stage] ?? "")}</h5>
2972
+ ${map.nodes.filter((node) => node.stage === stage).sort((a, b) => Number(b.weight === "primary") - Number(a.weight === "primary")).map((node) => renderNode(node, titles, map)).join("")}
2973
+ </div>`).join("");
2974
+ const labels = map.edges.map((edge, index) => renderEdgeLabel(edge, index)).join("");
2975
+ return `<div class="prb-map-board" data-map-board style="--prb-map-stages: ${stages.length}">
2976
+ <svg class="prb-map-edges" data-map-edges aria-hidden="true" focusable="false"></svg>
2977
+ ${columns}
2978
+ <div class="prb-map-labels" aria-hidden="true">${labels}</div>
2979
+ </div>`;
2980
+ }
2981
+ /** The part a node plays, as a word. Never colour alone. */
2982
+ var ROLE_LABEL = {
2983
+ intent: "Goal",
2984
+ actor: "Actor",
2985
+ concept: "Idea",
2986
+ state: "Shared state",
2987
+ constraint: "Rule",
2988
+ risk: "Risk",
2989
+ outcome: "Outcome"
2990
+ };
2991
+ function renderNode(node, titles, map) {
2992
+ const id = escapeHtml$1(node.id);
2993
+ const theme = node.sectionId ? titles.get(node.sectionId) : void 0;
2994
+ const names = new Map(map.nodes.map((other) => [other.id, other.title]));
2995
+ const inline = map.edges.map((edge, index) => ({
2996
+ edge,
2997
+ index
2998
+ })).filter(({ edge }) => edge.from === node.id && names.has(edge.to)).map(({ edge, index }) => `<li data-inline-edge="${index}" hidden><em>${both(edge.label)}</em> <span aria-hidden="true">→</span> ${both(names.get(edge.to))}</li>`).join("");
2999
+ return `<article class="prb-map-node" data-map-node="${id}" data-role="${node.role}" data-weight="${node.weight}" tabindex="0" aria-labelledby="prb-map-node-${id}">
3000
+ <p class="prb-map-role">${ROLE_LABEL[node.role]}${node.weight === "primary" ? "<span class=\"prb-map-core\"> · Core idea</span>" : ""}</p>
3001
+ <h5 class="prb-map-node-title" id="prb-map-node-${id}">${both(node.title)}</h5>
3002
+ ${node.body.simple || node.body.technical ? `<p class="prb-map-node-body">${both(node.body)}</p>` : ""}
3003
+ ${node.why ? `<p class="prb-map-why"><span class="prb-map-why-lead">Why it matters:</span> ${both(node.why)}</p>` : ""}
3004
+ ${node.anchor ? `<code class="prb-map-anchor">${escapeHtml$1(node.anchor)}</code>` : ""}
3005
+ ${inline ? `<ul class="prb-map-inline" aria-hidden="true">${inline}</ul>` : ""}
3006
+ ${node.sectionId && theme ? `<button type="button" class="prb-map-goto" data-goto-section="${escapeHtml$1(node.sectionId)}" aria-label="Open ${escapeHtml$1(theme)} in the Review Path">Review Path: ${escapeHtml$1(theme)} <span aria-hidden="true">→</span></button>` : ""}
3007
+ </article>`;
3008
+ }
3009
+ /** Placed by the client, at the middle of its line. Written here so its words are escaped here. */
3010
+ function renderEdgeLabel(edge, index) {
3011
+ return `<span class="prb-map-label" data-map-edge="${index}" data-from="${escapeHtml$1(edge.from)}" data-to="${escapeHtml$1(edge.to)}">${both(edge.label)}</span>`;
3012
+ }
3013
+ /**
3014
+ * The same relationships as sentences.
3015
+ *
3016
+ * For a screen reader, which cannot follow a line; for a narrow pane, where the lines are hardest to
3017
+ * follow; and for the case where laying the lines out failed, when this is the map.
3018
+ */
3019
+ function renderList(map) {
3020
+ const names = new Map(map.nodes.map((node) => [node.id, node.title]));
3021
+ return `<details class="prb-map-list">
3022
+ <summary>Read as a list</summary>
3023
+ <ol>${map.edges.map((edge) => {
3024
+ const from = names.get(edge.from);
3025
+ const to = names.get(edge.to);
3026
+ if (!from || !to) return "";
3027
+ return `<li><strong>${both(from)}</strong> <em>${both(edge.label)}</em> <strong>${both(to)}</strong>${edge.note ? `<span class="prb-map-note"> — ${both(edge.note)}</span>` : ""}</li>`;
3028
+ }).join("")}</ol>
3029
+ </details>`;
3030
+ }
3031
+ /**
3032
+ * Both registers, side by side in the markup; the board's `data-register` shows one.
3033
+ *
3034
+ * Backtick spans become code, after escaping, as in every other place model prose is shown: the
3035
+ * technical register names identifiers, and a literal pair of ticks around one reads as noise.
3036
+ */
3037
+ function both(text) {
3038
+ if (text.simple === text.technical) return renderInlineText(text.simple);
3039
+ return `<span data-reg="simple">${renderInlineText(text.simple)}</span><span data-reg="technical">${renderInlineText(text.technical)}</span>`;
3040
+ }
3041
+ //#endregion
3042
+ //#region ../../packages/review-harness/src/workspace/diagram_modes.ts
3043
+ var MODES = [{
3044
+ mode: "map",
3045
+ title: "Change map",
3046
+ subtitle: "Understand this change"
3047
+ }, {
3048
+ mode: "diagram",
3049
+ title: "Implementation",
3050
+ subtitle: "How the code does it"
3051
+ }];
3052
+ /**
3053
+ * `ready` says which pictures exist, shown as a small check on the tab, so from either tab the
3054
+ * reviewer can tell whether the other one is already there or would cost a run. Always in the
3055
+ * markup and hidden when not ready, so the client can flip it when a drawing lands.
3056
+ */
3057
+ function renderDiagramModes(selected, ready) {
3058
+ return `<div class="prb-diagram-modes" role="tablist" aria-label="Kind of diagram">${MODES.map(({ mode, title, subtitle }) => `<button type="button" role="tab" class="prb-diagram-mode" data-view-nav="${mode}" data-diagram-mode="${mode}" aria-selected="${mode === selected}" tabindex="${mode === selected ? 0 : -1}"${ready[mode] ? " data-ready" : ""}>
3059
+ <span class="prb-diagram-mode-title">${title}<span class="prb-diagram-mode-ready" data-mode-ready${ready[mode] ? "" : " hidden"}><span aria-hidden="true">✓</span><span class="prb-visually-hidden">, generated</span></span></span>
3060
+ <span class="prb-diagram-mode-subtitle">${subtitle}</span>
3061
+ </button>`).join("")}</div>`;
3062
+ }
3063
+ //#endregion
2612
3064
  //#region ../../packages/review-harness/src/workspace/file_links.ts
2613
3065
  /**
2614
3066
  * Where a file lives on the pull request, when there is one.
@@ -2857,7 +3309,7 @@ function renderPrContextView(view, attribution, refresh, conversation) {
2857
3309
  const { pullRequest, discussion } = view.prContext;
2858
3310
  const { processed } = view;
2859
3311
  const buckets = BUCKETS.map((bucket) => renderBucket(bucket.title, bucket.note, processed[bucket.key])).join("");
2860
- return `<h3 class="prb-doc-title">PR context</h3>
3312
+ return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
2861
3313
  ${renderProvenance$1(pullRequest, discussion.fetchedAt, processed)}
2862
3314
  ${renderRefresh(refresh)}
2863
3315
  ${renderAttribution(attribution, processed)}
@@ -2925,7 +3377,7 @@ function renderAttribution(attribution, processed) {
2925
3377
  */
2926
3378
  function renderNothingConnected(conversation) {
2927
3379
  const reason = conversation?.kind === "no-request" ? "This branch had no pull request open when the review was made, so there was no discussion to bring in." : conversation && conversation.kind !== "attached" ? conversationNote$1(conversation) : "";
2928
- return `<h3 class="prb-doc-title">PR context</h3>
3380
+ return `${renderDocHead("PR context", renderFocusToggle("prcontext", "PR context"))}
2929
3381
  <p class="prb-pr-empty">
2930
3382
  No pull request discussion was brought into this review, so none of the findings have been
2931
3383
  checked against what other reviewers have already said.
@@ -3058,6 +3510,9 @@ function verdictLabel(verdict) {
3058
3510
  * difference between "the analysis had nothing to say" and "the analysis did not run" should be
3059
3511
  * available to a test as a fact rather than as a substring of prose.
3060
3512
  */
3513
+ /** Both KISS panes can take the whole review page, in every state they are drawn in. */
3514
+ var KISS_TOGGLE = renderFocusToggle("kiss", "KISS review");
3515
+ var RECS_HEAD = renderDocHead("Recommendations", renderFocusToggle("kissrecs", "Recommendations"));
3061
3516
  /**
3062
3517
  * What each payoff level is called where a reader can see it.
3063
3518
  *
@@ -3128,7 +3583,7 @@ function renderKissSummary(view) {
3128
3583
  if (!run) return renderKissEmpty(view.canRun);
3129
3584
  if (run.status === "generating") return renderRunning(run, view.job ?? null);
3130
3585
  if (run.status === "failed" || !run.result) return renderFailed(run, view);
3131
- return `${renderDocHead("KISS review", renderAnalysisDetails(provenanceFacts(run)))}
3586
+ return `${renderDocHead("KISS review", `${renderAnalysisDetails(provenanceFacts(run))}${KISS_TOGGLE}`)}
3132
3587
  <div class="prb-kiss-fresh-region" data-kiss-fresh-region data-reviewed-sha="${escapeHtml$1(run.reviewedSha)}"${view.freshness ? ` data-freshness="${escapeHtml$1(JSON.stringify(view.freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderKissFreshness(run.reviewedSha, view.freshness ?? null, {
3133
3588
  dismissedSha: view.dismissedFreshnessSha,
3134
3589
  actions: view.canRun
@@ -3177,14 +3632,14 @@ function renderKissFreshness(reviewedSha, freshness, options = {}) {
3177
3632
  function renderKissRecommendations(view) {
3178
3633
  const presented = presentedKissRun(view);
3179
3634
  const result = presented?.result;
3180
- if (!result) return `<h3 class="prb-doc-title">Recommendations</h3>${renderNoRunYet(view)}`;
3635
+ if (!result) return `${RECS_HEAD}${renderNoRunYet(view)}`;
3181
3636
  const earlier = earlierNote(view, presented);
3182
- if (result.recommendations.length === 0) return `<h3 class="prb-doc-title">Recommendations</h3>${earlier}
3637
+ if (result.recommendations.length === 0) return `${RECS_HEAD}${earlier}
3183
3638
  <div class="prb-kiss-outcome" data-review-outcome="empty">
3184
3639
  <h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
3185
3640
  <p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
3186
3641
  </div>`;
3187
- return `<h3 class="prb-doc-title">Recommendations</h3>${earlier}
3642
+ return `${RECS_HEAD}${earlier}
3188
3643
  ${result.recommendations.map((recommendation) => renderRecommendation(recommendation)).join("")}`;
3189
3644
  }
3190
3645
  /**
@@ -3212,7 +3667,7 @@ function earlierNote(view, presented) {
3212
3667
  * else's code for the person who wrote it.
3213
3668
  */
3214
3669
  function renderKissEmpty(canRun) {
3215
- return `${renderDocHead("KISS review")}
3670
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3216
3671
  <p class="prb-kiss-lede">${escapeHtml$1(KISS_DESCRIPTION)}</p>
3217
3672
  <p class="prb-kiss-note">KISS looks at whether the complexity here is justified. It does not replace the review beside it, which is where correctness and risk are answered.</p>
3218
3673
  ${renderRunControl(canRun, "Run KISS review")}`;
@@ -3264,7 +3719,7 @@ function renderRunning(run, job) {
3264
3719
  const phase = job?.phase ?? "analysing";
3265
3720
  const copy = phaseSentence(job) || "Running KISS review. This takes about as long as a review.";
3266
3721
  const progress = job?.progress ? ` <span class="prb-job-progress">${escapeHtml$1(job.progress)}</span>` : "";
3267
- return `${renderDocHead("KISS review")}
3722
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3268
3723
  <div class="prb-kiss-status" data-kiss-phase="${escapeHtml$1(phase)}" role="status" aria-live="polite">
3269
3724
  <p><span class="prb-spinner" aria-hidden="true"></span> ${escapeHtml$1(copy)}${progress}</p>
3270
3725
  <p class="prb-kiss-status-note">Started ${escapeHtml$1(relativeTime$1(run.startedAt))}. The review beside this one is unaffected and can be read while it runs.</p>
@@ -3278,7 +3733,7 @@ function renderRunning(run, job) {
3278
3733
  */
3279
3734
  function renderFailed(run, view) {
3280
3735
  const standing = view.previous ?? null;
3281
- return `${renderDocHead("KISS review")}
3736
+ return `${renderDocHead("KISS review", KISS_TOGGLE)}
3282
3737
  <div class="prb-kiss-outcome" data-review-outcome="failed">
3283
3738
  <h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
3284
3739
  <p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
@@ -4048,11 +4503,12 @@ function renderAvailable(job, fileCount) {
4048
4503
  * with the broken phase marked, so the answer is which step failed rather than merely that
4049
4504
  * something did.
4050
4505
  */
4506
+ var PROGRESS_TOGGLE = renderFocusToggle("progress", "Review progress");
4051
4507
  function renderProgressView(job, fileCount) {
4052
- if (job.phase === "failed") return `<h3 class="prb-doc-title">Review stopped</h3>
4508
+ if (job.phase === "failed") return `${renderDocHead("Review stopped", PROGRESS_TOGGLE)}
4053
4509
  ${renderSteps(job)}
4054
4510
  <div class="prb-progress-failure">${renderFailureBody(job, { lead: false })}</div>`;
4055
- return `<h3 class="prb-doc-title">Review in progress</h3>
4511
+ return `${renderDocHead("Review in progress", PROGRESS_TOGGLE)}
4056
4512
  <p class="prb-progress-lead">Building the context needed to review this change.</p>
4057
4513
  ${renderSteps(job)}
4058
4514
  ${renderAvailable(job, fileCount)}
@@ -4285,17 +4741,42 @@ function renderFilesView(view, sections) {
4285
4741
  ${renderFiles(view, sections)}
4286
4742
  </section>`;
4287
4743
  }
4744
+ /**
4745
+ * What the implementation tab says before anything has been drawn on it.
4746
+ *
4747
+ * Hidden by the stylesheet the moment a card is in the pane, so the client can add the first
4748
+ * drawing without having to remember to take this away.
4749
+ */
4750
+ function renderImplementationEmpty() {
4751
+ return `<div class="prb-map-empty prb-diagram-empty" data-diagram-empty>
4752
+ <h4 class="prb-map-empty-title">Trace how the code does it</h4>
4753
+ <p>Which parts of the change call which, and in what order. Draw the whole change here, or one theme from its Review Path card.</p>
4754
+ <button type="button" class="prb-map-generate" data-diagram-request="">Generate implementation diagram</button>
4755
+ </div>`;
4756
+ }
4288
4757
  /** The standard review's panes, drawn only where there is a standard result to draw them from. */
4289
4758
  function renderStandardViews(view, sections) {
4290
4759
  const { session } = view;
4291
4760
  const result = session.result;
4292
- return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", renderAnalysisDetails(analysisDetailItems(session)))}${renderOverviewTab(session, "", {
4761
+ return `<section class="prb-view" data-view="overview">${renderDocHead("Review overview", `${renderAnalysisDetails(analysisDetailItems(session))}${renderFocusToggle("overview", "Overview")}`)}${renderOverviewTab(session, "", {
4293
4762
  diagrams: true,
4294
4763
  detailsInHeader: true
4295
4764
  })}</section>
4296
4765
  <section class="prb-view" data-view="guide" hidden>${renderWorkspaceGuide(session)}</section>
4297
- <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
4298
- <h3 class="prb-doc-title">Diagrams</h3>
4766
+ <section class="prb-view" data-view="map" hidden data-map-pane data-map-register="${view.changeMapRegister ?? "simple"}"${view.pendingDiagram?.drawing === "map" ? " data-map-running" : ""}>
4767
+ ${renderDocHead("Diagrams", renderFocusToggle("map", "Change map"))}
4768
+ ${renderDiagramModes("map", diagramsReady(view))}
4769
+ <div class="prb-map-body" data-map-body>${renderChangeMapBody({
4770
+ stored: view.changeMap,
4771
+ sections,
4772
+ reviewedSha: view.reviewedSha ?? null,
4773
+ register: view.changeMapRegister ?? "simple"
4774
+ })}</div>
4775
+ </section>
4776
+ <section class="prb-view" data-view="diagram" hidden data-diagram-pane${view.pendingDiagram && view.pendingDiagram.drawing !== "map" ? ` data-diagram-running="${escapeHtml$1(view.pendingDiagram.sectionId ?? "")}"` : ""}>
4777
+ ${renderDocHead("Diagrams", renderFocusToggle("diagram", "Implementation diagrams"))}
4778
+ ${renderDiagramModes("diagram", diagramsReady(view))}
4779
+ ${renderImplementationEmpty()}
4299
4780
  ${renderDiagrams(view.diagrams ?? [], sections, view.reviewedSha ?? null)}
4300
4781
  </section>
4301
4782
  <section class="prb-view" data-view="issues" hidden>
@@ -4613,7 +5094,7 @@ function renderRail(view, sections, stale = false) {
4613
5094
  <div class="prb-rail-path" id="prb-rail-path">
4614
5095
  ${themes || "<p class=\"prb-rail-heading\">No themes</p>"}
4615
5096
  </div>
4616
- ${(view.diagrams?.length ?? 0) > 0 ? railLink("diagram", "diagrams", "Diagrams", diagramsBadge(view.diagrams?.length ?? 0)) : ""}
5097
+ ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram")}
4617
5098
  ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)))}
4618
5099
  ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"))}
4619
5100
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
@@ -4721,7 +5202,24 @@ function recommendationsBadge(count) {
4721
5202
  };
4722
5203
  }
4723
5204
  /** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
5205
+ /** The map and the implementation diagrams together: everything behind the one rail item. */
5206
+ function diagramCount(view) {
5207
+ return (mapShown(view) ? 1 : 0) + (view.diagrams?.length ?? 0);
5208
+ }
5209
+ /** Which of the two tabs has something on it, for the check each tab carries. */
5210
+ function diagramsReady(view) {
5211
+ return {
5212
+ map: mapShown(view),
5213
+ diagram: (view.diagrams?.length ?? 0) > 0
5214
+ };
5215
+ }
5216
+ /** A map from a commit the review no longer describes is not shown, so it is neither counted nor ready. */
5217
+ function mapShown(view) {
5218
+ const drawnFrom = view.changeMap?.commitSha;
5219
+ return Boolean(view.changeMap) && !(drawnFrom && view.reviewedSha && drawnFrom !== view.reviewedSha);
5220
+ }
4724
5221
  function diagramsBadge(count) {
5222
+ if (count === 0) return null;
4725
5223
  return {
4726
5224
  text: String(count),
4727
5225
  label: count === 1 ? "1 diagram" : `${count} diagrams`
@@ -4797,8 +5295,8 @@ function renderBadge(badge) {
4797
5295
  return `<span class="prb-rail-count${badge.done ? " prb-rail-count-done" : ""}" aria-label="${escapeHtml$1(badge.label)}">${escapeHtml$1(badge.text)}</span>`;
4798
5296
  }
4799
5297
  /** A null badge renders nothing, for the case where zero would be a claim rather than a total. */
4800
- function railLink(view, icon, label, badge, note) {
4801
- return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${note ? ` title="${escapeHtml$1(note)}"` : ""}>
5298
+ function railLink(view, icon, label, badge, note, alsoCurrentOn) {
5299
+ return `<button type="button" class="prb-rail-item prb-rail-link" data-view-nav="${view}"${alsoCurrentOn ? ` data-view-also="${alsoCurrentOn}"` : ""}${note ? ` title="${escapeHtml$1(note)}"` : ""}>
4802
5300
  ${railIcon(icon)}
4803
5301
  <span class="prb-rail-label"><span class="prb-rail-title">${escapeHtml$1(label)}</span></span>
4804
5302
  ${renderBadge(badge)}
@@ -7338,7 +7836,7 @@ async function askAboutChange(workspace, question, sectionId, signal) {
7338
7836
  try {
7339
7837
  return extractAnswer(await withUsageRecorded(workspace.id, "ask", env, (onUsage) => agentFor(env).run({
7340
7838
  capability: "read-code",
7341
- prompt: buildPrompt$1(workspace, question, sectionId, cwd),
7839
+ prompt: buildPrompt$2(workspace, question, sectionId, cwd),
7342
7840
  cwd,
7343
7841
  originRepoPath: workspace.repoPath,
7344
7842
  env,
@@ -7397,7 +7895,7 @@ function extractAnswer(raw) {
7397
7895
  * where X is in another theme, and a prompt that had quietly dropped X would answer confidently
7398
7896
  * and wrongly.
7399
7897
  */
7400
- function buildPrompt$1(workspace, question, sectionId, cwd) {
7898
+ function buildPrompt$2(workspace, question, sectionId, cwd) {
7401
7899
  const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
7402
7900
  const recent = workspace.transcript.slice(-4);
7403
7901
  return [
@@ -7449,6 +7947,7 @@ function beginRun(workspaceId, kind, detail, now = Date.now()) {
7449
7947
  kind,
7450
7948
  question: detail.question ?? null,
7451
7949
  sectionId: detail.sectionId,
7950
+ ...detail.drawing ? { drawing: detail.drawing } : {},
7452
7951
  startedAt: now,
7453
7952
  signal: controller.signal,
7454
7953
  state: "running",
@@ -7464,6 +7963,7 @@ function reportRun(workspaceId, kind) {
7464
7963
  return {
7465
7964
  question: run.question,
7466
7965
  sectionId: run.sectionId,
7966
+ ...run.drawing ? { drawing: run.drawing } : {},
7467
7967
  startedAt: run.startedAt
7468
7968
  };
7469
7969
  }
@@ -7562,7 +8062,7 @@ async function drawDiagram(workspace, sectionId, signal) {
7562
8062
  try {
7563
8063
  raw = await withUsageRecorded(workspace.id, "diagram", env, (onUsage) => agentFor(env).run({
7564
8064
  capability: "read-code",
7565
- prompt: buildPrompt(workspace, sectionId, cwd),
8065
+ prompt: buildPrompt$1(workspace, sectionId, cwd),
7566
8066
  cwd,
7567
8067
  originRepoPath: workspace.repoPath,
7568
8068
  env,
@@ -7618,7 +8118,7 @@ function extractDiagram(raw) {
7618
8118
  * show what it calls into, and a prompt that had quietly dropped the rest would draw a component
7619
8119
  * with no edges leaving it and present that as the shape of the code.
7620
8120
  */
7621
- function buildPrompt(workspace, sectionId, cwd) {
8121
+ function buildPrompt$1(workspace, sectionId, cwd) {
7622
8122
  const section = workspace.session.result?.sections.find((s) => s.id === sectionId);
7623
8123
  return [
7624
8124
  SYSTEM_PROMPT,
@@ -7634,6 +8134,183 @@ function buildPrompt(workspace, sectionId, cwd) {
7634
8134
  section ? `DRAW: the "${section.title}" theme of this change. Centre the diagram on it, and show what it calls into and what calls it where that is needed to make sense of it.` : "DRAW: this change as a whole, at the level a reviewer needs to understand what it does."
7635
8135
  ].join("\n");
7636
8136
  }
8137
+ //#endregion
8138
+ //#region ../../packages/review-harness/src/domain/change_map/prompt.ts
8139
+ /**
8140
+ * What a change map is asked to be.
8141
+ *
8142
+ * Not the implementation diagram made smaller. That one asks "how does the code do this?" and is
8143
+ * right to name functions; this one asks "what is this change about, and why does each part matter
8144
+ * to the next?", and a function is a node here only when it is the handle a reader needs to find
8145
+ * the idea. The two prompts are separate because a single one asked to be both at once produces a
8146
+ * call graph with friendlier labels, which is the failure this feature exists to fix.
8147
+ *
8148
+ * The map is built from the review rather than beside it. The Review Path already decided what the
8149
+ * themes of this change are; a map that rediscovered them would sooner or later disagree with the
8150
+ * guide it sits next to, and the reviewer would be left to work out which one was wrong.
8151
+ */
8152
+ var CHANGE_MAP_PROMPT = `You are Buddy AI, PR Review Buddy's review assistant. A developer is trying to understand a code change before and while reviewing it, and learns best visually. Build a CHANGE MAP: a small diagram of the ideas in this change and how they bear on one another.
8153
+
8154
+ This is not a call graph. It answers "what is this change about, what are the important ideas, how do they depend on or affect each other, and where is the risk?" — not "which function calls which". A reviewer who has never seen this code should be able to read the map left to right and come away with a correct mental model of the change.
8155
+
8156
+ Build it from the REVIEW GUIDE below. Its themes are already the important ideas of this change; reuse them, keep their meaning, and link a node to its theme with "sectionId" wherever the node is one of those themes. You have the working tree too: open files to check a relationship is real before you draw it, and never draw what a change of this kind usually looks like.
8157
+
8158
+ What goes on the map:
8159
+ - One "intent" node: what the change is trying to accomplish for the person using it.
8160
+ - The main ideas ("concept"): the behaviour introduced or changed.
8161
+ - Actors and boundaries ("actor") only where they matter to the story: the CLI, a server, the browser, the filesystem, a worker, an external service.
8162
+ - Shared state or data ("state") that more than one part reads or writes.
8163
+ - Rules the change must keep ("constraint"), and what goes wrong if it does not ("risk").
8164
+ - One "outcome" node: what the person using it gets, or would lose.
8165
+ Leave out anything a reader can assume. Do not make a node for every file or function. 12 nodes is a hard maximum, and most changes need 5 to 9.
8166
+
8167
+ Reading order. Every node has a "stage" from 0 to 4, and the map is read by stage, left to right:
8168
+ 0 intent · 1 behaviour · 2 shared state and actors · 3 risks and constraints · 4 outcome.
8169
+ List nodes in the order the story reaches them.
8170
+
8171
+ Importance. Mark the ideas a reviewer most needs to understand, at most 3 of them, as "weight": "primary", and every other node "supporting". Say why an idea matters in its "why" — never give a score, a rating or a number.
8172
+
8173
+ Edges carry the meaning. Every edge has a short verb phrase "label" read from → to, such as: creates, reads from, writes to, depends on, requires, protects, can race with, happens before, removes from, invalidates, preserves, changes the behaviour of. An edge that only says "uses" or "calls" or has no label is not allowed. Add a one-sentence "note" when the verb alone would not explain the relationship. At most 16 edges. Draw the relationships that explain causality, not every connection that exists.
8174
+
8175
+ Two registers. Every title, body, why, label and note is an object with "simple" and "technical" versions of the SAME claim. The graph is identical in both; only the words differ.
8176
+ - simple: everyday words for someone who does not know this codebase. Where it genuinely helps, one real-world analogy may carry through the whole map (a shared whiteboard, a queue at a counter), but do not force an analogy onto every node.
8177
+ - technical: the precise terms a developer would use, including the mechanism.
8178
+ Titles are plain-English names for ideas ("Only one process updates the queue at a time"), never identifiers. Put the identifier or file a reader could search for in "anchor" instead ("withWriteLock()").
8179
+
8180
+ Keep text short, because a node is read at a glance beside the others: a title of at most 6 words, a body of ONE sentence of at most 20 words, a why of at most 15 words, a label of two to four words. Leave detail to the Review Path; the map is for the shape. Most maps need 6 to 12 edges: draw the spine of the story, and give no node more than 3 or 4 edges.
8181
+
8182
+ Reply with one JSON object and nothing else, in exactly this shape:
8183
+
8184
+ {
8185
+ "title": "<short noun phrase naming the change>",
8186
+ "intent": { "simple": "<one sentence>", "technical": "<one sentence>" },
8187
+ "nodes": [
8188
+ {
8189
+ "id": "<short-kebab-id>",
8190
+ "role": "intent | actor | concept | state | constraint | risk | outcome",
8191
+ "stage": 0,
8192
+ "weight": "primary | supporting",
8193
+ "title": { "simple": "...", "technical": "..." },
8194
+ "body": { "simple": "...", "technical": "..." },
8195
+ "why": { "simple": "...", "technical": "..." },
8196
+ "anchor": "<optional identifier or path>",
8197
+ "sectionId": "<optional theme id from the review guide>"
8198
+ }
8199
+ ],
8200
+ "edges": [
8201
+ {
8202
+ "from": "<node id>",
8203
+ "to": "<node id>",
8204
+ "label": { "simple": "...", "technical": "..." },
8205
+ "note": { "simple": "...", "technical": "..." }
8206
+ }
8207
+ ]
8208
+ }
8209
+
8210
+ "why", "anchor", "sectionId" and "note" are optional. Every other field is required.
8211
+
8212
+ For example, for a change that caches user profiles, two of the nodes and the edge between them might be:
8213
+
8214
+ { "id": "profile-cache", "role": "state", "stage": 2, "weight": "primary",
8215
+ "title": { "simple": "A saved copy of each profile", "technical": "In-memory profile cache" },
8216
+ "body": { "simple": "Pages read the saved copy instead of asking the database every time.", "technical": "getProfile() reads through an LRU keyed by user id; misses fall back to the database." },
8217
+ "why": { "simple": "If the copy is out of date, people see old details.", "technical": "Every read path now depends on invalidation being correct." },
8218
+ "anchor": "ProfileCache" }
8219
+ { "id": "profile-edit", "role": "concept", "stage": 1, "weight": "supporting",
8220
+ "title": { "simple": "Editing your profile", "technical": "Profile update endpoint" },
8221
+ "body": { "simple": "Saving changes writes the new details.", "technical": "PUT /profile writes the row and must evict the cache entry." } }
8222
+ { "from": "profile-edit", "to": "profile-cache",
8223
+ "label": { "simple": "must refresh", "technical": "invalidates" },
8224
+ "note": { "simple": "Otherwise the saved copy keeps showing the old details.", "technical": "A missed eviction serves stale data until the entry expires." } }`;
8225
+ //#endregion
8226
+ //#region ../../packages/review-harness/src/workspace/change_map.ts
8227
+ /** A diagram's budget. The map reads the same change and writes less code-shaped output. */
8228
+ var CHANGE_MAP_TIMEOUT_MS = 18e4;
8229
+ /** A change map needs something to map. Refused before any agent is asked. */
8230
+ var ChangeMapUnavailableError = class extends Error {};
8231
+ /**
8232
+ * Map the change, from the review the reviewer is already reading.
8233
+ *
8234
+ * The same isolation a diagram gets: a checkout pinned to the reviewed commit, the review's own
8235
+ * model, and its usage recorded against the review. Callable on its own, with nothing about the
8236
+ * request in it, so the day maps are drawn without being asked for is a new caller, not a new
8237
+ * function.
8238
+ *
8239
+ * Failures come back as the diagram's own errors, because to the reviewer this is the same act —
8240
+ * asking for a picture — and the pane already knows how to say either one.
8241
+ */
8242
+ async function drawChangeMap(workspace, signal) {
8243
+ const result = workspace.session.result;
8244
+ if (!result) throw new ChangeMapUnavailableError("This review has no analysis yet, so there is nothing to map.");
8245
+ const cwd = await askCheckout(workspace);
8246
+ const env = agentEnvOf(workspace);
8247
+ const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
8248
+ let raw;
8249
+ try {
8250
+ raw = await withUsageRecorded(workspace.id, "change-map", env, (onUsage) => agentFor(env).run({
8251
+ capability: "read-code",
8252
+ prompt: buildPrompt(workspace, cwd),
8253
+ cwd,
8254
+ originRepoPath: workspace.repoPath,
8255
+ env,
8256
+ ...requestedModel ? { requestedModel } : {},
8257
+ timeoutMs: CHANGE_MAP_TIMEOUT_MS,
8258
+ signal,
8259
+ onUsage
8260
+ }), void 0, requestedModel);
8261
+ } catch (error) {
8262
+ if (error instanceof AgentUnavailableError) throw new DiagramUnavailableError(`${error.message} It is what this review was made with, so nothing can be drawn.`);
8263
+ throw error;
8264
+ }
8265
+ let map;
8266
+ try {
8267
+ ({map} = validateChangeMap(raw, result.sections.map((section) => section.id)));
8268
+ } catch (error) {
8269
+ if (error instanceof ChangeMapRejected) throw new DiagramMalformedError(`Buddy AI did not return a change map that could be drawn. ${error.message}`);
8270
+ throw error;
8271
+ }
8272
+ return {
8273
+ ...map,
8274
+ commitSha: reviewedCommit(workspace),
8275
+ createdAt: Date.now(),
8276
+ promptVersion: "2"
8277
+ };
8278
+ }
8279
+ /**
8280
+ * The review, the change, and the simple explanation the reviewer has already read, if any.
8281
+ *
8282
+ * That last one is what keeps Simple mode and "Explain in simple terms" one explanation rather than
8283
+ * two. A reviewer who has just read that the queue is like a shared whiteboard should find the same
8284
+ * whiteboard on the map, not a second analogy that makes them wonder which one is right.
8285
+ */
8286
+ function buildPrompt(workspace, cwd) {
8287
+ const simple = simpleExplanation(workspace);
8288
+ return [
8289
+ CHANGE_MAP_PROMPT,
8290
+ "---",
8291
+ `CHANGE: ${workspace.changeSet.headRef} compared against ${workspace.changeSet.baseRef}, in the checkout at ${cwd}`,
8292
+ "---",
8293
+ "REVIEW GUIDE (JSON). Its \"sections\" are the Review Path themes; their \"id\" values are the only valid \"sectionId\" values:",
8294
+ JSON.stringify(workspace.session.result),
8295
+ ...simple ? [
8296
+ "---",
8297
+ "SIMPLE EXPLANATION THE REVIEWER HAS ALREADY READ. Keep to its analogy and its framing in the \"simple\" register, so the two explanations agree:",
8298
+ simple
8299
+ ] : [],
8300
+ "---",
8301
+ "DIFF:",
8302
+ workspace.diff
8303
+ ].join("\n");
8304
+ }
8305
+ /** The latest whole-change "Explain in simple terms" answer, which is the one the map sits beside. */
8306
+ function simpleExplanation(workspace) {
8307
+ const turns = workspace.transcript ?? [];
8308
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
8309
+ const turn = turns[i];
8310
+ if (turn.question === "Explain this review in plain English for someone unfamiliar with this codebase. Avoid jargon, use a simple everyday analogy if it helps, and keep it skippable — a couple of short paragraphs is enough." && !turn.sectionId && turn.answer.trim()) return turn.answer.trim();
8311
+ }
8312
+ return null;
8313
+ }
7637
8314
  var FileOutsideRepositoryError = class extends Error {};
7638
8315
  /**
7639
8316
  * Resolve a path against the reviewed repository, refusing anything outside it.
@@ -7990,11 +8667,12 @@ function readPreferences() {
7990
8667
  try {
7991
8668
  const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
7992
8669
  if (!parsed || typeof parsed !== "object") return {};
7993
- const { askWidth, dismissedNotices, promptsCollapsed } = parsed;
8670
+ const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister } = parsed;
7994
8671
  return {
7995
8672
  ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
7996
8673
  ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
7997
- ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {}
8674
+ ...typeof promptsCollapsed === "boolean" ? { promptsCollapsed } : {},
8675
+ ...changeMapRegister === "simple" || changeMapRegister === "technical" ? { changeMapRegister } : {}
7998
8676
  };
7999
8677
  } catch {
8000
8678
  return {};
@@ -8021,6 +8699,16 @@ function writePromptsCollapsed(collapsed) {
8021
8699
  }, null, 2)}\n`);
8022
8700
  } catch {}
8023
8701
  }
8702
+ /** Remember which words the change map is read in. Read-modify-write, like every other field here. */
8703
+ function writeChangeMapRegister(register) {
8704
+ try {
8705
+ mkdirSync(STORE_ROOT, { recursive: true });
8706
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
8707
+ ...readPreferences(),
8708
+ changeMapRegister: register
8709
+ }, null, 2)}\n`);
8710
+ } catch {}
8711
+ }
8024
8712
  /** Remember that the reviewer has read a notice. Refuses anything not shaped like a key. */
8025
8713
  function dismissNotice(key) {
8026
8714
  if (!NOTICE_KEY.test(key)) return false;
@@ -9023,6 +9711,7 @@ async function sendWorkspacePage(res, workspace) {
9023
9711
  askWidth: readPreferences().askWidth,
9024
9712
  dismissedNotices: readPreferences().dismissedNotices,
9025
9713
  promptsCollapsed: readPreferences().promptsCollapsed === true,
9714
+ changeMapRegister: readPreferences().changeMapRegister ?? "simple",
9026
9715
  transcript: workspace.transcript,
9027
9716
  pendingAsk: reportRun(workspace.id, "ask"),
9028
9717
  pendingDiagram: reportRun(workspace.id, "diagram"),
@@ -9043,6 +9732,7 @@ async function sendWorkspacePage(res, workspace) {
9043
9732
  lastPrRefresh: workspace.lastPrRefresh,
9044
9733
  lastUpdate: notice,
9045
9734
  diagrams: workspace.diagrams,
9735
+ changeMap: workspace.changeMap,
9046
9736
  reviewedSha: reviewedCommit(workspace),
9047
9737
  revision: workspaceRevision(workspace),
9048
9738
  lineage: lineagePosition(workspace),
@@ -9466,10 +10156,15 @@ async function handleStop(req, res, workspace) {
9466
10156
  */
9467
10157
  async function handleDiagram(req, res, workspace) {
9468
10158
  const body = await readJson(req);
9469
- const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
10159
+ const drawing = body?.kind === "map" ? "map" : "implementation";
10160
+ const sectionId = drawing === "implementation" && typeof body?.sectionId === "string" ? body.sectionId : null;
9470
10161
  const scope = sectionId ?? "change";
9471
- const redraw = (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
9472
- const started = beginRun(workspace.id, "diagram", { sectionId });
10162
+ const redraw = drawing === "map" ? workspace.changeMap !== void 0 : (workspace.diagrams ?? []).some((diagram) => diagram.scope === scope);
10163
+ if (drawing === "map" && !workspace.session.result) return send(res, 409, { error: "This review has no analysis yet, so there is nothing to map." });
10164
+ const started = beginRun(workspace.id, "diagram", {
10165
+ sectionId,
10166
+ drawing
10167
+ });
9473
10168
  if ("alreadyRunning" in started) return send(res, 409, {
9474
10169
  error: "A diagram of this review is already being drawn.",
9475
10170
  running: true
@@ -9478,6 +10173,7 @@ async function handleDiagram(req, res, workspace) {
9478
10173
  record({
9479
10174
  event: "diagram_requested",
9480
10175
  reviewId: workspace.id,
10176
+ drawing,
9481
10177
  scoped: sectionId !== null,
9482
10178
  redraw
9483
10179
  });
@@ -9485,18 +10181,21 @@ async function handleDiagram(req, res, workspace) {
9485
10181
  const failed = () => record({
9486
10182
  event: "diagram_drawn",
9487
10183
  reviewId: workspace.id,
10184
+ drawing,
9488
10185
  durationMs: Date.now() - startedAt,
9489
10186
  ok: false,
9490
10187
  ...run.state === "stopped" ? { cancelled: true } : {}
9491
10188
  });
9492
10189
  try {
9493
10190
  let diagram;
10191
+ let changeMap;
9494
10192
  try {
9495
- diagram = await drawDiagram(workspace, sectionId, run.signal);
10193
+ if (drawing === "map") changeMap = await drawChangeMap(workspace, run.signal);
10194
+ else diagram = await drawDiagram(workspace, sectionId, run.signal);
9496
10195
  } catch (error) {
9497
10196
  failed();
9498
10197
  if (run.state === "stopped") return sendIfOpen(res, 200, { stopped: true });
9499
- if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError) return sendIfOpen(res, 502, { error: error.message });
10198
+ if (error instanceof DiagramUnavailableError || error instanceof DiagramMalformedError || error instanceof ChangeMapUnavailableError) return sendIfOpen(res, 502, { error: error.message });
9500
10199
  throw error;
9501
10200
  }
9502
10201
  if (!completeRun(run)) {
@@ -9506,9 +10205,21 @@ async function handleDiagram(req, res, workspace) {
9506
10205
  record({
9507
10206
  event: "diagram_drawn",
9508
10207
  reviewId: workspace.id,
10208
+ drawing,
9509
10209
  durationMs: Date.now() - startedAt,
9510
10210
  ok: true
9511
10211
  });
10212
+ if (changeMap) {
10213
+ let kept = false;
10214
+ mutateWorkspace(workspace.id, (fresh) => {
10215
+ if (changeMap.commitSha && reviewedCommit(fresh) && changeMap.commitSha !== reviewedCommit(fresh) || fresh.jobId !== workspace.jobId || !fresh.session.result) return;
10216
+ fresh.changeMap = changeMap;
10217
+ kept = true;
10218
+ });
10219
+ if (!kept) return sendIfOpen(res, 409, { error: "The review moved to a newer commit while this map was being drawn. Generate it again to map the review as it is now." });
10220
+ return sendIfOpen(res, 200, { changeMap });
10221
+ }
10222
+ if (!diagram) return sendIfOpen(res, 502, { error: "Buddy AI did not return a diagram." });
9512
10223
  mutateWorkspace(workspace.id, (fresh) => {
9513
10224
  const diagrams = [...fresh.diagrams ?? []];
9514
10225
  const existing = diagrams.findIndex((one) => one.scope === scope);
@@ -9608,6 +10319,7 @@ async function handlePrefs(req, res) {
9608
10319
  if (typeof askWidth === "number") writeAskWidth(askWidth);
9609
10320
  else if (askWidth === null) clearAskWidth();
9610
10321
  if (typeof body?.promptsCollapsed === "boolean") writePromptsCollapsed(body.promptsCollapsed);
10322
+ if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
9611
10323
  if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
9612
10324
  send(res, 200, { ok: true });
9613
10325
  }
@@ -9705,6 +10417,7 @@ var VIEWS = [
9705
10417
  "files",
9706
10418
  "prcontext",
9707
10419
  "diagram",
10420
+ "map",
9708
10421
  "kiss",
9709
10422
  "kissrecs"
9710
10423
  ];