prreviewbuddy 0.50.1 → 0.52.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/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.52.2
4
+
5
+ **Continue reviewing is gone from the Reviews page.** The banner opened whatever was first in your
6
+ review queue, which the Queue view already shows as its first card. The queue itself is unchanged:
7
+ the **Queue** filter lists your queued reviews in order, marking one Reviewed takes it out, and the
8
+ card menus and select mode still add and remove reviews.
9
+
10
+ ## 0.52.1
11
+
12
+ **Fetching remote updates moved to where it applies.** A fetch only refreshes reviews that follow a
13
+ remote branch, such as `origin/feature`. Pull request reviews are checked automatically, and a fetch
14
+ cannot move a local branch. So the **Fetch updates** button is gone from the Reviews toolbar, where
15
+ it read as the way the list checks for updates and sat disabled for most people. Instead:
16
+
17
+ - A card that follows a remote branch has **Fetch remote updates** in its `...` menu.
18
+ - A review that follows one has **Fetch remote updates** in its `...` menu, beside Check for
19
+ updates. Check for updates still only reads what is on your machine. After the fetch the review
20
+ checks again, so the freshness banner reflects what came in.
21
+ - In select mode, **Fetch selected** appears only when the selection includes a review that can be
22
+ fetched.
23
+
24
+ What a fetch did, such as "Fetched 1 repository", now appears as a notice in the corner.
25
+
26
+ ## 0.52.0
27
+
28
+ **Saved is now Pinned, and pinned reviews have their own section.** The Reviews page lists
29
+ **Pinned**, **Active**, **Waiting** and **Done** in that order, each under a heading with its count.
30
+ Pinning a review moves it into Pinned straight away, and unpinning moves it back. A pinned review
31
+ keeps its lifecycle: a pinned Waiting or Done review says so on its card, and a Waiting one that
32
+ changes says "Changed while waiting" there instead of jumping to Active. Pinning no longer changes
33
+ the order. Every section, Pinned included, is sorted by what last happened to each change.
34
+
35
+ The Status filter no longer has a Saved option, since the Pinned section does that job. A link to
36
+ the old Saved view opens the whole list. On a review's own page, the menu item is now **Pin review**
37
+ or **Unpin review**. Pinned reviews are still kept for ninety days.
38
+
39
+ Under the **Queue** filter the list stays in the order you queued it: a pinned review keeps its
40
+ place in the queue and shows its star. Select mode acts on reviews top to bottom as they are shown,
41
+ so **Add to review queue** queues them in that order. The star cannot be pressed again until the
42
+ last press has been saved, so quick clicks can no longer leave it showing the opposite of what was
43
+ stored.
44
+
45
+ ## 0.51.2
46
+
47
+ **The review queue counts what it shows.** When a queued branch was analysed again, the queue kept
48
+ pointing at the older review. The Queue count included it, the Queue view had no card for it,
49
+ Continue reviewing opened the older review, and the card's menu could not take it out. A new review
50
+ of a queued branch now takes that branch's place in the queue. Opening the Reviews page repairs a
51
+ queue that already has older reviews in it, and a branch queued twice keeps its earlier place.
52
+
53
+ ## 0.51.1
54
+
55
+ **The workspace fits a phone-width window.** Below about 600px the review's top bar ran off the
56
+ right edge and the whole page scrolled sideways; below about 450px the thin bar above it did too.
57
+ The review bar now wraps onto a second row (what the review is of, then its facts and controls),
58
+ and the thin bar keeps every control and the version by tightening its spacing and showing Send
59
+ feedback as an icon.
60
+
61
+ Buddy AI, opened in a narrow window, now covers exactly the review below the bars instead of
62
+ assuming how tall they are, so it no longer sits over a banner such as the freshness notice. The
63
+ review's `...` menu now opens over the Buddy AI tab rather than under it.
64
+
65
+ ## 0.51.0
66
+
67
+ **KISS recommendations read in seconds.** Each card now leads with the point: a sentence or two on
68
+ what the change adds and why the requirements do not need it, then the simpler approach, a short
69
+ line of what acting on it gains, and where to start. The full reasoning (the evidence, why the
70
+ requirements do not call for it, the trade-offs and the other files involved) is one click away
71
+ under **Evidence & trade-offs**. A trade-off that could change your decision is shown on the card
72
+ itself.
73
+
74
+ Titles now say what to do rather than naming the idea, such as "Remove key readback until hardware
75
+ supports it" instead of "Defer device-reported key support".
76
+
77
+ A recommendation is shown only when it comes with a summary, a simpler approach, the evidence and
78
+ reasoning behind it, and a file that exists in the reviewed commit. One that falls short, including
79
+ one that points only at files that do not exist, is no longer shown as a card. The review says how many were held back, both in the workspace and
80
+ in the terminal. If every recommendation came back incomplete, the review says the analysis was
81
+ incomplete rather than calling the change appropriately simple.
82
+
83
+ KISS reviews you already have keep working, in the new layout.
84
+
3
85
  ## 0.50.1
4
86
 
5
87
  **Copy and Ask Buddy look and behave as a pair.** On every issue and question, both icons are the
@@ -3187,6 +3187,20 @@ async function hasCommit(root, sha) {
3187
3187
  }
3188
3188
  }
3189
3189
  //#endregion
