diffninja 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -48,9 +48,12 @@ npx -y diffninja@latest setup
48
48
  It installs diffninja and adds it to every agent CLI it finds on your machine.
49
49
  Then restart your agent CLI.
50
50
 
51
- To update later, run the same command again. To remove diffninja from your
52
- agent CLIs, run `npx -y diffninja setup --uninstall`. Other options:
53
- `npx -y diffninja setup --help`.
51
+ To update later, run the same command again: setup brings an older global
52
+ install up to its own version, then restart your agent CLI. (Setup from
53
+ 0.3.0 or earlier does not update an existing install; if yours reports an
54
+ older version, run `npm install -g diffninja@latest` once.) To remove
55
+ diffninja from your agent CLIs, run `npx -y diffninja@latest setup --uninstall`.
56
+ Other options: `npx -y diffninja@latest setup --help`.
54
57
 
55
58
  ## How to use it
56
59
 
@@ -21,7 +21,9 @@ import type { ReviewReport } from "./types.js";
21
21
  * reaches the reviewer as a glyph plus color for a call that was added,
22
22
  * removed, or contains a change, and plain dimmed text for an unchanged call.
23
23
  */
24
- export declare function renderCallFlows(report: ReviewReport): string;
24
+ export declare function renderCallFlows(report: ReviewReport, options?: {
25
+ readonly treeOnly?: boolean;
26
+ }): string;
25
27
  export declare const CALL_FLOW_STYLES = "\n.cf {\n display: flex; flex-direction: column; gap: 12px; margin-top: 18px;\n --cf-add: #1a7f37; --cf-add-soft: #dafbe1;\n --cf-del: #cf222e; --cf-del-soft: #ffebe9;\n --cf-chg: #9a6700; --cf-chg-soft: #fff8c5;\n --cf-quiet: var(--ink-soft);\n}\n@media (prefers-color-scheme: dark) {\n .cf {\n --cf-add: #3fb950; --cf-add-soft: rgba(46, 160, 67, 0.15);\n --cf-del: #f85149; --cf-del-soft: rgba(248, 81, 73, 0.12);\n --cf-chg: #d29922; --cf-chg-soft: rgba(187, 128, 9, 0.15);\n }\n}\n/* Mode sections, folded files and the breadcrumb are toggled with the hidden\n attribute, so the module carries that rule itself instead of relying on the page. */\n.cf [hidden] { display: none !important; }\n.cf-head { display: flex; flex-direction: column; gap: 4px; }\n.cf-prov { font-size: 12px; color: var(--ink-soft); }\n.cf-note { font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-absence { padding: 16px 0; }\n.cf-bounds {\n margin: 10px 0 0;\n padding: 2px 0 2px 10px;\n border-left: 2px solid var(--cf-chg);\n color: var(--ink-soft);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.cf-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }\n.cf-ready .cf-controls { position: sticky; top: var(--toolbar-height, 0px); z-index: 4; background: var(--bg); padding: 8px 0; }\n.cf, .cf-files, .cf-file-body, .cf-mode { min-width: 0; }\n.cf-modes {\n display: inline-flex;\n align-items: stretch;\n border: 1px solid var(--line);\n border-radius: 6px;\n overflow: hidden;\n background: var(--panel);\n}\n.cf-mode-link {\n display: inline-flex;\n align-items: center;\n padding: 5px 14px;\n font-size: 12px;\n color: var(--ink-soft);\n text-decoration: none;\n border-right: 1px solid var(--line);\n}\n.cf-mode-link:last-child { border-right: 0; }\n.cf-mode-link:hover, .cf-mode-link:focus-visible { color: var(--ink); background: var(--sunken); }\n.js .cf-mode-link[aria-current] { color: var(--ink); background: var(--sunken); font-weight: 600; }\n.cf-jump ul {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n max-height: 22vh;\n overflow-y: auto;\n}\n.cf-jump-link {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n max-width: 100%;\n min-width: 0;\n padding: 4px 10px 4px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n font-size: 12px;\n text-decoration: none;\n}\n.cf-jump-link:hover, .cf-jump-link:focus-visible { border-color: var(--cursor); }\n.cf-jump-path { min-width: 0; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.cf-jump-count { color: var(--ink-soft); white-space: nowrap; }\n.cf-crumbs {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n gap: 6px;\n font-size: 12.5px;\n padding-bottom: 3px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n}\n.cf-crumb {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 34ch;\n font: inherit;\n font-size: 12px;\n padding: 3px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: var(--panel);\n color: var(--ink);\n cursor: pointer;\n white-space: nowrap;\n}\n.cf-crumb[aria-current] { border-color: var(--cursor); font-weight: 600; }\n.cf-crumb-label { overflow: hidden; text-overflow: ellipsis; }\n.cf-crumb-file {\n flex: 0 0 auto;\n max-width: 22ch;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 11px;\n color: var(--ink-soft);\n}\n.cf-sep { flex: 0 0 auto; color: var(--ink-soft); }\n.cf-files { display: flex; flex-direction: column; gap: 12px; }\n.cf-file {\n background: var(--panel);\n border: 1px solid var(--line);\n border-left: 3px solid var(--line-strong);\n border-radius: 6px;\n overflow: hidden;\n}\n.cf-file { --cf-color: var(--ink-soft); --cf-fill: var(--sunken); }\n.cf-file-attention { border-left-color: var(--alarm); --cf-color: var(--alarm); --cf-fill: var(--alarm-bg); }\n.cf-file-uncertain { border-left-color: var(--warn); --cf-color: var(--warn); --cf-fill: var(--warn-bg); }\n.cf-file-low { border-left-color: var(--line-strong); }\n.cf-file-passed { border-left-color: var(--teal); --cf-color: var(--teal); --cf-fill: var(--teal-bg); }\n.cf-file-head {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 10px;\n padding: 8px 12px;\n background: var(--sunken);\n cursor: pointer;\n list-style: none;\n}\n.cf-file-head::-webkit-details-marker { display: none; }\n.cf-file-head::after {\n content: \"\u25B8\";\n margin-left: auto;\n font-size: 11px;\n color: var(--ink-soft);\n transition: transform 0.15s ease;\n}\n.cf-file[open] > .cf-file-head::after { transform: rotate(90deg); }\n.cf-file-head:focus-visible { outline: 2px solid var(--cursor); outline-offset: -2px; }\n.cf-file-path { flex: 1 1 260px; min-width: 0; font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }\n.cf-file-count { font-size: 12px; color: var(--ink-soft); white-space: nowrap; }\n.cf-diff-link { font-size: 12px; white-space: nowrap; }\n.cf-file-body { border-top: 1px solid var(--line); padding: 0 12px 12px; }\n.cf-mode { padding-top: 12px; }\n.cf-noscript { display: block; }\n.cf-noscript .cf-note { margin: 6px 0 0; }\n.cf-mode + .cf-mode { margin-top: 12px; border-top: 1px dashed var(--line); }\n.cf-mode-head {\n margin: 0 0 8px;\n font-size: 11.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-soft);\n}\n.cf-ready .cf-mode-head { display: none; }\n.cf-ready .cf-mode + .cf-mode { margin-top: 0; border-top: 0; }\n.cf-tree, .cf-children { list-style: none; margin: 0; padding: 0; }\n.cf-children { margin-left: 15px; padding-left: 10px; border-left: 1px dashed var(--line); }\n.cf-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; padding: 2px 6px; border-radius: 6px; min-width: 0; }\n.cf-row:hover { background: var(--sunken); }\n.cf-fold > .cf-row { cursor: pointer; list-style: none; }\n.cf-fold > .cf-row::-webkit-details-marker { display: none; }\n.cf-fold > .cf-row::before { content: \"\u25B8\"; font-size: 10px; color: var(--ink-soft); transition: transform 0.15s ease; }\n.cf-fold[open] > .cf-row::before { transform: rotate(90deg); }\n.cf-badge, .cf-chip-badge {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n min-width: 16px;\n height: 16px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 700;\n}\n.cf-badge, .cf-chip-badge { color: var(--cf-node-color); background: var(--cf-node-fill); }\n.cf-label {\n font: 400 12.5px/1.5 var(--mono);\n padding: 0 2px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: none;\n text-align: left;\n /* Capped so a long call never pushes its row onto a second line; the\n location takes what is left and truncates first. */\n flex: 0 1 auto;\n min-width: 0;\n max-width: 62%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.js .cf-label { cursor: pointer; }\n.js .cf-label:hover, .cf-label:focus-visible { text-decoration: underline; }\n.cf-label:focus-visible { outline: 2px solid var(--cursor); outline-offset: 1px; }\n.cf-loc { flex: 1 1 0; min-width: 0; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-loc-none { font-style: italic; }\n.cf-node, .cf-gnode, .cf-chip { --cf-node-color: var(--cf-chg); --cf-node-fill: var(--cf-chg-soft); --cf-node-weight: 600; --cf-node-decoration: none; }\n.cf-st-added { --cf-node-color: var(--cf-add); --cf-node-fill: var(--cf-add-soft); }\n.cf-st-removed { --cf-node-color: var(--cf-del); --cf-node-fill: var(--cf-del-soft); --cf-node-decoration: line-through; }\n.cf-node.cf-st-same, .cf-gnode.cf-st-same, .cf-chip.cf-st-same { --cf-node-color: var(--ink-soft); --cf-node-fill: var(--sunken); --cf-node-weight: 400; }\n.cf-label { color: var(--cf-node-color); font-weight: var(--cf-node-weight); text-decoration: var(--cf-node-decoration); }\n.cf-node.cf-ancestor > .cf-fold > .cf-row { display: none; }\n.cf-node.cf-ancestor > .cf-fold > .cf-children { margin: 0; padding: 0; border: 0; }\n.cf-svg-wrap {\n max-width: 100%;\n overflow: auto;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n overscroll-behavior: contain;\n}\n.cf-svg { display: block; max-width: none; }\n.cf-ready .cf-svg-wrap { height: clamp(220px, 42vh, 400px); overflow: hidden; touch-action: none; cursor: grab; }\n.cf-diagram-btn, .cf-diagram-close {\n display: inline-flex; align-items: center; gap: 6px; height: 29px; padding: 0 12px;\n border: 1px solid var(--line); border-radius: 6px; background: var(--panel);\n font: 500 12px/1 var(--sans, inherit); color: var(--ink); cursor: pointer;\n}\n.cf-diagram-btn { color: var(--cursor); border-color: var(--cursor); }\n.cf-diagram-btn::after { content: \"\\2197\"; font-size: 12px; }\n.cf-diagram-btn:hover, .cf-diagram-close:hover { background: var(--sunken); }\n.cf-diagram-close { margin-left: auto; }\n.cf-diagram-hint { margin: 0 0 0 auto; font-size: 12px; color: var(--ink-soft); }\n.cf-diagram-hint + .cf-diagram-close { margin-left: 12px; }\n.cf.cf-diagram-open .cf-head { display: none; }\n/* The diagram view fills the browser window, and the graph gets nearly all of its height. */\n.cf.cf-diagram-open {\n position: fixed; inset: 0; z-index: 50; margin: 0; padding: 12px 20px 20px;\n background: var(--bg); overflow: auto; overscroll-behavior: contain;\n}\n.cf.cf-diagram-open .cf-controls { position: sticky; top: -12px; z-index: 5; background: var(--bg); padding: 10px 0; }\n.cf.cf-diagram-open .cf-modes, .cf.cf-diagram-open .cf-diagram-btn { display: none !important; }\n.cf.cf-diagram-open { padding-left: 292px; }\n.cf.cf-diagram-open .cf-svg-wrap { height: calc(100vh - 150px); min-height: 320px; }\n/* One canvas at a time: the entry point picked in the list, in its own file. */\n.cf.cf-diagram-open .cf-jump, .cf.cf-diagram-open .cf-bounds, .cf.cf-diagram-open .cf-omitted { display: none !important; }\n.cf.cf-diagram-open .cf-file:not(.cf-file-current) { display: none; }\n.cf.cf-diagram-open .cf-graph-frame:not(.cf-frame-current) { display: none; }\n.cf.cf-diagram-open .cf-file-body { padding-bottom: 0; }\n.cf-diagram-nav {\n position: fixed; top: 0; bottom: 0; left: 0; width: 276px; overflow-y: auto; z-index: 6;\n padding: 16px 12px; background: var(--sunken); border-right: 1px solid var(--line);\n}\n.cf-diagram-nav-head { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: var(--ink); }\n.cf-diagram-nav-file {\n margin: 14px 0 4px; font: 600 12px var(--mono); color: var(--ink-soft);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.cf-diagram-nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }\n.cf-diagram-nav button {\n display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%;\n padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--ink); text-align: left; cursor: pointer;\n}\n.cf-diagram-nav button:hover { background: var(--panel); }\n.cf-diagram-nav button[aria-current] { background: var(--panel); box-shadow: inset 3px 0 0 var(--cursor); }\n.cf-diagram-nav-name { max-width: 100%; font: 600 12px/1.35 var(--mono); overflow-wrap: anywhere; }\n.cf-diagram-nav-count { max-width: 100%; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n@media (max-width: 760px) {\n .cf.cf-diagram-open { padding-left: 12px; padding-top: 12px; }\n .cf-diagram-nav { position: static; width: auto; max-height: 30vh; margin: 0 0 8px; border: 1px solid var(--line); border-radius: 6px; }\n}\n.cf-ready .cf-svg-wrap:focus-visible { outline: 2px solid var(--cursor); outline-offset: 2px; }\n.cf-ready .cf-svg-wrap.cf-dragging { cursor: grabbing; user-select: none; }\n.cf-ready .cf-svg { width: 100%; height: 100%; }\n.cf-graph-frame + .cf-graph-frame { margin-top: 14px; }\n.cf-camera-tools { align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }\n.cf-camera-tools button { min-height: 28px; padding: 0 10px; font-size: 12px; }\n.cf-scale { min-width: 4ch; text-align: center; font: 11px var(--mono); color: var(--ink-soft); }\n.cf-omitted { display: none; }\n.cf-ready .cf-omitted { display: block; }\n.cf-node.cf-selected > .cf-row, .cf-node.cf-selected > .cf-fold > .cf-row,\n.cf-chip.cf-selected { outline: 2px solid var(--cursor); outline-offset: 1px; border-radius: 5px; }\n.cf-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode rect { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode:focus-visible { outline: none; }\n.cf-gzoom circle { fill: var(--panel); stroke: var(--line-strong); }\n.cf-gzoom text { fill: var(--ink); font: 14px var(--mono); }\n.cf-gzoom:hover circle, .cf-gzoom:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-gzoom:focus-visible { outline: none; }\n.cf-glabel { font-family: var(--mono); font-size: 12px; fill: var(--ink); }\n.cf-gloc { font-family: var(--mono); font-size: 10px; fill: var(--ink-soft); }\n.cf-gnode.cf-st-same rect { stroke: var(--line); fill: transparent; }\n.cf-gnode.cf-st-same .cf-glabel { fill: var(--ink-soft); }\n.cf-gnode:not(.cf-st-same) rect { stroke: var(--cf-node-color); fill: var(--panel); }\n.cf-gnode:not(.cf-st-same) .cf-glabel { fill: var(--cf-node-color); font-weight: 600; }\n.cf-gnode:hover rect, .cf-gnode:focus-visible rect { stroke: var(--cursor); stroke-width: 2.5; }\n.cf-gnode.cf-selected rect { stroke: var(--cursor); stroke-width: 3; }\n.cf-paths { display: flex; flex-direction: column; gap: 6px; list-style: none; margin: 0; padding: 0; }\n.cf-path {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px 6px;\n padding: 6px 8px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n padding: 2px 8px 2px 4px;\n border: 1px solid var(--line);\n border-left: 3px solid var(--cf-node-color);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n}\n.cf-chip:hover, .cf-chip:focus-within { border-color: var(--cursor); }\n.cf-chip-zoom {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n}\n.cf-chip-label { min-width: 0; max-width: 36ch; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip-loc { min-width: 0; max-width: 28ch; font-size: 10.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip.cf-st-same .cf-chip-label { color: var(--ink-soft); }\n.cf-chip:not(.cf-st-same) .cf-chip-label { color: var(--cf-node-color); font-weight: 600; }\n.cf-chip .cf-desc { max-width: 28ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-arrow { color: var(--ink-soft); font-size: 12px; }\n/* One description line per call, only when the backend attached one. */\n.cf-desc { font-size: 11.5px; color: var(--ink-soft); font-style: italic; overflow-wrap: anywhere; }\n.cf-business { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 10px; }\n.cf-business-note { font-size: 13px; color: var(--ink-soft); }\n.cf-plumbing-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--ink-soft); cursor: pointer; width: fit-content; }\n.cf:has(.cf-show-plumbing:not(:checked)) .cf-node.cf-plumbing { display: none; }\n.cf-purpose { font-family: var(--sans); font-size: 13.5px; font-weight: 550; color: var(--ink); overflow-wrap: anywhere; }\n.cf-row > .cf-purpose { flex: 1 0 100%; order: -1; padding-left: 26px; }\n.cf-fold > .cf-row > .cf-purpose { padding-left: 16px; }\n.cf-node.cf-explained > .cf-row .cf-label, .cf-node.cf-explained > .cf-fold > .cf-row .cf-label { font-size: 11.5px; }\n.cf-chip .cf-purpose { font-size: 12.5px; max-width: 34ch; }\n.cf-gpurpose { font-family: var(--sans); font-size: 12px; font-weight: 600; fill: var(--ink); }\n.cf-diagram-nav-name.cf-diagram-nav-purpose { font-family: var(--sans); white-space: normal; }\n.cf-row > .cf-desc { flex: 1 0 100%; order: 5; padding-left: 26px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-row > .cf-src-link { order: 4; }\n.cf-depth { align-items: center; gap: 4px; }\n.cf-depth-label { font-size: 12px; color: var(--ink-soft); }\n.cf-depth-btn { padding: 3px 9px; font-size: 12px; }\n.cf-depth-btn[aria-pressed=\"true\"] { border-color: var(--cursor); color: var(--cursor); font-weight: 600; }\n/* Numbered, clickable call order on the graph edges. */\n.cf-edge-num circle { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-edge-num text { font-family: var(--mono); font-size: 10.5px; fill: var(--ink-soft); }\n.cf-edge-num:hover circle, .cf-edge-num:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-edge-num:hover text, .cf-edge-num:focus-visible text { fill: var(--cursor); font-weight: 700; }\n.cf-edge-num:focus-visible { outline: none; }\n.cf-gdesc { font-family: var(--mono); font-size: 10px; font-style: italic; fill: var(--ink-soft); }\n/* Resolved definition of one call: a native disclosure without the script, and\n the body the script clones into its single shared panel. */\n.cf-source { margin: 3px 0 3px 24px; }\n.cf-ready .cf-source { display: none; }\n.cf-src-sum { width: fit-content; cursor: pointer; list-style: none; font-size: 11.5px; color: var(--ink-soft); }\n.cf-src-sum::-webkit-details-marker { display: none; }\n.cf-src-sum::before { content: \"\u25B8\"; margin-right: 6px; font-size: 10px; transition: transform 0.15s ease; display: inline-block; }\n.cf-source[open] > .cf-src-sum::before { transform: rotate(90deg); }\n.cf-src-sum:hover, .cf-src-sum:focus-visible { color: var(--cursor); }\n.cf-src-body { padding: 6px 0 4px; }\n.cf-src-ref { margin: 0 0 5px; font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-code {\n margin: 0;\n padding: 6px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.5;\n overflow-x: auto;\n}\n.cf-src-line { display: block; white-space: pre; }\n.cf-src-no {\n display: inline-block;\n width: 4ch;\n margin-right: 10px;\n text-align: right;\n color: var(--ink-soft);\n user-select: none;\n}\n.cf-src-note { margin: 0; font-size: 11.5px; color: var(--ink-soft); font-style: italic; }\n.cf-src-panel {\n padding: 8px 12px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-src-panel-sum { width: fit-content; cursor: pointer; font-size: 12.5px; font-weight: 600; }\n.cf-src-panel-body { padding-top: 6px; }\n.cf-src-panel-body { max-height: 40vh; overflow: auto; }\n.cf-src-head-line { margin: 0 0 6px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-missing { margin: 0; }\n.cf-src-link {\n flex: 0 0 auto;\n font-size: 11px;\n color: var(--ink-soft);\n text-decoration: none;\n white-space: nowrap;\n padding: 0 6px;\n border: 1px solid var(--line);\n border-radius: 999px;\n}\n.cf-src-link:hover { text-decoration: none; }\n.cf-src-link:hover, .cf-src-link:focus-visible { color: var(--cursor); }\n@media (max-width: 680px) {\n .cf-jump ul { max-height: 38vh; }\n .cf-jump-link { width: 100%; }\n .cf-jump-path { max-width: none; flex: 1 1 auto; }\n .cf-file-body { padding: 0 11px 12px; }\n .cf-crumb { max-width: 24ch; }\n .cf-crumb-file { max-width: 12ch; }\n .cf-source { margin-left: 10px; }\n .cf-src-no { width: 3ch; margin-right: 7px; }\n .cf-chip, .cf-chip-zoom { min-width: 0; flex-wrap: wrap; }\n .cf-chip-loc { overflow-wrap: anywhere; }\n}\n";
