@inceptionstack/pi-hard-no 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changes.ts CHANGED
@@ -370,7 +370,16 @@ export function hasGitCommitCommand(toolCalls: TrackedToolCall[]): boolean {
370
370
  return toolCalls.some((tc) => {
371
371
  if (tc.name !== "bash") return false;
372
372
  const cmd = String(tc.input?.command ?? "");
373
- return /\bgit(?:\s+-C\s+\S+)?\s+commit\b/.test(cmd);
373
+ // Direct: git commit
374
+ if (/\bgit(?:\s+-C\s+\S+)?\s+commit\b/.test(cmd)) return true;
375
+ // Subprocess wrapper: perl/python/node/ruby calling git commit
376
+ if (
377
+ /\b(?:python3?|node|perl|ruby)\b/.test(cmd) &&
378
+ /\bgit\b/.test(cmd) &&
379
+ /\bcommit\b/.test(cmd)
380
+ )
381
+ return true;
382
+ return false;
374
383
  });
375
384
  }
376
385
 
package/commands.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { type AutoReviewSettings, configDirs } from "./settings";
4
4
  import { buildReviewPrompt } from "./prompt";
package/context.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Falls back gracefully when git is unavailable.
7
7
  */
8
8
 
9
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateDiff } from "./helpers";
11
11
  import { filterIgnored } from "./ignore";
12
12
  import { log } from "./logger";