3190
+ //#region ../../packages/review-harness/src/domain/kiss/outcome.ts
3191
+ /**
3192
+ * What a recommendation needs in order to be shown, said the same way wherever a withheld one is.
3193
+ *
3194
+ * The page and the terminal both explain why something was held back, and the rule behind it lives
3195
+ * in two places (`parse.ts` for the fields, `validate.ts` for the files). One sentence, so the
3196
+ * explanation cannot fall behind either of them.
3197
+ */
3198
+ var WITHHELD_REASON = "missing a summary, its evidence and reasoning, a simpler approach, or a file that exists in the reviewed commit";
3199
+ function kissOutcome(result, audit) {
3200
+ if (result.recommendations.length > 0) return "recommendations";
3201
+ return (audit.withheldRecommendations ?? 0) > 0 ? "incomplete" : "proportionate";
3202
+ }
3203
+ //#endregion
3190
3204
  //#region ../../packages/review-harness/src/workspace/authorship.ts
3191
3205
  function describeAuthorship(changeSet, pullRequest) {
3192
3206
  const author = pullRequest?.author;
@@ -4371,10 +4385,24 @@ function createWorkspace(input) {
4371
4385
  ...input
4372
4386
  };
4373
4387
  writeWorkspace(workspace);
4388
+ forwardQueueTo(workspace);
4374
4389
  pruneOldWorkspaces();
4375
4390
  return workspace;
4376
4391
  }