26
28
  /**
27
29
  * Progressive enhancement for the rendered call flow. Nothing here is
@@ -24,7 +24,8 @@ import { nodeFunctionId, purposesOf, wrapPurpose } from "./explanation.js";
24
24
  * reaches the reviewer as a glyph plus color for a call that was added,
25
25
  * removed, or contains a change, and plain dimmed text for an unchanged call.
26
26
  */
27
- export function renderCallFlows(report) {
27
+ export function renderCallFlows(report, options = {}) {
28
+ const treeOnly = options.treeOnly === true;
28
29
  const purposes = purposesOf(report);
29
30
  const files = collectFiles(report.callFlows, report.items, purposes);
30
31
  if (files.length === 0) {
@@ -35,7 +36,7 @@ export function renderCallFlows(report) {
35
36
  '<div class="cf">',
36
37
  renderSummary(files, new Set(report.items.map(item => item.file)).size),
37
38
  renderPlumbingToggle(plumbing, report.agentExplanation?.explainedBy),
38
- renderControls(),
39
+ treeOnly ? "" : renderControls(),
39
40
  renderJump(files),
40
41
  '<nav class="cf-crumbs" id="cf-crumbs" aria-label="Visited call trail" hidden></nav>',
41
42
  // One panel the script fills with the focused call's definition; without the
@@ -45,7 +46,7 @@ export function renderCallFlows(report) {
45
46
  '<div class="cf-src-panel-body" id="cf-src-panel-body"></div>',
46
47
  "</details>",
47
48
  '<div class="cf-files">',
48
- files.map((view, index) => renderFile(view, index + 1)).join("\n"),
49
+ files.map((view, index) => renderFile(view, index + 1, treeOnly)).join("\n"),
49
50
  "</div>",
50
51
  "</div>",
51
52
  ].join("\n");
@@ -267,7 +268,7 @@ function renderJump(files) {
267
268
  ].join("\n");
268
269
  }
269
270
  /** One file section: severity, path, call count and its diff link. */
270
- function renderFile(view, at) {
271
+ function renderFile(view, at, treeOnly) {
271
272
  const { dot, tone, count } = fileFacts(view);
272
273
  const status = view.status;
273
274
  const diff = `<a class="cf-diff-link" data-open-hunk href="#item-${view.rank}">View diff #${view.rank}</a>`;
@@ -284,8 +285,8 @@ function renderFile(view, at) {
284
285
  view.truncated
285
286
  ? `<p class="cf-bounds">Bounds reached for this file: ${escapeHtml(BOUNDS_TEXT)}. Calls cut at a bound are omitted.</p>`
286
287
  : "",
287
- modesNote(),
288
- renderModes(view, at),
288
+ treeOnly ? "" : modesNote(),
289
+ treeOnly ? renderTreeMode(view, at, false) : renderModes(view, at),
289
290
  "</div>",
290
291
  "</details>",
291
292
  ]
@@ -303,7 +304,7 @@ function renderFile(view, at) {
303
304
  */
304
305
  function renderModes(view, at) {
305
306
  return [
306
- renderTreeMode(view, at),
307
+ renderTreeMode(view, at, true),
307
308
  lazyMode("graph", at, renderGraphMode(view, at)),
308
309
  lazyMode("sequence", at, renderSequenceMode(view, at)),
309
310
  ].join("\n");
@@ -321,10 +322,10 @@ function modesNote() {
321
322
  `resolved definition opens on its own. The diff holds every changed line.` +
322
323
  `</p></noscript>`);
323
324
  }
324
- function modeSection(mode, at, body) {
325
+ function modeSection(mode, at, body, headed = true) {
325
326
  return [
326
327
  `<section class="cf-mode cf-mode-${mode}" id="cf-f${at}-${mode}" data-cf-mode-body="${mode}">`,
327
- `<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3>`,
328
+ headed ? `<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3>` : "",
328
329
  body,
329
330
  "</section>",
330
331
  ].join("\n");
@@ -333,11 +334,11 @@ function modeSection(mode, at, body) {
333
334
  function lazyMode(mode, at, section) {
334
335
  return `<template data-cf-lazy-mode="${mode}" data-cf-file="${at}">${section}</template>`;
335
336
  }
336
- function renderTreeMode(view, at) {
337
+ function renderTreeMode(view, at, headed) {
337
338
  const trees = view.trees
338
339
  .map((tree, root) => renderTreeNode(tree, [root], at, view.file, view.purposes))
339
340
  .join("\n");
340
- return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`);
341
+ return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`, headed);
341
342
  }
342
343
  /** The resolved definition of one call. It is a native disclosure, so the actual
343
344
  * source is readable without the script, and the script clones this body into
@@ -1843,7 +1843,8 @@ function script(csrf) {
1843
1843
  for (var i = 0; i < list.length; i += 1) {
1844
1844
  var s = list[i];
1845
1845
  var shaped = s && typeof s === 'object' && typeof s.path === 'string' && typeof s.line === 'number'
1846
- && (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== '';
1846
+ && (s.side === 'LEFT' || s.side === 'RIGHT') && typeof s.body === 'string' && s.body !== ''
1847
+ && SEVERITY_LABELS[s.severity] !== undefined;
1847
1848
  if (!shaped || !currentLine(s) || settled[suggestionKey(current.snapshotId, s)]) continue;
1848
1849
  out.push(s);
1849
1850
  }
@@ -1910,12 +1911,17 @@ function script(csrf) {
1910
1911
  render();
1911
1912
  }
1912
1913
 
1914
+ var SEVERITY_LABELS = { critical: 'Critical', major: 'Major', minor: 'Minor' };
1915
+
1913
1916
  function suggestionRow(suggestion, disabled) {
1914
1917
  var wrap = make('div', 'suggestion');
1915
1918
  var head = make('p', 'suggestion-by');
1916
1919
  head.appendChild(make('span', 'agent-dot', ''));
1917
1920
  head.appendChild(make('strong', '', suggestedBy()));
1918
1921
  head.appendChild(document.createTextNode(' suggests'));
1922
+ var severity = make('span', 'severity severity-' + suggestion.severity, SEVERITY_LABELS[suggestion.severity]);
1923
+ severity.title = 'How much the agent thinks this matters';
1924
+ head.appendChild(severity);
1919
1925
  wrap.appendChild(head);
1920
1926
  wrap.appendChild(make('p', 'suggestion-body', suggestion.body));
1921
1927
  var actions = make('div', 'suggestion-actions');
@@ -3071,8 +3077,14 @@ kbd {
3071
3077
  .editor { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent-soft); }
3072
3078
  .editor-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
3073
3079
  .editor-input { flex: 1 1 240px; min-width: 0; }
3074
- .suggestion-by { font-size: 12px; color: var(--ink-soft); }
3080
+ .suggestion-by { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: var(--ink-soft); }
3075
3081
  .suggestion-by strong { color: var(--ink); font-weight: 600; }
3082
+ .severity {
3083
+ margin-left: 6px; padding: 1px 7px; border-radius: 4px; font-size: 11.5px; font-weight: 600; letter-spacing: 0.02em;
3084
+ color: var(--ink-soft); background: var(--neutral-soft);
3085
+ }
3086
+ .severity-critical { color: var(--alarm); background: var(--alarm-bg); }
3087
+ .severity-major { color: var(--warn); background: var(--warn-soft); }
3076
3088
  .suggestion-body { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
3077
3089
  .suggestion-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
3078
3090
 
@@ -35,7 +35,8 @@ export declare function renderBusinessPage(report: ReviewReport): string;
35
35
  /**
36
36
  * The call flows of one review as a page of their own, for the connected pull
37
37
  * request page to show beside its diff: every changed file with call flows, or
38
- * only `file` when given. Tree, Graph and Sequence work as in the report; links
39
- * into the report's diff are dropped, since the page showing this has its own.
38
+ * only `file` when given. Only the Tree view is offered, and the business view
39
+ * stays out of it: the page around it already shows that view. Links into the
40
+ * report's diff are dropped, since the page showing this has its own.
40
41
  */
41
42
  export declare function renderCallFlowPage(report: ReviewReport, file?: string): string;
@@ -110,7 +110,6 @@ const BUSINESS_VIEW_STYLES = `
110
110
  .business { padding-top: 14px; }
111
111
  .business-title { font-size: 20px; }
112
112
  .business-lede { margin-top: 4px; color: var(--ink-soft); font-size: 14px; max-width: 90ch; }
113
- .flow-section-title { margin: 22px 0 0; font-size: 18px; }
114
113
  `;
115
114
  /**
116
115
  * Posts the document's height to the page that frames it, so the pull request
@@ -122,7 +121,9 @@ const BUSINESS_FRAME_SCRIPT = `
122
121
  'use strict';
123
122
  function post() {
124
123
  if (window.parent === window) return;
125
- window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.scrollHeight) }, window.location.origin);
124
+ // The html element's own box, not scrollHeight: scrollHeight never drops below the frame's
125
+ // height, so the frame could grow but never shrink back around its content.
126
+ window.parent.postMessage({ type: 'diffninja-business-height', height: Math.ceil(document.documentElement.getBoundingClientRect().height) }, window.location.origin);
126
127
  }
127
128
  window.addEventListener('load', post);
128
129
  if (typeof ResizeObserver !== 'undefined') new ResizeObserver(post).observe(document.body);
@@ -173,8 +174,9 @@ body { background: var(--bg); font-family: var(--sans); }
173
174
  /**
174
175
  * The call flows of one review as a page of their own, for the connected pull
175
176
  * request page to show beside its diff: every changed file with call flows, or
176
- * only `file` when given. Tree, Graph and Sequence work as in the report; links
177
- * into the report's diff are dropped, since the page showing this has its own.
177
+ * only `file` when given. Only the Tree view is offered, and the business view
178
+ * stays out of it: the page around it already shows that view. Links into the
179
+ * report's diff are dropped, since the page showing this has its own.
178
180
  */
179
181
  export function renderCallFlowPage(report, file) {
180
182
  const scoped = file === undefined ? report : { ...report, callFlows: report.callFlows.filter((entry) => entry.file === file) };
@@ -186,20 +188,11 @@ export function renderCallFlowPage(report, file) {
186
188
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
187
189
  '<meta name="color-scheme" content="light dark">',
188
190
  `<title>${escapeHtml(file === undefined ? "Call flows" : `Call flow: ${file}`)}</title>`,
189
- `<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${BUSINESS_STYLES}\n${BUSINESS_VIEW_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
191
+ `<style>${STYLES}\n${CALL_FLOW_STYLES}\n${PALETTE_STYLES}\n${EMBEDDED_FLOW_STYLES}</style>`,
190
192
  "</head>",
191
193
  "<body>",
192
- report.agentExplanation === undefined
193
- ? ""
194
- : [
195
- '<section class="flow-embed business" aria-labelledby="flow-business-title">',
196
- `<h2 class="business-title" id="flow-business-title">${file === undefined ? "How it works" : "How this file fits the process"}</h2>`,
197
- renderBusinessView(report, { file, glossary: false }),
198
- "</section>",
199
- ].join("\n"),
200
194
  `<section id="view-call-flow" class="flow-embed${file === undefined ? "" : " flow-single"}" aria-label="Call flow">`,
201
- report.agentExplanation === undefined ? "" : '<h2 class="flow-section-title">Call flow</h2>',
202
- renderCallFlows(scoped),
195
+ renderCallFlows(scoped, { treeOnly: true }),
203
196
  "</section>",
204
197
  `<script>document.documentElement.classList.add('js');\n${CALL_FLOW_SCRIPT}</script>`,
205
198
  "</body>",
@@ -9,7 +9,9 @@ import { detectPullRequest } from "./pr-input.js";
9
9
  import { renderBusinessPage, renderCallFlowPage, renderReview } from "./html.js";
10
10
  import { MAX_SUGGESTED_COMMENTS, ReportPages } from "./report-pages.js";
11
11
  import { reviewDiff } from "./service.js";
12
+ import { COMMENT_SEVERITIES } from "./types.js";
12
13
  import { MAX_BRANCH_CHARS, MAX_DETAIL_CHARS, MAX_EXPLAINED_FUNCTIONS, MAX_PROCESSES, MAX_PROCESS_STEPS, MAX_PURPOSE_CHARS, MAX_RULES, MAX_RULE_CHARS, MAX_STEP_CHARS, MAX_STEP_EXITS, MAX_TITLE_CHARS, MIN_PROCESS_STEPS, } from "./explanation.js";
14
+ import { packageVersion } from "./version.js";
13
15
  const PR_LINK_ERROR = "A pull request review needs exactly one full github.com pull request URL, for example https://github.com/OWNER/REPO/pull/123. Ask the user for their link; do not guess, search, or invent one.";
14
16
  const STATIC_MODE_ERROR = "mode static reviews a diff or git range and accepts no pr or input. Use mode connected to review a pull request link.";
15
17
  /**
@@ -220,7 +222,7 @@ class ReviewServer extends McpServer {
220
222
  sessions;
221
223
  reports;
222
224
  constructor(sessions, reports) {
223
- super({ name: "diffninja", version: "0.1.0" });
225
+ super({ name: "diffninja", version: packageVersion() });
224
226
  this.sessions = sessions;
225
227
  this.reports = reports;
226
228
  this.server.onclose = () => {
@@ -244,8 +246,9 @@ const commentSchema = z.object({
244
246
  line: z.number().int().positive().describe("The line number on that side."),
245
247
  side: z.enum(["LEFT", "RIGHT"]).describe("RIGHT for an added or context line (new side), LEFT for a removed line (old side)."),
246
248
  body: z.string().max(1000).describe("The comment, as the reviewer would write it: one short line, no labels or formatting."),
249
+ severity: z.enum(COMMENT_SEVERITIES).describe("How much it matters, so the human can triage: critical for a bug, data loss, security or contract break that should block the merge; major for a real risk or missing case worth fixing before merge; minor for a nit, naming or style point. Give it here, never inside the body."),
247
250
  }).strict();
248
- const COMMENT_RULES = "Only comment where a maintainer would actually ask for something or point something out: a bug, a risk, a missing case, a confusing name, a missing test; never pad. Write each one as the reviewer would type it on GitHub, in their own voice: short (one line, at most 280 characters), concrete, conversational, e.g. \"This drops the error from Close(); should we return it?\" or \"nit: could this reuse parseVersion?\". No report scaffolding: no headings, bold, list markers, numbering, or labels such as Finding, Issue, Attention, Error, Severity. Each names a line of the diff: path, line, and side RIGHT for an added or context line, LEFT for a removed line; at most one per line and 30 in all.";
251
+ const COMMENT_RULES = "Only comment where a maintainer would actually ask for something or point something out: a bug, a risk, a missing case, a confusing name, a missing test; never pad. Write each one as the reviewer would type it on GitHub, in their own voice: short (one line, at most 280 characters), concrete, conversational, e.g. \"This drops the error from Close(); should we return it?\" or \"nit: could this reuse parseVersion?\". No report scaffolding: no headings, bold, list markers, numbering, or labels such as Finding, Issue, Attention, Error, Severity. Each names a line of the diff: path, line, and side RIGHT for an added or context line, LEFT for a removed line; at most one per line and 30 in all. Each also carries a severity (critical, major or minor) in its own field, which the page shows next to the comment so the human reviews the important ones first; the words critical, major and minor do not belong in the body.";
249
252
  /**
250
253
  * What the goal summary is for. It is the agent's own paragraph for the human
251
254
  * reading the pull request, written from the author's own title and
@@ -461,12 +464,7 @@ export function createReviewServer() {
461
464
  description: "Update the line comments suggested for a pull request review after finish_review, or before it. " + COMMENT_RULES + " The whole call is refused, and the previous suggestions kept, if any comment breaks these rules. A later call replaces the earlier suggestions; an empty list clears them. Nothing is posted: the page shows each suggestion under its line, attributed to this MCP client, and the human adds it to their own review, edits it, or dismisses it. This returns no page link: only finish_review does.",
462
465
  inputSchema: z.object({
463
466
  reviewId: z.string().regex(/^[a-f0-9]{32}$/).describe("The reviewId a review_diff result returned on this connection."),
464
- comments: z.array(z.object({
465
- path: z.string().min(1).max(1024).describe("The file's path in the diff."),
466
- line: z.number().int().positive().describe("The line number on that side."),
467
- side: z.enum(["LEFT", "RIGHT"]).describe("RIGHT for an added or context line (new side), LEFT for a removed line (old side)."),
468
- body: z.string().max(1000).describe("The comment, as the reviewer would write it: one short line, no labels or formatting."),
469
- }).strict()).max(MAX_SUGGESTED_COMMENTS),
467
+ comments: z.array(commentSchema).max(MAX_SUGGESTED_COMMENTS),
470
468
  }).strict(),
471
469
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
472
470
  }, async ({ reviewId, comments }) => {
@@ -22,8 +22,6 @@ import type { ReviewReport } from "./types.js";
22
22
  export interface BusinessViewOptions {
23
23
  /** Link for a hunk id, or undefined when this page has no diff to link to. */
24
24
  readonly hunkHref?: (itemId: string) => string | undefined;
25
- /** Draw only the processes and rules that touch this changed file. */
26
- readonly file?: string;
27
25
  /** Leave out the function glossary (the call-flow view shows the purposes). */
28
26
  readonly glossary?: boolean;
29
27
  /** Leave out the attribution line when the page around it already says who explained it. */
@@ -32,9 +30,7 @@ export interface BusinessViewOptions {
32
30
  readonly stepsOpen?: boolean;
33
31
  }
34
32
  /**
35
- * The whole business view, or a note saying why there is none. `options.file`
36
- * scopes it to one changed file: the processes whose steps name a hunk or a
37
- * function of that file, and the rules that name one of its hunks.
33
+ * The whole business view, or a note saying why there is none.
38
34
  */
39
35
  export declare function renderBusinessView(report: ReviewReport, options?: BusinessViewOptions): string;
40
36
  interface StepBox {
@@ -49,9 +49,7 @@ const LANE_GAP = 18;
49
49
  const CHART_PAD = 14;
50
50
  const DECISION_INSET = 16;
51
51
  /**
52
- * The whole business view, or a note saying why there is none. `options.file`
53
- * scopes it to one changed file: the processes whose steps name a hunk or a
54
- * function of that file, and the rules that name one of its hunks.
52
+ * The whole business view, or a note saying why there is none.
55
53
  */
56
54
  export function renderBusinessView(report, options = {}) {
57
55
  const explanation = report.agentExplanation;
@@ -62,11 +60,7 @@ export function renderBusinessView(report, options = {}) {
62
60
  const purposes = purposesOf(report);
63
61
  const ranks = new Map(report.items.map((item, index) => [item.id, index + 1]));
64
62
  const itemFiles = new Map(report.items.map((item) => [item.id, item.file]));
65
- const touches = (hunks, fns) => options.file === undefined ||
66
- (hunks ?? []).some((id) => itemFiles.get(id) === options.file) ||
67
- (fns ?? []).some((id) => functions.get(id)?.file === options.file);
68
- const processes = explanation.processes.filter((process) => process.steps.some((step) => touches(step.hunks, step.functions)));
69
- const rules = explanation.rules.filter((rule) => touches(rule.hunks, undefined));
63
+ const { processes, rules } = explanation;
70
64
  const context = { functions, purposes, ranks, itemFiles, hunkHref: options.hunkHref, stepsOpen: options.stepsOpen !== false };
71
65
  const parts = [
72
66
  '<div class="bp">',
@@ -77,13 +71,10 @@ export function renderBusinessView(report, options = {}) {
77
71
  renderLegend(),
78
72
  "</div>",
79
73
  ].filter((part) => part !== "");
80
- if (processes.length === 0 && rules.length === 0) {
81
- parts.push(`<p class="bp-note">No process or rule in the explanation names this file's hunks or functions.</p>`);
82
- }
83
74
  processes.forEach((process, index) => parts.push(renderProcess(process, index + 1, context)));
84
75
  if (rules.length > 0)
85
76
  parts.push(renderRules(rules, context));
86
- if (options.glossary !== false && options.file === undefined)
77
+ if (options.glossary !== false)
87
78
  parts.push(renderGlossary(report.functions ?? [], purposes));
88
79
  parts.push("</div>");
89
80
  return parts.join("\n");
@@ -117,7 +117,7 @@ function checkComments(report, comments) {
117
117
  }
118
118
  function applyComments(report, comments, suggestedBy) {
119
119
  report.agentComments = {
120
- comments: comments.map(({ path, line, side, body }) => ({ path, line, side, body: body.trim() })),
120
+ comments: comments.map(({ path, line, side, body, severity }) => ({ path, line, side, body: body.trim(), severity })),
121
121
  suggestedBy,
122
122
  suggestedAt: new Date().toISOString(),
123
123
  };
@@ -3,13 +3,15 @@
3
3
  * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
4
  *
5
5
  * The setup installs the package globally first so the registration points
6
- * at a permanent binary instead of the npx cache. When the global install is
7
- * unavailable it falls back to an npx-based entry and says so. The server
6
+ * at a permanent binary instead of the npx cache, and brings a global install
7
+ * older than itself up to its own version, so running the newest setup is how
8
+ * a user updates. When the global install is unavailable it falls back to an
9
+ * npx-based entry pinned to its own version and says so. The server
8
10
  * needs no key or environment: reviews are local, and connected reviews reuse
9
11
  * the `gh` session.
10
12
  */
11
13
  import { type Stats } from "node:fs";
12
- export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja`) so the registration keeps working; when that\ninstall fails it registers an npx-based entry instead and says so.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip the global install and register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
14
+ export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja@<this version>`) so the registration keeps\nworking, and updates a global install older than this setup, so running\n`npx -y diffninja@latest setup` again is how you update. When that install\nfails it registers an npx-based entry pinned to this version and says so.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip installing or updating the global package; without one,\n register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
13
15
  export declare const CLI_NAMES: readonly ["claude", "codex", "omp", "pi"];
14
16
  export type CliName = (typeof CLI_NAMES)[number];
15
17
  export interface McpEntry {
@@ -29,7 +31,8 @@ export interface SetupOptions {
29
31
  }
30
32
  export interface Npm {
31
33
  rootG(): Promise<string>;
32
- installG(): Promise<boolean>;
34
+ /** `npm install -g <spec>`, e.g. `diffninja@0.3.1`; true on success. */
35
+ installG(spec: string): Promise<boolean>;
33
36
  }
34
37
  export interface CliReport {
35
38
  cli: CliName;
@@ -44,9 +47,16 @@ export interface SetupReport {
44
47
  }
45
48
  export interface SetupDeps {
46
49
  npm?: Npm;
50
+ /** Version this setup installs and pins; defaults to the running package's. */
51
+ version?: string;
47
52
  }
48
- /** Entry that runs the published package on demand, without an install. */
49
- export declare function npxEntry(platform?: NodeJS.Platform): McpEntry;
53
+ /**
54
+ * Entry that runs the published package on demand, without an install. It
55
+ * names this setup's version: an unversioned package lets npx keep running
56
+ * whichever copy it cached first, while a pinned one changes with each newer
57
+ * setup, which rewrites the entry.
58
+ */
59
+ export declare function npxEntry(platform?: NodeJS.Platform, version?: string): McpEntry;
50
60
  /**
51
61
  * Entry for a command that Windows ships only as a `.cmd` shim, which a client
52
62
  * that spawns without a shell cannot launch. Running npm's JS entry point with
@@ -60,6 +70,14 @@ export declare function windowsCliEntry(name: string, args: readonly string[], c
60
70
  /** Entry that runs the globally installed package. `globalRoot` is `npm root -g`. */
61
71
  export declare function globalEntry(globalRoot: string): McpEntry;
62
72
  export declare function globalEntryExists(globalRoot: string): boolean;
73
+ /** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
74
+ export declare function globalVersion(globalRoot: string): string | undefined;
75
+ /**
76
+ * Whether a global install at `installed` should be brought up to `version`.
77
+ * A manifest that cannot be read or compared counts as older, so a broken
78
+ * install is repaired; a newer one is never downgraded.
79
+ */
80
+ export declare function globalIsOlder(installed: string | undefined, version: string): boolean;
63
81
  /**
64
82
  * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
65
83
  * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
@@ -3,37 +3,43 @@
3
3
  * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
4
  *
5
5
  * The setup installs the package globally first so the registration points
6
- * at a permanent binary instead of the npx cache. When the global install is
7
- * unavailable it falls back to an npx-based entry and says so. The server
6
+ * at a permanent binary instead of the npx cache, and brings a global install
7
+ * older than itself up to its own version, so running the newest setup is how
8
+ * a user updates. When the global install is unavailable it falls back to an
9
+ * npx-based entry pinned to its own version and says so. The server
8
10
  * needs no key or environment: reviews are local, and connected reviews reuse
9
11
  * the `gh` session.
10
12
  */
11
13
  import { spawn } from "node:child_process";
12
14
  import { randomBytes } from "node:crypto";
13
15
  import { once } from "node:events";
14
- import { existsSync } from "node:fs";
16
+ import { existsSync, readFileSync } from "node:fs";
15
17
  import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat } from "node:fs/promises";
16
18
  import { homedir } from "node:os";
17
19
  import { basename, delimiter, dirname, join, resolve } from "node:path";
18
20
  import { z } from "zod";
19
21
  import { npmCliPath, npmSpawnSpec } from "../languages/grammars.js";
20
22
  import { removeTomlTable, upsertTomlTable } from "./toml.js";
23
+ import { compareVersions, packageVersion } from "./version.js";
21
24
  export const setupHelp = `diffninja setup. Register the diffninja MCP server on every detected agent CLI.
22
25
 
23
- npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]
26
+ npx -y diffninja@latest setup [--cli claude,codex,omp,pi] [--dry-run]
24
27
  diffninja setup --uninstall [--cli codex]
25
28
 
26
29
  Detects Claude Code, Codex, OMP, and pi from their config files or binaries
27
30
  and registers the diffninja MCP server in each user config, pointing at the
28
31
  globally installed package. Installs the package globally first
29
- (\`npm install -g diffninja\`) so the registration keeps working; when that
30
- install fails it registers an npx-based entry instead and says so.
32
+ (\`npm install -g diffninja@<this version>\`) so the registration keeps
33
+ working, and updates a global install older than this setup, so running
34
+ \`npx -y diffninja@latest setup\` again is how you update. When that install
35
+ fails it registers an npx-based entry pinned to this version and says so.
31
36
 
32
37
  Options:
33
38
  --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.
34
39
  --uninstall Remove the diffninja server from every detected CLI.
35
40
  --dry-run Show what would change, without installing or writing.
36
- --no-install Skip the global install and register npx-based entries.
41
+ --no-install Skip installing or updating the global package; without one,
42
+ register npx-based entries.
37
43
  --help Show this help.
38
44
 
39
45
  The server needs no API key: static reviews run locally, and pull request
@@ -42,9 +48,14 @@ reviews reuse your authenticated gh session.
42
48
  export const CLI_NAMES = ["claude", "codex", "omp", "pi"];
43
49
  const jsonValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(jsonValueSchema), z.record(z.string(), jsonValueSchema)]));
44
50
  const jsonObjectSchema = z.object({}).catchall(jsonValueSchema);
45
- /** Entry that runs the published package on demand, without an install. */
46
- export function npxEntry(platform = process.platform) {
47
- const args = ["-y", "-p", "diffninja", "diffninja-mcp"];
51
+ /**
52
+ * Entry that runs the published package on demand, without an install. It
53
+ * names this setup's version: an unversioned package lets npx keep running
54
+ * whichever copy it cached first, while a pinned one changes with each newer
55
+ * setup, which rewrites the entry.
56
+ */
57
+ export function npxEntry(platform = process.platform, version = packageVersion()) {
58
+ const args = ["-y", "-p", `diffninja@${version}`, "diffninja-mcp"];
48
59
  return platform === "win32" ? windowsCliEntry("npx", args) : { command: "npx", args };
49
60
  }
50
61
  /**
@@ -70,6 +81,27 @@ export function globalEntry(globalRoot) {
70
81
  export function globalEntryExists(globalRoot) {
71
82
  return existsSync(join(globalRoot, "diffninja", "dist", "review", "mcp-cli.js"));
72
83
  }
84
+ const installedManifestSchema = z.object({ version: z.string() });
85
+ /** Version of the globally installed package, or undefined when its manifest is missing or unreadable. */
86
+ export function globalVersion(globalRoot) {
87
+ try {
88
+ const manifest = installedManifestSchema.safeParse(JSON.parse(readFileSync(join(globalRoot, "diffninja", "package.json"), "utf8")));
89
+ return manifest.success ? manifest.data.version : undefined;
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ /**
96
+ * Whether a global install at `installed` should be brought up to `version`.
97
+ * A manifest that cannot be read or compared counts as older, so a broken
98
+ * install is repaired; a newer one is never downgraded.
99
+ */
100
+ export function globalIsOlder(installed, version) {
101
+ if (installed === undefined)
102
+ return true;
103
+ return (compareVersions(installed, version) ?? -1) < 0;
104
+ }
73
105
  /**
74
106
  * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
75
107
  * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
@@ -90,8 +122,8 @@ export function createNpm(overrides = {}) {
90
122
  });
91
123
  return (await exitCode(child)) === 0 ? out.trim() : "";
92
124
  },
93
- async installG() {
94
- return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, "diffninja"], true))) === 0;
125
+ async installG(spec) {
126
+ return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, spec], true))) === 0;
95
127
  },
96
128
  };
97
129
  }
@@ -123,35 +155,70 @@ async function globalRoot(npm) {
123
155
  return undefined;
124
156
  }
125
157
  }
158
+ /** Run one global install; a throw counts as a failed install. */
159
+ async function installGlobal(npm, spec) {
160
+ try {
161
+ return await npm.installG(spec);
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
126
167
  /**
127
- * Resolve the registration entry. `--dry-run` never installs: it reports the
128
- * install it would have run and the entry that install would have produced.
168
+ * Resolve the registration entry. A global install older than `version` is
169
+ * updated to it first; one that cannot be updated keeps its entry, with a
170
+ * warning naming the command that updates it. `--dry-run` never installs: it
171
+ * reports the install it would have run and the entry that install would have
172
+ * produced.
129
173
  */
130
- async function resolveEntry(noInstall, npm, quiet, dryRun) {
174
+ async function resolveEntry(noInstall, npm, quiet, dryRun, version) {
175
+ const spec = `diffninja@${version}`;
131
176
  const root = await globalRoot(npm);
132
- if (root !== undefined && globalEntryExists(root))
177
+ if (root !== undefined && globalEntryExists(root)) {
178
+ const installed = globalVersion(root);
179
+ if (!globalIsOlder(installed, version))
180
+ return { entry: globalEntry(root), viaNpx: false };
181
+ const from = installed ?? "an unknown version";
182
+ const update = `npm install -g ${spec}`;
183
+ if (noInstall) {
184
+ if (!quiet)
185
+ console.error(`diffninja: the global install is ${from}, older than this setup (${version}); update it with ${update}.`);
186
+ }
187
+ else if (dryRun) {
188
+ if (!quiet)
189
+ console.log(`diffninja: dry run: would update the global install from ${from} to ${version} (${update}).`);
190
+ }
191
+ else {
192
+ if (!quiet)
193
+ console.log(`diffninja: updating the global install from ${from} to ${version} (${update})...`);
194
+ const updated = await installGlobal(npm, spec);
195
+ const updatedRoot = updated ? await globalRoot(npm) : undefined;
196
+ if (updatedRoot !== undefined && globalEntryExists(updatedRoot))
197
+ return { entry: globalEntry(updatedRoot), viaNpx: false };
198
+ if (!globalEntryExists(root)) {
199
+ if (!quiet)
200
+ console.error("diffninja: global update failed and left no install; registering npx-based entries instead.");
201
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
202
+ }
203
+ if (!quiet)
204
+ console.error(`diffninja: update failed; the global install is still ${globalVersion(root) ?? from}. Update it with ${update}.`);
205
+ }
133
206
  return { entry: globalEntry(root), viaNpx: false };
207
+ }
134
208
  if (dryRun) {
135
209
  if (!noInstall && root !== undefined) {
136
210
  if (!quiet)
137
- console.log("diffninja: dry run: would install the package globally (npm install -g diffninja).");
211
+ console.log(`diffninja: dry run: would install the package globally (npm install -g ${spec}).`);
138
212
  return { entry: globalEntry(root), viaNpx: false };
139
213
  }
140
214
  if (!quiet)
141
215
  console.error("diffninja: dry run: global install unavailable; would register npx-based entries instead.");
142
- return { entry: npxEntry(), viaNpx: true };
216
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
143
217
  }
144
218
  if (!noInstall) {
145
219
  if (!quiet)
146
- console.log("diffninja: installing the package globally (npm install -g diffninja)...");
147
- let installed = false;
148
- try {
149
- installed = await npm.installG();
150
- }
151
- catch {
152
- installed = false;
153
- }
154
- if (installed) {
220
+ console.log(`diffninja: installing the package globally (npm install -g ${spec})...`);
221
+ if (await installGlobal(npm, spec)) {
155
222
  const installedRoot = await globalRoot(npm);
156
223
  if (installedRoot !== undefined && globalEntryExists(installedRoot)) {
157
224
  return { entry: globalEntry(installedRoot), viaNpx: false };
@@ -160,7 +227,7 @@ async function resolveEntry(noInstall, npm, quiet, dryRun) {
160
227
  }
161
228
  if (!quiet)
162
229
  console.error("diffninja: global install unavailable; registering npx-based entries instead.");
163
- return { entry: npxEntry(), viaNpx: true };
230
+ return { entry: npxEntry(process.platform, version), viaNpx: true };
164
231
  }
165
232
  function findOnPath(name, pathDirs) {
166
233
  const candidates = process.platform === "win32" ? [name, `${name}.cmd`, `${name}.exe`] : [name];
@@ -486,10 +553,11 @@ export async function runSetup(options = {}, deps = {}) {
486
553
  const quiet = options.quiet === true;
487
554
  const uninstall = options.uninstall === true;
488
555
  const dryRun = options.dryRun === true;
556
+ const version = deps.version ?? packageVersion();
489
557
  let entry;
490
558
  let viaNpx = false;
491
559
  if (!uninstall) {
492
- const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun);
560
+ const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun, version);
493
561
  entry = resolved.entry;
494
562
  viaNpx = resolved.viaNpx;
495
563
  }
@@ -545,5 +613,5 @@ export async function runSetup(options = {}, deps = {}) {
545
613
  console.log("note: pi needs the pi-mcp-extension for MCP support (pi install npm:pi-mcp-extension)");
546
614
  }
547
615
  }
548
- return { entry: entry ?? npxEntry(), viaNpx, clis: reports };
616
+ return { entry: entry ?? npxEntry(process.platform, version), viaNpx, clis: reports };
549
617
  }
@@ -132,12 +132,16 @@ export interface AgentOrder {
132
132
  /** diffninja's own order of the same items, kept so the page can still offer it. */
133
133
  diffninjaIds: string[];
134
134
  }
135
+ /** How much a suggested comment matters, most to least: what a reviewer triages by. */
136
+ export declare const COMMENT_SEVERITIES: readonly ["critical", "major", "minor"];
137
+ export type CommentSeverity = (typeof COMMENT_SEVERITIES)[number];
135
138
  /** One line comment the reviewing agent suggests; the human adds it to their review or not. */
136
139
  export interface SuggestedComment {
137
140
  path: string;
138
141
  line: number;
139
142
  side: "LEFT" | "RIGHT";
140
143
  body: string;
144
+ severity: CommentSeverity;
141
145
  }
142
146
  export interface AgentComments {
143
147
  comments: SuggestedComment[];
@@ -1 +1,2 @@
1
- export {};
1
+ /** How much a suggested comment matters, most to least: what a reviewer triages by. */
2
+ export const COMMENT_SEVERITIES = ["critical", "major", "minor"];
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The version of the running diffninja package, read from its own
3
+ * `package.json`. Both layouts put it two levels up: `src/review/` in a
4
+ * checkout and `dist/review/` in the published package, which always ships
5
+ * `package.json`.
6
+ */
7
+ export declare function packageVersion(): string;
8
+ /**
9
+ * Compare two `major.minor.patch` versions: negative when `left` is older,
10
+ * zero when equal, positive when newer. A prerelease sorts before its release;
11
+ * two prereleases of one release compare by their tags as text. Undefined when
12
+ * either is not a version.
13
+ */
14
+ export declare function compareVersions(left: string, right: string): number | undefined;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The version of the running diffninja package, read from its own
3
+ * `package.json`. Both layouts put it two levels up: `src/review/` in a
4
+ * checkout and `dist/review/` in the published package, which always ships
5
+ * `package.json`.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { z } from "zod";
9
+ const manifestSchema = z.object({ version: z.string() });
10
+ let cached;
11
+ export function packageVersion() {
12
+ cached ??= manifestSchema.parse(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))).version;
13
+ return cached;
14
+ }
15
+ /**
16
+ * Compare two `major.minor.patch` versions: negative when `left` is older,
17
+ * zero when equal, positive when newer. A prerelease sorts before its release;
18
+ * two prereleases of one release compare by their tags as text. Undefined when
19
+ * either is not a version.
20
+ */
21
+ export function compareVersions(left, right) {
22
+ const a = parseVersion(left);
23
+ const b = parseVersion(right);
24
+ if (a === undefined || b === undefined)
25
+ return undefined;
26
+ for (let index = 0; index < 3; index += 1) {
27
+ const difference = a.core[index] - b.core[index];
28
+ if (difference !== 0)
29
+ return difference;
30
+ }
31
+ if (a.prerelease === b.prerelease)
32
+ return 0;
33
+ if (a.prerelease === undefined)
34
+ return 1;
35
+ if (b.prerelease === undefined)
36
+ return -1;
37
+ return a.prerelease < b.prerelease ? -1 : 1;
38
+ }
39
+ function parseVersion(text) {
40
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text.trim());
41
+ if (match === null)
42
+ return undefined;
43
+ return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffninja",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Local, deterministic PR review for coding agents over MCP: call flows, change facts, and a human review workspace",
5
5
  "type": "module",
6
6
  "exports": {