@@ -627,6 +627,12 @@ export async function getBestReviewContent(
627
627
 
628
628
  const allowLastCommitFallback = hasGitCommitCommand(agentToolCalls);
629
629
 
630
+ // Also allow last-commit fallback when the agent modified files via edit/write
631
+ // tools but the working tree is clean (implying an undetected commit occurred,
632
+ // e.g. via subprocess wrapper that evaded hasGitCommitCommand detection).
633
+ const agentModifiedFiles = agentToolCalls.some((tc) => tc.name === "write" || tc.name === "edit");
634
+ const shouldFallbackToLastCommit = allowLastCommitFallback || agentModifiedFiles;
635
+
630
636
  if (gitRoots && gitRoots.size > 0) {
631
637
  const result = await getContentFromGitRoots(
632
638
  pi,
@@ -635,7 +641,7 @@ export async function getBestReviewContent(
635
641
  summarySection,
636
642
  onStatus,
637
643
  lim,
638
- allowLastCommitFallback,
644
+ shouldFallbackToLastCommit,
639
645
  );
640
646
  if (result) return result;
641
647
  }
@@ -643,7 +649,7 @@ export async function getBestReviewContent(
643
649
  const cwdResult = await getContentFromCwd(pi, ignorePatterns, summarySection, onStatus, lim);
644
650
  if (cwdResult) return cwdResult;
645
651
 
646
- if (allowLastCommitFallback) {
652
+ if (shouldFallbackToLastCommit) {
647
653
  const lastCommitResult = await getContentFromLastCommit(
648
654
  pi,
649
655
  ignorePatterns,
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { findingKey, numberFindings, parseDismissals, filterSuppressed, DismissTracker } from "./dismiss";
3
+
4
+ describe("findingKey", () => {
5
+ it("extracts severity + location from finding line", () => {
6
+ const line = '- **Medium:** src/gateway/model.ts:97 — chatId extraction uses wrong index';
7
+ const key = findingKey(line);
8
+ expect(key).toContain("medium:src/gateway/model.ts:97");
9
+ });
10
+
11
+ it("handles numbered finding format (F# prefix)", () => {
12
+ const line = '- **F1 Medium:** src/foo.ts:10 — something bad';
13
+ const key = findingKey(line);
14
+ expect(key).toBe("medium:src/foo.ts:10 — something bad");
15
+ });
16
+
17
+ it("falls back to trimmed line for non-matching format", () => {
18
+ const key = findingKey("some random text");
19
+ expect(key).toBe("some random text");
20
+ });
21
+ });
22
+
23
+ describe("numberFindings", () => {
24
+ it("numbers finding bullets sequentially", () => {
25
+ const text = '- **High:** foo.ts:1 — bug\n- **Low:** bar.ts:2 — nit\nSome other text';
26
+ const { numbered, findings } = numberFindings(text);
27
+ expect(numbered).toContain("**F1 High:**");
28
+ expect(numbered).toContain("**F2 Low:**");
29
+ expect(numbered).toContain("Some other text");
30
+ expect(findings).toHaveLength(2);
31
+ });
32
+
33
+ it("preserves non-finding lines unchanged", () => {
34
+ const text = 'Header\n\n- **Medium:** x.ts:5 — issue\n\nFooter';
35
+ const { numbered } = numberFindings(text);
36
+ expect(numbered).toContain("Header");
37
+ expect(numbered).toContain("Footer");
38
+ expect(numbered).toContain("**F1 Medium:**");
39
+ });
40
+ });
41
+
42
+ describe("parseDismissals", () => {
43
+ it("parses DISMISS F# with colon separator", () => {
44
+ const text = "The chatId extraction is intentional.\nDISMISS F1: intentional design for telegram thread format";
45
+ const dismissals = parseDismissals(text);
46
+ expect(dismissals.size).toBe(1);
47
+ expect(dismissals.get(1)).toBe("intentional design for telegram thread format");
48
+ });
49
+
50
+ it("parses multiple dismissals", () => {
51
+ const text = "DISMISS F1: by design\nDISMISS F2: not a real issue";
52
+ const dismissals = parseDismissals(text);
53
+ expect(dismissals.size).toBe(2);
54
+ expect(dismissals.get(1)).toBe("by design");
55
+ expect(dismissals.get(2)).toBe("not a real issue");
56
+ });
57
+
58
+ it("parses dash separator", () => {
59
+ const text = "DISMISS F3 - already reviewed";
60
+ const dismissals = parseDismissals(text);
61
+ expect(dismissals.get(3)).toBe("already reviewed");
62
+ });
63
+
64
+ it("returns empty map when no dismissals", () => {
65
+ const text = "I fixed both issues as suggested.";
66
+ expect(parseDismissals(text).size).toBe(0);
67
+ });
68
+ });
69
+
70
+ describe("filterSuppressed", () => {
71
+ it("removes suppressed findings", () => {
72
+ const text = '- **High:** foo.ts:1 — bug one\n- **Low:** bar.ts:2 — nit two';
73
+ const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
74
+ const result = filterSuppressed(text, suppressed);
75
+ expect(result).not.toContain("bug one");
76
+ expect(result).toContain("nit two");
77
+ });
78
+
79
+ it("returns null when all findings suppressed", () => {
80
+ const text = '- **High:** foo.ts:1 — bug one';
81
+ const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
82
+ expect(filterSuppressed(text, suppressed)).toBeNull();
83
+ });
84
+
85
+ it("returns original when no suppressions", () => {
86
+ const text = '- **Low:** x.ts:5 — something';
87
+ expect(filterSuppressed(text, new Set())).toBe(text);
88
+ });
89
+ });
90
+
91
+ describe("DismissTracker", () => {
92
+ it("tracks dismissals and suppresses after threshold", () => {
93
+ const tracker = new DismissTracker();
94
+ const findings = ['- **Medium:** src/foo.ts:10 — bad pattern', '- **Low:** src/bar.ts:5 — nit'];
95
+ tracker.setLastFindings(findings);
96
+
97
+ // First dismiss
98
+ tracker.processDismissals("DISMISS F1: intentional");
99
+ expect(tracker.getSuppressed().size).toBe(0); // threshold is 2
100
+
101
+ // Second dismiss (same finding via new review)
102
+ tracker.setLastFindings(findings);
103
+ tracker.processDismissals("DISMISS F1: still intentional");
104
+ expect(tracker.getSuppressed().size).toBe(1);
105
+ });
106
+
107
+ it("reset clears all state", () => {
108
+ const tracker = new DismissTracker();
109
+ tracker.setLastFindings(['- **High:** x.ts:1 — bug']);
110
+ tracker.processDismissals("DISMISS F1: nope");
111
+ tracker.processDismissals("DISMISS F1: nope again");
112
+ expect(tracker.getSuppressed().size).toBe(1);
113
+
114
+ tracker.reset();
115
+ expect(tracker.getSuppressed().size).toBe(0);
116
+ });
117
+ });
package/dismiss.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * dismiss.ts — Track and suppress dismissed review findings
3
+ *
4
+ * When the agent responds to a review with "DISMISS F#: reason",
5
+ * that finding is tracked. If the same finding appears in a subsequent
6
+ * review cycle and has been dismissed >= threshold times, it's suppressed.
7
+ *
8
+ * Finding identity: severity + file:line + first 60 chars of problem text.
9
+ */
10
+
11
+ import { log } from "./logger";
12
+
13
+ export interface DismissedFinding {
14
+ key: string;
15
+ reason: string;
16
+ count: number;
17
+ }
18
+
19
+ /** How many times a finding must be dismissed before it's auto-suppressed. */
20
+ const SUPPRESS_THRESHOLD = 2;
21
+
22
+ /** Extract a stable key from a finding bullet line. */
23
+ export function findingKey(line: string): string {
24
+ // Format: - **Severity:** file:line — problem text
25
+ const match = line.match(/^\s*-\s*\*\*(?:F\d+\s+)?(\w+):\*\*\s*(.+)/);
26
+ if (!match) return line.trim().slice(0, 80);
27
+ const severity = match[1].toLowerCase();
28
+ const rest = match[2].trim().slice(0, 60);
29
+ return `${severity}:${rest}`;
30
+ }
31
+
32
+ /** Number findings in review text and return the numbered version + finding list. */
33
+ export function numberFindings(text: string): { numbered: string; findings: string[] } {
34
+ const lines = text.split("\n");
35
+ const findings: string[] = [];
36
+ let counter = 0;
37
+
38
+ const numbered = lines.map(line => {
39
+ // Match finding bullets: - **Severity:** ...
40
+ const match = line.match(/^(\s*-\s*)\*\*(\w+):\*\*(.*)$/);
41
+ if (match) {
42
+ counter++;
43
+ findings.push(line);
44
+ return `${match[1]}**F${counter} ${match[2]}:**${match[3]}`;
45
+ }
46
+ return line;
47
+ }).join("\n");
48
+
49
+ return { numbered, findings };
50
+ }
51
+
52
+ /** Parse DISMISS markers from agent text. Returns map of F# → reason. */
53
+ export function parseDismissals(text: string): Map<number, string> {
54
+ const dismissals = new Map<number, string>();
55
+ // Match: DISMISS F1: reason or DISMISS F1 - reason or DISMISS F1 reason
56
+ const pattern = /DISMISS\s+F(\d+)\s*[:–\-]\s*(.+)/gi;
57
+ let match;
58
+ while ((match = pattern.exec(text)) !== null) {
59
+ dismissals.set(parseInt(match[1], 10), match[2].trim());
60
+ }
61
+ return dismissals;
62
+ }
63
+
64
+ /** Filter suppressed findings from review text. Returns filtered text or null if all suppressed. */
65
+ export function filterSuppressed(text: string, suppressed: Set<string>): string | null {
66
+ if (suppressed.size === 0) return text;
67
+
68
+ const lines = text.split("\n");
69
+ const filtered = lines.filter(line => {
70
+ const match = line.match(/^\s*-\s*\*\*\w+:\*\*/);
71
+ if (!match) return true; // keep non-finding lines
72
+ const key = findingKey(line);
73
+ return !suppressed.has(key);
74
+ });
75
+
76
+ // If all findings were suppressed, return null (should be LGTM)
77
+ const remaining = filtered.filter(l => l.match(/^\s*-\s*\*\*/));
78
+ if (remaining.length === 0) return null;
79
+
80
+ return filtered.join("\n");
81
+ }
82
+
83
+ /**
84
+ * Dismiss tracker — stores dismissed findings across review loops.
85
+ * Scoped to one orchestrator instance (one session).
86
+ */
87
+ export class DismissTracker {
88
+ private dismissed = new Map<string, DismissedFinding>();
89
+ private lastFindings: string[] = [];
90
+
91
+ /** Record the findings from the latest review (for F# → finding mapping). */
92
+ setLastFindings(findings: string[]): void {
93
+ this.lastFindings = findings;
94
+ }
95
+
96
+ /** Process agent's response text for DISMISS markers. */
97
+ processDismissals(agentText: string): number {
98
+ const markers = parseDismissals(agentText);
99
+ if (markers.size === 0) return 0;
100
+
101
+ let count = 0;
102
+ for (const [fNum, reason] of markers) {
103
+ const finding = this.lastFindings[fNum - 1]; // F1 = index 0
104
+ if (!finding) continue;
105
+
106
+ const key = findingKey(finding);
107
+ const existing = this.dismissed.get(key);
108
+ if (existing) {
109
+ existing.count++;
110
+ existing.reason = reason;
111
+ } else {
112
+ this.dismissed.set(key, { key, reason, count: 1 });
113
+ }
114
+ count++;
115
+ log(`dismiss: F${fNum} dismissed (${key}) — "${reason}" [count=${this.dismissed.get(key)!.count}]`);
116
+ }
117
+ return count;
118
+ }
119
+
120
+ /** Get the set of finding keys that should be suppressed (dismissed >= threshold). */
121
+ getSuppressed(): Set<string> {
122
+ const suppressed = new Set<string>();
123
+ for (const [key, entry] of this.dismissed) {
124
+ if (entry.count >= SUPPRESS_THRESHOLD) {
125
+ suppressed.add(key);
126
+ }
127
+ }
128
+ return suppressed;
129
+ }
130
+
131
+ /** Reset all dismissals (e.g. on session end). */
132
+ reset(): void {
133
+ this.dismissed.clear();
134
+ this.lastFindings = [];
135
+ }
136
+ }
package/git-roots.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { dirname, resolve, isAbsolute } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
 
11
11
  /**
12
12
  * Find the git repo root for a given directory.
package/index.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * or: cp index.ts ~/.pi/agent/extensions/pi-hard-no.ts
22
22
  */
23
23
 
24
- import { type ExtensionAPI, isToolCallEventType } from "@mariozechner/pi-coding-agent";
24
+ import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
25
25
 
26
26
  import {
27
27
  type AutoReviewSettings,
@@ -615,10 +615,18 @@ export default function (pi: ExtensionAPI) {
615
615
  const hasArchitectStep = Boolean(outcome.architect);
616
616
  const hasArchitectFailure = Boolean(outcome.architectFailure);
617
617
  const hasFollowUp = hasArchitectStep || hasArchitectFailure;
618
+ // Apply dismiss filtering (suppress previously dismissed findings, number remaining)
619
+ const filteredResult = orchestrator.applyDismissFiltering(outcome.senior.result);
620
+
621
+ // If dismiss filtering converted issues to LGTM, clear the issues state
622
+ if (filteredResult.isLgtm && !outcome.senior.result.isLgtm) {
623
+ orchestrator.clearIssuesAfterDismiss();
624
+ }
625
+
618
626
  // Always trigger a turn for ISSUES_FOUND so agent can fix.
619
627
  // Also trigger for LGTM so agent can continue (push, etc.).
620
628
  // Skip triggering only when architect (success or failure) follows — it sends its own message.
621
- sendReviewResult(pi, outcome.senior.result, outcome.senior.label ?? "", {
629
+ sendReviewResult(pi, filteredResult, outcome.senior.label ?? "", {
622
630
  showLoopCount: outcome.senior.loopInfo,
623
631
  reviewedFiles: outcome.files,
624
632
  triggerTurn: !hasFollowUp,
@@ -696,6 +704,15 @@ export default function (pi: ExtensionAPI) {
696
704
  return;
697
705
  }
698
706
 
707
+ // Process DISMISS markers from agent's response (before running review)
708
+ if (lastAssistant) {
709
+ const textParts = (lastAssistant.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text);
710
+ const agentText = textParts.join("\n");
711
+ if (agentText) {
712
+ orchestrator.processDismissals(agentText);
713
+ }
714
+ }
715
+
699
716
  if (!orchestrator.isEnabled) {
700
717
  // Keep tracking state (modifiedFiles, agentToolCalls) so we can
701
718
  // offer to review when the user toggles review back on.
package/judge.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  SessionManager,
27
27
  createAgentSession,
28
28
  type AgentSessionEvent,
29
- } from "@mariozechner/pi-coding-agent";
29
+ } from "@earendil-works/pi-coding-agent";
30
30
 
31
31
  import { log } from "./logger";
32
32
 
package/message-sender.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { log } from "./logger";
4
4
  import type { ReviewResult } from "./reviewer";
@@ -74,7 +74,7 @@ export function sendReviewResult(
74
74
  pi.sendMessage(
75
75
  {
76
76
  customType: "code-review",
77
- content: `🔍 **Automated Code Review**${loopInfo || (label ? ` (${label})` : "")} — ${duration}\n\nA separate reviewer examined your recent changes and found potential issues:\n\n${result.text}${fileList}${idFooter}\n\nPlease review these findings. If any are valid, fix them. If they're false positives, briefly explain why and move on.\n\n⚠️ **Do NOT push to remote yet.** Fix any issues first. Do NOT push after fixing either — a new review cycle will check your fixes automatically.`,
77
+ content: `🔍 **Automated Code Review**${loopInfo || (label ? ` (${label})` : "")} — ${duration}\n\nA separate reviewer examined your recent changes and found potential issues:\n\n${result.text}${fileList}${idFooter}\n\nPlease review these findings. If any are valid, fix them. If they're false positives, reply with \`DISMISS F#: reason\` (e.g. \`DISMISS F1: intentional design\`) and move on.\n\n⚠️ **Do NOT push to remote yet.** Fix any issues first. Do NOT push after fixing either — a new review cycle will check your fixes automatically.`,
78
78
  display: true,
79
79
  },
80
80
  { triggerTurn: opts?.triggerTurn ?? true, deliverAs: "followUp" },
package/orchestrator.ts CHANGED
@@ -10,6 +10,7 @@ import type { AutoReviewSettings } from "./settings";
10
10
  import { runArchitectReview, shouldRunArchitectReview } from "./architect";
11
11
  import type { ReviewResult, ReviewRunner } from "./reviewer";
12
12
  import { log } from "./logger";
13
+ import { DismissTracker, numberFindings, filterSuppressed } from "./dismiss";
13
14
 
14
15
  const MIN_REVIEW_CONTENT_LENGTH = 50;
15
16
 
@@ -110,6 +111,7 @@ export class ReviewOrchestrator {
110
111
  private sessionChangedFiles = new Set<string>();
111
112
  private sessionHasGitContent = false;
112
113
  private lastReviewHadIssues = false;
114
+ private readonly dismissTracker = new DismissTracker();
113
115
 
114
116
  constructor(opts: ReviewOrchestratorOptions) {
115
117
  this.runner = opts.runner;
@@ -148,6 +150,35 @@ export class ReviewOrchestrator {
148
150
  this.isReviewingValue = false;
149
151
  this.resetCycleState();
150
152
  this.lastReviewHadIssues = false;
153
+ this.dismissTracker.reset();
154
+ }
155
+
156
+ /** Process agent's response text for DISMISS markers. Call before review. */
157
+ processDismissals(agentText: string): number {
158
+ return this.dismissTracker.processDismissals(agentText);
159
+ }
160
+
161
+ /** Apply dismiss filtering + numbering to review result text. */
162
+ applyDismissFiltering(result: ReviewResult): ReviewResult {
163
+ const suppressed = this.dismissTracker.getSuppressed();
164
+ const filtered = filterSuppressed(result.text, suppressed);
165
+
166
+ // All findings suppressed — treat as LGTM
167
+ if (filtered === null) {
168
+ log("dismiss: all findings suppressed — treating as LGTM");
169
+ return { ...result, isLgtm: true, text: "No issues found (previously dismissed findings suppressed)." };
170
+ }
171
+
172
+ // Number remaining findings and track them
173
+ const { numbered, findings } = numberFindings(filtered);
174
+ this.dismissTracker.setLastFindings(findings);
175
+ return { ...result, text: numbered };
176
+ }
177
+
178
+ /** Clear issue state after dismiss filtering converts to LGTM. */
179
+ clearIssuesAfterDismiss(): void {
180
+ this.lastReviewHadIssues = false;
181
+ this.loopCount = 0;
151
182
  }
152
183
 
153
184
  cancel(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/pi-hard-no",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension — automatic code review after every agent turn",
6
6
  "license": "MIT",
@@ -40,11 +40,11 @@
40
40
  "check": "npm run typecheck && npm run lint && npm run format:check && npm run test"
41
41
  },
42
42
  "peerDependencies": {
43
- "@mariozechner/pi-coding-agent": "*"
43
+ "@earendil-works/pi-coding-agent": "*"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@eslint/js": "^9.27.0",
47
- "@mariozechner/pi-coding-agent": "^0.69.0",
47
+ "@earendil-works/pi-coding-agent": "^0.74.0",
48
48
  "@types/node": "^22.15.17",
49
49
  "eslint": "^9.27.0",
50
50
  "prettier": "^3.5.3",
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseVerdict, cleanReviewText, isLgtmResult } from "./reviewer";
3
+
4
+ describe("reviewer verdict parsing", () => {
5
+ it("parseVerdict extracts LGTM", () => {
6
+ expect(parseVerdict("some text <verdict>LGTM</verdict> more")).toBe("lgtm");
7
+ });
8
+
9
+ it("parseVerdict extracts ISSUES_FOUND", () => {
10
+ expect(parseVerdict("<verdict>ISSUES_FOUND</verdict>")).toBe("issues");
11
+ });
12
+
13
+ it("parseVerdict returns null when no tag", () => {
14
+ expect(parseVerdict("no verdict here")).toBeNull();
15
+ });
16
+
17
+ it("isLgtmResult returns true for empty text", () => {
18
+ expect(isLgtmResult("")).toBe(true);
19
+ expect(isLgtmResult(" ")).toBe(true);
20
+ });
21
+
22
+ it("isLgtmResult returns false when severity markers present", () => {
23
+ expect(isLgtmResult("- **High:** something bad")).toBe(false);
24
+ expect(isLgtmResult("- **Medium:** fix this")).toBe(false);
25
+ });
26
+
27
+ it("isLgtmResult returns true for explicit LGTM text", () => {
28
+ expect(isLgtmResult("LGTM — looks good")).toBe(true);
29
+ });
30
+
31
+ it("cleanReviewText strips verdict tags", () => {
32
+ const raw = "Some findings\n<verdict>ISSUES_FOUND</verdict>";
33
+ expect(cleanReviewText(raw)).toBe("Some findings");
34
+ });
35
+
36
+ it("empty cleanedText with ISSUES_FOUND verdict should be treated as LGTM", () => {
37
+ // This tests the bug where model returns <verdict>ISSUES_FOUND</verdict>
38
+ // with no actual findings text — resulting in confusing "found potential issues:" + nothing
39
+ const raw = "<verdict>ISSUES_FOUND</verdict>";
40
+ const cleaned = cleanReviewText(raw);
41
+ expect(cleaned).toBe("");
42
+ // The fix: verdict=issues + empty cleaned = treat as LGTM
43
+ // (tested at integration level in reviewer.ts line ~395)
44
+ expect(isLgtmResult(cleaned)).toBe(true);
45
+ });
46
+ });
package/reviewer.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  AuthStorage,
18
18
  ModelRegistry,
19
19
  type AgentSessionEvent,
20
- } from "@mariozechner/pi-coding-agent";
20
+ } from "@earendil-works/pi-coding-agent";
21
21
 
22
22
  import { log, logReview, safeStringify, type ReviewToolCall } from "./logger";
23
23
 
@@ -392,7 +392,8 @@ export async function runReviewSession(prompt: string, opts: ReviewOptions): Pro
392
392
  }
393
393
 
394
394
  const cleanedText = cleanReviewText(reviewText);
395
- const isLgtm = verdict === "lgtm";
395
+ // If model said ISSUES_FOUND but produced no actual findings text, treat as LGTM
396
+ const isLgtm = verdict === "lgtm" || (verdict === "issues" && !cleanedText.trim());
396
397
  const durationMs = Date.now() - startTime;
397
398
 
398
399
  rlog(
package/session-kind.ts CHANGED
@@ -52,7 +52,7 @@
52
52
  * using distinct mock objects stay isolated without an explicit reset.
53
53
  */
54
54
 
55
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
55
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
56
56
 
57
57
  import { log } from "./logger";
58
58