4377
4392
  /**
4393
+ * A new review of a queued branch takes the branch's place in the queue. See `forwardQueue`.
4394
+ *
4395
+ * Reads only the queued reviews, not the store: the queue is capped and the store is not. A failed
4396
+ * queue write is left alone, because the reviews index settles the same thing when it next loads.
4397
+ */
4398
+ function forwardQueueTo(workspace) {
4399
+ updateQueue((current) => {
4400
+ const queued = current.map(loadWorkspace).filter((one) => one !== null);
4401
+ if (queued.length === 0) return null;
4402
+ return forwardQueue(current, groupByLineage([...queued, workspace].map(summarise)));
4403
+ });
4404
+ }
4405
+ /**
4378
4406
  * Save a review that already exists, and nothing else.
4379
4407
  *
4380
4408
  * The existence check is what makes deleting a review stick. Every caller here is holding a
@@ -4733,6 +4761,24 @@ function groupByLineage(reviews) {
4733
4761
  }).sort((a, b) => b.latest.createdAt - a.latest.createdAt);
4734
4762
  }
4735
4763
  /**
4764
+ * The queue with every entry naming the newest review of its branch, or null when it already does.
4765
+ *
4766
+ * The queue holds a branch, written down as one review's id, and a card takes its queue position
4767
+ * from its newest review only. An entry left on an older review is counted by the pill and drawn by
4768
+ * no card, and the card's Remove cannot reach it. So a superseded id
4769
+ * becomes the newest one, and a branch queued twice keeps the earlier of its places.
4770
+ *
4771
+ * An id no group knows is left where it is. Dropping deleted reviews is `compactQueue`'s job, and
4772
+ * the groups can be older than the file the result is applied to: a review queued since they were
4773
+ * read is not in them.
4774
+ */
4775
+ function forwardQueue(queue, groups) {
4776
+ const newest = /* @__PURE__ */ new Map();
4777
+ for (const group of groups) for (const review of group.history) newest.set(review.id, group.latest.id);
4778
+ const forwarded = [...new Set(queue.map((id) => newest.get(id) ?? id))];
4779
+ return forwarded.length === queue.length && forwarded.every((id, at) => id === queue[at]) ? null : forwarded;
4780
+ }
4781
+ /**
4736
4782
  * Where one review sits in its branch's history.
4737
4783
  *
4738
4784
  * Answered from the store rather than from anything written on the workspace, for the reason
@@ -4915,7 +4961,8 @@ function startKissRun(workspace, start) {
4915
4961
  error: null,
4916
4962
  audit: {
4917
4963
  unknownPaths: [],
4918
- unlocatedRecommendations: 0
4964
+ unlocatedRecommendations: 0,
4965
+ withheldRecommendations: 0
4919
4966
  }
4920
4967
  };
4921
4968
  workspace.kissRuns = [...workspace.kissRuns ?? [], run];
@@ -7733,6 +7780,20 @@ function compactQueue(stored) {
7733
7780
  return updateQueue((current) => livePart(current)) ?? stored;
7734
7781
  }
7735
7782
  /**
7783
+ * The queue with every entry on the newest review of its branch, stored when that changed it.
7784
+ *
7785
+ * `createWorkspace` keeps this true from the moment a branch is analysed again; this is for the
7786
+ * entries a store written before that already holds. Forwarded again under the lock rather than
7787
+ * written from `read`, so an id somebody queued since is kept. On a failed write the forwarded copy
7788
+ * still stands in, so the page counts what it can draw.
7789
+ */
7790
+ function settleQueue(groups) {
7791
+ const read = readQueue();
7792
+ const forwarded = forwardQueue(read, groups);
7793
+ if (forwarded === null) return read;
7794
+ return updateQueue((current) => forwardQueue(current, groups)) ?? forwarded;
7795
+ }
7796
+ /**
7736
7797
  * Put one review on the queue, the way the index's Add does, and say how that went.
7737
7798
  *
7738
7799
  * **A Reviewed review is queued like any other.** Reviewed says what the reviewer has done with it,
@@ -9344,6 +9405,17 @@ Emit a recommendation only when you can do all five of these. If you cannot, say
9344
9405
  5. Say what acting on it is worth, and what it would cost.
9345
9406
  Never emit a recommendation resting on naming preference, personal taste, or a requirement nobody has yet.`;
9346
9407
  /**
9408
+ * The five checks above are how to reason, not how to write.
9409
+ *
9410
+ * Schema 1 gave each check its own output field, and the result was the reasoning printed five
9411
+ * times over: one argument restated under five headings, with the conclusion fourth. The checks stay
9412
+ * as the gate; this block asks for the conclusion first and keeps the checks as the audit trail.
9413
+ */
9414
+ var DISTIL = `How to write a recommendation:
9415
+ Work through all five checks first. Then distil what you found. A reviewer should understand the recommendation from "title", "gist", "simplerDirection" and "gains" alone, in a few seconds, without reading anything else.
9416
+ "evidence", "whyNotJustified" and "payoffAndTradeoffs" are the audit trail for somebody who wants to check or challenge you. They carry the detail. They must not restate the gist. "evidence" and "whyNotJustified" are required: a recommendation without a basis somebody can check is withheld, however good its summary.
9417
+ Name each piece of machinery where it matters and do not repeat the same argument in every field. One argument, stated once, then supported.`;
9418
+ /**
9347
9419
  * How to read the pull request conversation, for a lens that is not attributing anything to it.
9348
9420
  *
9349
9421
  * KISS needs the conversation for one reason and it is the load-bearing one: `whyNotJustified`
@@ -9382,6 +9454,9 @@ var RUBRIC = `What to look at:
9382
9454
  - repeated wrappers or conversions that could collapse
9383
9455
  - abstractions justified only by a hypothetical second implementation
9384
9456
 
9457
+ Fewer lines is evidence, not the objective. A twenty-line explicit implementation can be simpler than a five-line generic one. Judge simplicity by fewer concepts, fewer states, fewer sources of truth, fewer speculative abstractions, fewer indirections and less machinery needed to understand the behaviour.
9458
+ This is not a style review. Naming preferences, "extract this method", small tidy-ups and repetition that costs nobody any understanding are not recommendations.
9459
+
9385
9460
  This is a list of things to look at, not a list of things to report. Every item here is sometimes exactly right. One implementation does not make an interface wrong: it may be a real external boundary, a testing seam, or a repository convention. More files do not make code worse. Judge what each thing is for and what it costs.`;
9386
9461
  var WORTH_KEEPING = `Complexity worth keeping:
9387
9462
  Look actively for complexity that is doing a job, and say so. This is not a politeness section — it is how the reader can tell you understood the change rather than counted its abstractions.
@@ -9415,14 +9490,16 @@ var KISS_OUTPUT_INSTRUCTIONS = `Respond with a single JSON object, no markdown a
9415
9490
  "id": string,
9416
9491
  "title": string,
9417
9492
  "payoff": "high_value" | "worth_considering" | "nit",
9418
- "complexity": string,
9493
+ "gist": string,
9494
+ "simplerDirection": string,
9495
+ "gains": [string],
9496
+ "reductionEstimate": string,
9497
+ "caveat": string,
9419
9498
  "evidence": string,
9420
9499
  "whyNotJustified": string,
9421
- "simplerDirection": string,
9422
9500
  "payoffAndTradeoffs": string,
9423
9501
  "commentWorthiness": "strong" | "consider" | "leave",
9424
9502
  "draftComment": string,
9425
- "reductionEstimate": string,
9426
9503
  "locations": [{ "path": string, "line": number, "primary": boolean }]
9427
9504
  }],
9428
9505
  "justifiedComplexity": [{
@@ -9439,17 +9516,19 @@ Field guidance:
9439
9516
  - "summary": what drives that assessment and where the largest opportunities are, if there are any. A few sentences.
9440
9517
  - "recommendations": ordered by what acting on them is worth — reduction in concepts and code, maintenance impact, your confidence, and what the change would cost to make. Never ordered by file, and never by line count alone. An empty list is a valid and complete answer; return one rather than finding something to say.
9441
9518
  - "id": short, stable and unique within this response, e.g. "pdf-source-layers".
9442
- - "title": the simplification, in a few words. "Collapse the four PDF source types into one".
9519
+ - "title": the simplification itself, in a few words, as the action where that is accurate. "Collapse the four PDF source types into one", "Use the existing retry helper instead of adding a second", "Remove the cache; the store already memoises", "Keep the formatter local until it has a second caller". Name what to do, not the architectural concept: "Remove key readback until a device supports it", not "Defer device-reported key support".
9443
9520
  - "payoff": "high_value" for a substantial reduction in code, concepts, risk or maintenance with strong evidence. "worth_considering" for a credible simplification with meaningful but non-critical payoff, or a real trade-off. "nit" for small and cheap. Use "nit" sparingly: a report that is mostly nits buries the recommendation that mattered.
9444
- - "complexity": what was introduced, named concretely — the types, layers, files, flags or machinery, by name.
9445
- - "evidence": what in this change shows it. Point at the code, not at the idea.
9446
- - "whyNotJustified": why the current requirements and this repository do not call for it. This is the field that separates a recommendation from a preference.
9447
- - "simplerDirection": what to do instead, concretely enough to act on.
9448
- - "payoffAndTradeoffs": what is gained, and honestly what is lost or risked. If there is a good reason somebody might keep it as it is, say so here.
9521
+ - "gist": one or two sentences, at most 45 words: what this change adds and why the current requirements do not need it. The reader's first contact with the recommendation, so it must carry the point on its own. Name the machinery as a concept ("a dismissal flow", "a readback capability"), not as a list of identifiers: the identifiers belong in "simplerDirection", and listing them in both is the repetition this card exists to avoid.
9522
+ - "simplerDirection": the simpler implementation, concretely, in at most 50 words: what to remove, collapse or replace, naming the identifiers, and what to keep. "Simplify this" or "consider consolidating" is not a direction. Required: without one there is no recommendation.
9523
+ - "gains": one to four short phrases, at most six words each, naming what acting on it gains: "one less abstraction", "fewer states", "one source of truth", "no hypothetical capability". Every one must be supported by the evidence. No numbers here.
9524
+ - "reductionEstimate": optional and explicitly approximate, at most eight words, e.g. "~35–50 lines" or "four types become one". Omit it when you are not confident. It will be shown as an estimate and must never read as a measurement.
9525
+ - "caveat": optional, one sentence. Only a trade-off or uncertainty, shown by the evidence, that could change whether somebody acts on this. Never a hypothetical ("if readers later find…"). When there is none, omit the key entirely rather than writing "None"; routine trade-offs belong in "payoffAndTradeoffs".
9526
+ - "evidence": required. What in this change shows it, in detail. Point at the code, not at the idea.
9527
+ - "whyNotJustified": required. Why the current requirements and this repository do not call for it, in detail. This is the field that separates a recommendation from a preference.
9528
+ - "payoffAndTradeoffs": optional. What is gained and, honestly, what is lost or risked, in detail. If there is a good reason somebody might keep it as it is, say so here.
9449
9529
  - "commentWorthiness": REVIEWER FRAMING ONLY — omit the field entirely on an author review. "strong" for worth raising directly, "consider" for worth mentioning, "leave" for a valid observation not worth a comment.
9450
9530
  - "draftComment": REVIEWER FRAMING ONLY, and only where a comment is worth making. Omit it otherwise. A short, constructive pull request comment in the reviewer's voice.
9451
- - "reductionEstimate": optional and explicitly approximate, e.g. "four types become one" or "roughly 180 lines". Omit it when you are not confident. It will be shown as an estimate and must never read as a measurement.
9452
- - "locations": where this complexity lives. Prefer the changed files responsible for it, named exactly as they appear in the diff. If an unchanged repository file is where the complexity actually sits and no changed file represents it, cite that file too, but never describe an unchanged file as part of this change. "line" is optional. Set "primary": true on exactly one location — the one place a reader should open first, which should be a changed file wherever one represents the concern — and false on the rest.
9531
+ - "locations": required, at least one. Where this complexity lives. Prefer the changed files responsible for it, named exactly as they appear in the diff. If an unchanged repository file is where the complexity actually sits and no changed file represents it, cite that file too, but never describe an unchanged file as part of this change. "line" is optional. Set "primary": true on exactly one location — the one place a reader should open first, which should be a changed file wherever one represents the concern — and false on the rest.
9453
9532
  - "justifiedComplexity": complexity in this change that is doing a real job. Empty list when there is nothing worth the note.
9454
9533
  - "aggregateEstimate": optional, one phrase, across the whole change. Omit it unless it is defensible.`;
9455
9534
  function buildKissPrompt(pr, options) {
@@ -9475,6 +9554,8 @@ ${SCOPE_BOUNDARY}
9475
9554
 
9476
9555
  ${EVIDENCE_THRESHOLD}
9477
9556
 
9557
+ ${DISTIL}
9558
+
9478
9559
  ---
9479
9560
 
9480
9561
  ${RUBRIC}
@@ -9731,7 +9812,7 @@ var WORTHINESSES = [
9731
9812
  "consider",
9732
9813
  "leave"
9733
9814
  ];
9734
- function parseKissResult(raw) {
9815
+ function parseKissAnswer(raw) {
9735
9816
  const jsonText = extractJsonText(raw);
9736
9817
  let parsed;
9737
9818
  try {
@@ -9744,13 +9825,17 @@ function parseKissResult(raw) {
9744
9825
  const assessment = asString(obj.assessment);
9745
9826
  const summary = asString(obj.summary);
9746
9827
  if (!assessment && !summary && !Array.isArray(obj.recommendations) && !Array.isArray(obj.justifiedComplexity)) throw new Error("The agent returned valid JSON, but not in the shape a KISS review takes (missing assessment/summary/recommendations/justifiedComplexity). This usually means the model ignored the requested schema.");
9828
+ const { kept, withheld } = normalizeRecommendations(obj.recommendations);
9747
9829
  return {
9748
- schemaVersion: 1,
9749
- assessment,
9750
- summary,
9751
- recommendations: normalizeRecommendations(obj.recommendations),
9752
- justifiedComplexity: normalizeJustified(obj.justifiedComplexity),
9753
- ...optional("aggregateEstimate", asString(obj.aggregateEstimate))
9830
+ result: {
9831
+ schemaVersion: 2,
9832
+ assessment,
9833
+ summary,
9834
+ recommendations: kept,
9835
+ justifiedComplexity: normalizeJustified(obj.justifiedComplexity),
9836
+ ...optional("aggregateEstimate", asString(obj.aggregateEstimate))
9837
+ },
9838
+ withheldRecommendations: withheld
9754
9839
  };
9755
9840
  }
9756
9841
  /** Absent rather than empty, so "the model said nothing here" has one representation. */
@@ -9758,36 +9843,53 @@ function optional(key, value) {
9758
9843
  return value ? { [key]: value } : {};
9759
9844
  }
9760
9845
  /**
9761
- * A recommendation with nothing to act on is dropped rather than rendered.
9846
+ * A recommendation is kept only when it can stand on the card by itself.
9762
9847
  *
9763
- * The threshold the prompt sets is that a recommendation names complexity and says what to do
9764
- * instead. An entry missing both halves is not a recommendation the model made and we failed to
9765
- * display; it is one that was never made, and a card carrying a payoff pill and no text reads as
9766
- * the first.
9848
+ * That takes a title, a gist, a simpler direction and somewhere to open, which is what the card
9849
+ * shows without expanding anything, and the evidence and requirements reasoning behind it, which is
9850
+ * what makes it auditable. A polished summary with no basis a reader can check is a claim rather
9851
+ * than a recommendation. The trade-offs stay optional: some recommendations genuinely cost nothing. What fails is withheld and counted,
9852
+ * never padded or truncated, so the page can say that something came back incomplete.
9767
9853
  */
9768
9854
  function normalizeRecommendations(value) {
9769
- if (!Array.isArray(value)) return [];
9770
- return value.filter((item) => typeof item === "object" && item !== null).map((item, index) => {
9771
- const title = asString(item.title);
9772
- const simplerDirection = asString(item.simplerDirection);
9855
+ if (!Array.isArray(value)) return {
9856
+ kept: [],
9857
+ withheld: 0
9858
+ };
9859
+ const entries = value.filter((item) => typeof item === "object" && item !== null);
9860
+ const kept = entries.map((item, index) => {
9773
9861
  const worthiness = WORTHINESSES.includes(item.commentWorthiness) ? item.commentWorthiness : void 0;
9774
- const draftComment = asString(item.draftComment);
9775
- const reductionEstimate = asString(item.reductionEstimate);
9862
+ const gains = asStringArray(item.gains).map((gain) => gain.trim()).filter((gain) => gain !== "");
9776
9863
  return {
9777
9864
  id: asString(item.id) || `kiss-${index + 1}`,
9778
- title,
9865
+ title: asString(item.title),
9779
9866
  payoff: PAYOFFS.includes(item.payoff) ? item.payoff : "worth_considering",
9780
- complexity: asString(item.complexity),
9867
+ gist: asString(item.gist),
9781
9868
  evidence: asString(item.evidence),
9782
9869
  whyNotJustified: asString(item.whyNotJustified),
9783
- simplerDirection,
9870
+ simplerDirection: asString(item.simplerDirection),
9871
+ ...gains.length > 0 ? { gains } : {},
9872
+ ...optional("caveat", unlessPlaceholder(asString(item.caveat))),
9784
9873
  payoffAndTradeoffs: asString(item.payoffAndTradeoffs),
9785
9874
  ...worthiness ? { commentWorthiness: worthiness } : {},
9786
- ...draftComment ? { draftComment } : {},
9787
- ...reductionEstimate ? { reductionEstimate } : {},
9875
+ ...optional("draftComment", asString(item.draftComment)),
9876
+ ...optional("reductionEstimate", asString(item.reductionEstimate)),
9788
9877
  locations: normalizeLocations(item.locations)
9789
9878
  };
9790
- }).filter((rec) => rec.title !== "" || rec.complexity !== "" || rec.simplerDirection !== "");
9879
+ }).filter((rec) => rec.title !== "" && rec.gist !== "" && rec.simplerDirection !== "" && rec.evidence !== "" && rec.whyNotJustified !== "" && rec.locations.length > 0);
9880
+ return {
9881
+ kept,
9882
+ withheld: entries.length - kept.length
9883
+ };
9884
+ }
9885
+ /**
9886
+ * An optional sentence the model filled with a word for "nothing".
9887
+ *
9888
+ * Seen on real runs: asked to omit the caveat when there is none, the model wrote "None", which the
9889
+ * card would show as a trade-off called None. Only a bare placeholder is caught, never a sentence.
9890
+ */
9891
+ function unlessPlaceholder(value) {
9892
+ return /^(none|n\/a|null|nil|-+|no caveat|not applicable)\.?$/i.test(value.trim()) ? "" : value;
9791
9893
  }
9792
9894
  function normalizeJustified(value) {
9793
9895
  if (!Array.isArray(value)) return [];
@@ -9860,8 +9962,9 @@ var KissRejected = class extends Error {
9860
9962
  };
9861
9963
  async function validateKiss(raw, changedPaths, existingPaths) {
9862
9964
  let result;
9965
+ let withheldRecommendations;
9863
9966
  try {
9864
- result = parseKissResult(raw);
9967
+ ({result, withheldRecommendations} = parseKissAnswer(raw));
9865
9968
  } catch (error) {
9866
9969
  throw new KissRejected(error instanceof Error ? error.message : String(error), raw);
9867
9970
  }
@@ -9896,13 +9999,20 @@ async function validateKiss(raw, changedPaths, existingPaths) {
9896
9999
  }
9897
10000
  return surviving;
9898
10001
  };
9899
- for (const recommendation of result.recommendations) recommendation.locations = keep(recommendation.locations);
10002
+ let withheldForLocations = 0;
10003
+ result.recommendations = result.recommendations.filter((recommendation) => {
10004
+ recommendation.locations = keep(recommendation.locations);
10005
+ if (recommendation.locations.length > 0) return true;
10006
+ withheldForLocations++;
10007
+ return false;
10008
+ });
9900
10009
  for (const entry of result.justifiedComplexity) entry.locations = keep(entry.locations);
9901
10010
  return {
9902
10011
  result,
9903
10012
  audit: {
9904
10013
  unknownPaths: [...unknownPaths].sort(),
9905
- unlocatedRecommendations: result.recommendations.filter((r) => r.locations.length === 0).length
10014
+ unlocatedRecommendations: 0,
10015
+ withheldRecommendations: withheldRecommendations + withheldForLocations
9906
10016
  }
9907
10017
  };
9908
10018
  }
@@ -10743,7 +10853,7 @@ async function startJob(input) {
10743
10853
  jobId: job.id,
10744
10854
  audience: review.audience,
10745
10855
  reviewedSha: target.sha,
10746
- promptVersion: "1",
10856
+ promptVersion: "2",
10747
10857
  agentId: agent.id,
10748
10858
  ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
10749
10859
  });
@@ -11348,7 +11458,7 @@ async function recordKissRun(input) {
11348
11458
  jobId: job.id,
11349
11459
  audience: input.audience,
11350
11460
  reviewedSha: sha,
11351
- promptVersion: "1",
11461
+ promptVersion: "2",
11352
11462
  agentId: agent.id,
11353
11463
  ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
11354
11464
  });
@@ -12881,4 +12991,4 @@ function lineageIds(id) {
12881
12991
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
12882
12992
  }
12883
12993
  //#endregion
12884
- export { compactQueue as $, matchingWorkspaceIds as $t, createPrState as A, processDiscussion as An, reviewedShaOf as At, toneOf as B, writeAgentPreference as Bn, progressSteps as Bt, readEvents as C, readQueue as Cn, forgeResolver as Ct, announceJob as D, issuesOutstanding as Dn, checkCodeFreshness as Dt, telemetryUploadConsent as E, isQuestionOutstanding as En, fillFileUrlTemplate as Et, markAllRead as F, git as Fn, loadJob as Ft, extractJsonText as G, agentById as Gn, followedRefName as Gt, withUsageRecorded as H, STORE_ROOT as Hn, clearClaim as Ht, markRead as I, resolveAgent as In, saveJob as It, readMarker as J, AgentCancelledError as Jn, indexReviewGroups as Jt, ReviewBeingDeletedError as K, agentFor as Kn, groupByLineage as Kt, pruneNotifications as L, resolveModel as Ln, PHASES as Lt, eventName as M, resolveTarget as Mn, endedAt as Mt, headline as N, displayRef as Nn, fail as Nt, branchOf as O, questionsOutstanding as On, checkReview as Ot, listNotifications as P, isCommitReview as Pn, isTerminal as Pt, activateQueued as Q, loadWorkspace as Qt, readNotification as R, clearAgentPreference as Rn, isLiveJob as Rt, startJob as S, queueStamp as Sn, claimRequestUrl as St, recordTelemetryUploadConsent as T, isIssueOutstanding as Tn, remoteHost as Tt, EXPLAIN_SIMPLY_PROMPT as U, AGENT_IDS as Un, driverGone as Ut, unreadCount as V, CONFIG_PATH as Vn, purposeOf as Vt, asString as W, DEFAULT_AGENT_ID as Wn, isClaimed as Wt, modelHelpLines as X, lineageKeyFor as Xt, removeWorktree as Y, AgentUnavailableError as Yn, latestKissRun as Yt, relativeTime as Z, lineagePosition as Zt, agentEnvOf as _, parseLifecycle as _n, MIN_WORKSPACE_PORT as _t, wasBlocked as a, reviewedRepositories as an, readServerRecord as at, reanalyseReview as b, queueAddMany as bn, statedWorkspacePort as bt, RefreshUnavailableError as c, workspaceRevision as cn, spawnServer as ct, refreshPrContext as d, activityLabel as dn, writeServerRecord as dt, mutateWorkspace as en, queueForReview as et, isUnchanged as f, isLifecycle as fn, BUILD_VERSION as ft, runningJobs as g, offersLifecycle as gn, MAX_WORKSPACE_PORT as gt, liveUpdateFor as h, lifecyclePill as hn, DEFAULT_WORKSPACE_PORT as ht, liveJobsFor as i, reviewedCommit as in, ensureServer as it, createPrUpdate as j, describeAuthorship as jn, allJobs as jt, subjectOf as k, reviewerDispositions as kn, followedBranch as kt, mergeUpdateInto as l, workspaceStamps as ln, stopServer as lt, UpdateAlreadyRunningError as m, lifecycleChip as mn, feedbackUrl as mt, deleteReview as n, previousKissResult as nn, bootstrapUrl as nt, discardJob as o, summarise as on, reviewsUrl as ot, askCheckout as p, lifecycleAction as pn, PACKAGE_NAME as pt, MANAGED_ROOT as q, detectAgents as qn, indexKeptIds as qt, lineageIds as r, recentWorkspaces as rn, clearServerRecord as rt, FETCH_TIMEOUT_MS as s, touchWorkspace as sn, serverEntryExists as st, deleteLineage as t, positionInLineage as tn, readIndexToken as tt, updateReview as u, LIFECYCLES as un, workspaceUrl as ut, recordKissRun as v, sectionOf as vn, parseWorkspacePort as vt, record as w, doneVerb as wn, forgeFor as wt, runJob as x, queueRemove as xn, writePortPreference as xt, startKissJob as y, queueAdd as yn, resolveWorkspacePort as yt, removeNotification as z, readAgentPreference as zn, phasesFor as zt };
12994
+ export { compactQueue as $, loadWorkspace as $t, createPrState as A, reviewerDispositions as An, followedBranch as At, toneOf as B, resolveModel as Bn, phasesFor as Bt, readEvents as C, queueStamp as Cn, claimRequestUrl as Ct, announceJob as D, isQuestionOutstanding as Dn, fillFileUrlTemplate as Dt, telemetryUploadConsent as E, isIssueOutstanding as En, remoteHost as Et, markAllRead as F, resolveTarget as Fn, isTerminal as Ft, extractJsonText as G, STORE_ROOT as Gn, isClaimed as Gt, withUsageRecorded as H, readAgentPreference as Hn, purposeOf as Ht, markRead as I, displayRef as In, loadJob as It, readMarker as J, agentById as Jn, indexKeptIds as Jt, ReviewBeingDeletedError as K, AGENT_IDS as Kn, followedRefName as Kt, pruneNotifications as L, isCommitReview as Ln, saveJob as Lt, eventName as M, describeAuthorship as Mn, allJobs as Mt, headline as N, WITHHELD_REASON as Nn, endedAt as Nt, branchOf as O, issuesOutstanding as On, checkCodeFreshness as Ot, listNotifications as P, kissOutcome as Pn, fail as Pt, activateQueued as Q, AgentUnavailableError as Qn, lineagePosition as Qt, readNotification as R, git as Rn, PHASES as Rt, startJob as S, queueRemove as Sn, writePortPreference as St, recordTelemetryUploadConsent as T, doneVerb as Tn, forgeFor as Tt, EXPLAIN_SIMPLY_PROMPT as U, writeAgentPreference as Un, clearClaim as Ut, unreadCount as V, clearAgentPreference as Vn, progressSteps as Vt, asString as W, CONFIG_PATH as Wn, driverGone as Wt, modelHelpLines as X, detectAgents as Xn, latestKissRun as Xt, removeWorktree as Y, agentFor as Yn, indexReviewGroups as Yt, relativeTime as Z, AgentCancelledError as Zn, lineageKeyFor as Zt, agentEnvOf as _, offersLifecycle as _n, MAX_WORKSPACE_PORT as _t, wasBlocked as a, reviewedCommit as an, ensureServer as at, reanalyseReview as b, queueAdd as bn, resolveWorkspacePort as bt, RefreshUnavailableError as c, touchWorkspace as cn, serverEntryExists as ct, refreshPrContext as d, LIFECYCLES as dn, workspaceUrl as dt, matchingWorkspaceIds as en, queueForReview as et, isUnchanged as f, activityLabel as fn, writeServerRecord as ft, runningJobs as g, lifecyclePill as gn, DEFAULT_WORKSPACE_PORT as gt, liveUpdateFor as h, lifecycleChip as hn, feedbackUrl as ht, liveJobsFor as i, recentWorkspaces as in, clearServerRecord as it, createPrUpdate as j, processDiscussion as jn, reviewedShaOf as jt, subjectOf as k, questionsOutstanding as kn, checkReview as kt, mergeUpdateInto as l, workspaceRevision as ln, spawnServer as lt, UpdateAlreadyRunningError as m, lifecycleAction as mn, PACKAGE_NAME as mt, deleteReview as n, positionInLineage as nn, readIndexToken as nt, discardJob as o, reviewedRepositories as on, readServerRecord as ot, askCheckout as p, isLifecycle as pn, BUILD_VERSION as pt, MANAGED_ROOT as q, DEFAULT_AGENT_ID as qn, groupByLineage as qt, lineageIds as r, previousKissResult as rn, bootstrapUrl as rt, FETCH_TIMEOUT_MS as s, summarise as sn, reviewsUrl as st, deleteLineage as t, mutateWorkspace as tn, settleQueue as tt, updateReview as u, workspaceStamps as un, stopServer as ut, recordKissRun as v, parseLifecycle as vn, MIN_WORKSPACE_PORT as vt, record as w, readQueue as wn, forgeResolver as wt, runJob as x, queueAddMany as xn, statedWorkspacePort as xt, startKissJob as y, sectionOf as yn, parseWorkspacePort as yt, removeNotification as z, resolveAgent as zn, isLiveJob as zt };
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $t as matchingWorkspaceIds, Bn as writeAgentPreference, Bt as progressSteps, Ct as forgeResolver, E as telemetryUploadConsent, Fn as git, Ft as loadJob, Gn as agentById, Gt as followedRefName, Hn as STORE_ROOT, Ht as clearClaim, In as resolveAgent, J as readMarker, Kt as groupByLineage, Ln as resolveModel, Lt as PHASES, Mn as resolveTarget, Nn as displayRef, Ot as checkReview, Pt as isTerminal, Qt as loadWorkspace, S as startJob, T as recordTelemetryUploadConsent, Un as AGENT_IDS, Ut as driverGone, Vn as CONFIG_PATH, Vt as purposeOf, Wn as DEFAULT_AGENT_ID, Wt as isClaimed, X as modelHelpLines, Xt as lineageKeyFor, Y as removeWorktree, Z as relativeTime, _t as MIN_WORKSPACE_PORT, a as wasBlocked, an as reviewedRepositories, b as reanalyseReview, bt as statedWorkspacePort, en as mutateWorkspace, et as queueForReview, ft as BUILD_VERSION, g as runningJobs, gt as MAX_WORKSPACE_PORT, h as liveUpdateFor, ht as DEFAULT_WORKSPACE_PORT, i as liveJobsFor, it as ensureServer, jt as allJobs, l as mergeUpdateInto, lt as stopServer, m as UpdateAlreadyRunningError, n as deleteReview$1, nt as bootstrapUrl, on as summarise, ot as reviewsUrl, pt as PACKAGE_NAME, q as MANAGED_ROOT, qn as detectAgents, rn as recentWorkspaces, tn as positionInLineage, tt as readIndexToken, u as updateReview, ut as workspaceUrl, v as recordKissRun, vt as parseWorkspacePort, w as record$1, x as runJob, xt as writePortPreference, zn as readAgentPreference } from "./delete_review-BIXqUnpx.js";
2
+ import { $t as loadWorkspace, Bn as resolveModel, E as telemetryUploadConsent, Fn as resolveTarget, Ft as isTerminal, Gn as STORE_ROOT, Gt as isClaimed, Hn as readAgentPreference, Ht as purposeOf, In as displayRef, It as loadJob, J as readMarker, Jn as agentById, Kn as AGENT_IDS, Kt as followedRefName, Mt as allJobs, Nn as WITHHELD_REASON, Pn as kissOutcome, Rn as git, Rt as PHASES, S as startJob, St as writePortPreference, T as recordTelemetryUploadConsent, Un as writeAgentPreference, Ut as clearClaim, Vt as progressSteps, Wn as CONFIG_PATH, Wt as driverGone, X as modelHelpLines, Xn as detectAgents, Y as removeWorktree, Z as relativeTime, Zt as lineageKeyFor, _t as MAX_WORKSPACE_PORT, a as wasBlocked, at as ensureServer, b as reanalyseReview, dt as workspaceUrl, en as matchingWorkspaceIds, et as queueForReview, g as runningJobs, gt as DEFAULT_WORKSPACE_PORT, h as liveUpdateFor, i as liveJobsFor, in as recentWorkspaces, kt as checkReview, l as mergeUpdateInto, m as UpdateAlreadyRunningError, mt as PACKAGE_NAME, n as deleteReview$1, nn as positionInLineage, nt as readIndexToken, on as reviewedRepositories, pt as BUILD_VERSION, q as MANAGED_ROOT, qn as DEFAULT_AGENT_ID, qt as groupByLineage, rt as bootstrapUrl, sn as summarise, st as reviewsUrl, tn as mutateWorkspace, u as updateReview, ut as stopServer, v as recordKissRun, vt as MIN_WORKSPACE_PORT, w as record$1, wt as forgeResolver, x as runJob, xt as statedWorkspacePort, yt as parseWorkspacePort, zn as resolveAgent } from "./delete_review-BiV1aBpx.js";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -1386,8 +1386,8 @@ function reviewFinished(url, summary, conversation) {
1386
1386
  * worth saying plainly: somebody who ran this wants to know the change is proportionate, and a
1387
1387
  * bare zero leaves them wondering whether the analysis ran.
1388
1388
  */
1389
- function kissFinished(url, recommendations) {
1390
- return `\nKISS review ready · ${recommendations === 0 ? "No material simplifications were identified." : count(recommendations, "recommendation")}\n${url}`;
1389
+ function kissFinished(url, outcome, recommendations, withheld = 0) {
1390
+ return `\nKISS review ready · ${outcome === "proportionate" ? "No material simplifications were identified." : outcome === "incomplete" ? `${count(withheld, "recommendation")} came back incomplete and ${withheld === 1 ? "was" : "were"} withheld (${WITHHELD_REASON}). This is not a finding that the change is simple; run it again.` : `${count(recommendations, "recommendation")}${withheld > 0 ? ` (${withheld} more withheld as incomplete)` : ""}`}\n${url}`;
1391
1391
  }
1392
1392
  function cap(word) {
1393
1393
  return word.charAt(0).toUpperCase() + word.slice(1);
@@ -2746,12 +2746,13 @@ async function drive(workspaceId, url, kissRun) {
2746
2746
  }
2747
2747
  const done = loadWorkspace(workspaceId);
2748
2748
  if (kissRun) {
2749
- const answer = (done.kissRuns ?? []).find((candidate) => candidate.jobId === kissRun.jobId)?.result;
2750
- if (!answer) {
2749
+ const finished = (done.kissRuns ?? []).find((candidate) => candidate.jobId === kissRun.jobId);
2750
+ const answer = finished?.result;
2751
+ if (!finished || !answer) {
2751
2752
  err(reviewFailed("The analysis finished without recording an answer.", link));
2752
2753
  return 1;
2753
2754
  }
2754
- out(kissFinished(link, answer.recommendations.length));
2755
+ out(kissFinished(link, kissOutcome(answer, finished.audit), answer.recommendations.length, finished.audit.withheldRecommendations ?? 0));
2755
2756
  return 0;
2756
2757
  }
2757
2758
  out(reviewFinished(link, summarise(done), settled.job.conversation));