@inceptionstack/pi-hard-no 1.0.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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * judge-skip-chain.ts — loop safeguard for consecutive judge-skip outcomes.
3
+ *
4
+ * CONTEXT: when the orchestrator's judge gate classifies a turn as read-only
5
+ * it emits a `{ type: "skipped", reason: "judge_read_only" }` outcome. The
6
+ * extension surfaces that in chat with `triggerTurn: true` so the agent keeps
7
+ * working (e.g. "ran `git status`, now ready to push"). Without a cap, an
8
+ * unlucky agent + judge combo could loop forever:
9
+ *
10
+ * agent reads → judge skips → triggerTurn → agent reads → judge skips → …
11
+ *
12
+ * SHAPE: a small state machine owned by `index.ts`. Each `judge_read_only`
13
+ * outcome increments the counter; any other outcome resets it. Once the
14
+ * counter exceeds `maxChain`, we still post the skip message to chat (the
15
+ * user paid for the judge call — show them it ran) but set `triggerTurn=false`
16
+ * so the agent halts and waits for input.
17
+ *
18
+ * The message text is split into a pure `formatJudgeSkipMessage` helper so
19
+ * we can unit-test the copy without instantiating the tracker.
20
+ *
21
+ * TESTING: pure TS, no SDK imports, no I/O. Drop-in replaceable in `index.ts`.
22
+ */
23
+
24
+ /**
25
+ * Default cap for `triggerTurn: true` judge-skip replies. Chosen small on
26
+ * purpose — three chained "read-only" turns is already a strong signal the
27
+ * agent is stuck exploring and the user should step in. Overridable via the
28
+ * `JudgeSkipChain` constructor for tests and future tuning.
29
+ */
30
+ export const DEFAULT_MAX_JUDGE_SKIP_CHAIN = 3;
31
+
32
+ /** Payload returned from `JudgeSkipChain.handleJudgeSkip`. */
33
+ export interface JudgeSkipMessage {
34
+ /** Markdown message body to post to chat. */
35
+ content: string;
36
+ /** Whether to request another agent turn. `false` once the cap is exceeded. */
37
+ triggerTurn: boolean;
38
+ /** Consecutive-skip count after this invocation. Useful for logging/tests. */
39
+ count: number;
40
+ /** True when this call crossed the cap (message includes the "chain reached" warning). */
41
+ capReached: boolean;
42
+ }
43
+
44
+ /**
45
+ * Format the chat-message body for a judge-skip outcome.
46
+ *
47
+ * Pure: same inputs → same output. No state, no side effects.
48
+ *
49
+ * @param count consecutive-skip counter value *after* this skip was recorded
50
+ * @param maxChain cap above which `triggerTurn` is suppressed
51
+ * @param judgeModel full "provider/model-id" string — only the tail is shown
52
+ * @param shouldTrigger whether the caller will still request another turn
53
+ */
54
+ export function formatJudgeSkipMessage(
55
+ count: number,
56
+ maxChain: number,
57
+ judgeModel: string,
58
+ shouldTrigger: boolean,
59
+ ): string {
60
+ const baseMsg = `⚖️ **Review skipped by judge** — all bash commands this turn classified as read-only (no file mutation). Skipping the main review.`;
61
+ const modelShort = judgeModel.split("/").pop() || judgeModel;
62
+ const footer = `_Model: \`${modelShort}\` — toggle with \`/review-judge-toggle\`_`;
63
+
64
+ if (shouldTrigger) {
65
+ return `${baseMsg}\n\n${footer}`;
66
+ }
67
+ return `${baseMsg}\n\n⚠️ Chain of ${count} consecutive judge-skips reached — not triggering another turn to avoid a loop. Reply to me or \`/review-judge-toggle\` off if you want to proceed.\n\n${footer}`;
68
+ }
69
+
70
+ /**
71
+ * Tracks consecutive `judge_read_only` skips across an extension session.
72
+ *
73
+ * Call `handleJudgeSkip(model)` for each such outcome; call `reset()` for
74
+ * every other outcome type (completed / error / cancelled / max_loops / non-
75
+ * judge skip reasons) and at session boundaries.
76
+ */
77
+ export class JudgeSkipChain {
78
+ private count = 0;
79
+ readonly maxChain: number;
80
+
81
+ constructor(maxChain: number = DEFAULT_MAX_JUDGE_SKIP_CHAIN) {
82
+ // Guard: a zero or negative cap would suppress triggerTurn immediately,
83
+ // which contradicts the feature's "allow some agent progress" intent.
84
+ // Treat it as a configuration error and fall back to the default rather
85
+ // than silently producing a confusing UX.
86
+ this.maxChain = maxChain > 0 ? maxChain : DEFAULT_MAX_JUDGE_SKIP_CHAIN;
87
+ }
88
+
89
+ /**
90
+ * Record a judge-skip outcome and compute the chat payload to emit.
91
+ * Increments the internal counter; does NOT mutate anything else.
92
+ */
93
+ handleJudgeSkip(judgeModel: string): JudgeSkipMessage {
94
+ this.count += 1;
95
+ const shouldTrigger = this.count <= this.maxChain;
96
+ return {
97
+ content: formatJudgeSkipMessage(this.count, this.maxChain, judgeModel, shouldTrigger),
98
+ triggerTurn: shouldTrigger,
99
+ count: this.count,
100
+ capReached: !shouldTrigger,
101
+ };
102
+ }
103
+
104
+ /** Reset the consecutive-skip counter. Called on any non-judge-skip outcome. */
105
+ reset(): void {
106
+ this.count = 0;
107
+ }
108
+
109
+ /** Current consecutive-skip count. Exposed for logging/diagnostics. */
110
+ getCount(): number {
111
+ return this.count;
112
+ }
113
+ }
package/judge.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * judge.ts — LLM-backed bash-command classifier (the "judge").
3
+ *
4
+ * ROLE: narrow duplicate-review suppressor. The orchestrator calls this ONLY
5
+ * for bash commands that the deterministic classifier in `changes.ts` flagged
6
+ * as potentially file-modifying but aren't definitively so (e.g. commands
7
+ * containing unknown shell builtins like `echo` that the static allowlist
8
+ * doesn't cover). The judge returns one of:
9
+ *
10
+ * - inspection_vcs_noop: reads/reports state only, no mutation
11
+ * - modifying: changes files / git / deps / env
12
+ * - unsure: ambiguous, truncated, or unknown
13
+ *
14
+ * FAIL-OPEN: any failure (timeout, parse error, transport, missing model,
15
+ * missing API key) maps to `unsure`. Callers treat `unsure` and `modifying`
16
+ * identically (both → run the main review), so the judge can only ever
17
+ * suppress a review when it's confidently sure the turn was read-only.
18
+ *
19
+ * DESIGN: runner is injected so tests can mock without spinning up real
20
+ * pi sessions, mirroring the pattern used by `reviewer.ts` + `orchestrator.ts`.
21
+ */
22
+
23
+ import {
24
+ AuthStorage,
25
+ ModelRegistry,
26
+ SessionManager,
27
+ createAgentSession,
28
+ type AgentSessionEvent,
29
+ } from "@mariozechner/pi-coding-agent";
30
+
31
+ import { log } from "./logger";
32
+
33
+ /** The three output classes the judge can return. */
34
+ export const JUDGE_CLASSES = ["inspection_vcs_noop", "modifying", "unsure"] as const;
35
+ export type BashClassification = (typeof JUDGE_CLASSES)[number];
36
+
37
+ export interface JudgeOptions {
38
+ signal: AbortSignal;
39
+ cwd: string;
40
+ /** Model to invoke. Defaults handled by callers; keep explicit here for testability. */
41
+ model: string;
42
+ /** Max wall-clock for the classifier call. Defaults to 10s. */
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Low-level judge runner contract: given a single bash command, return the
48
+ * raw model text plus whether the outer timeout fired. Separated so tests
49
+ * can mock without going through createAgentSession.
50
+ */
51
+ export type JudgeRunner = (command: string, opts: JudgeOptions) => Promise<{ text: string }>;
52
+
53
+ /** Same prompt text we validated in `eval/run-eval.mjs` (prompt v1). */
54
+ const PROMPT = `You classify ONE bash command into exactly one of three classes for an automated code review system.
55
+
56
+ CLASSES:
57
+ - inspection_vcs_noop: reads/reports state only, no file/git/dep/process/network/env mutation
58
+ - modifying: may change files, git index/commits/branches/remotes, deps, artifacts, processes, services, permissions, caches, or env
59
+ - unsure: ambiguous, truncated, unknown executable/script, or not confidently classifiable
60
+
61
+ TAXONOMY (authoritative):
62
+ - ls, pwd, cat, head, tail, wc, rg, grep, find (no -delete/-exec), sed -n, test, echo, printf, true, false → inspection_vcs_noop (only if not redirecting output)
63
+ - git status/diff/log/show/rev-parse/branch --show-current → inspection_vcs_noop
64
+ - git add/commit/push/pull/merge/rebase/reset/checkout/switch/stash/clean/tag → modifying
65
+ - touch/cp/mv/rm/mkdir/rmdir/chmod/chown, redirections >, >>, tee, truncate → modifying
66
+ - npm/pnpm/yarn/pip/cargo install, make, cargo build, npm run format, codegen scripts → modifying
67
+ - kill/pkill/systemctl, docker run, docker compose up → modifying
68
+ - sed -i, perl -pi → modifying (in-place edit)
69
+ - ./script.sh or npm run <unknown> → unsure unless clearly read-only
70
+ - truncated command (e.g. "git commi") → unsure
71
+ - Compound commands with &&, ;, ||, pipes, subshells: ANY modifying part → modifying; ANY unknown/truncated → unsure; otherwise the class of the safest-subset.
72
+
73
+ OUTPUT: return ONLY this JSON, no prose, no markdown fences:
74
+ {"classification":"inspection_vcs_noop"|"modifying"|"unsure"}
75
+
76
+ Command to classify:
77
+ `;
78
+
79
+ /**
80
+ * Parse the judge's raw response into a classification.
81
+ * Strips optional ```json``` fences, tolerates minor whitespace, falls back
82
+ * to regex extraction if JSON parse fails, and ultimately returns `unsure`
83
+ * on any ambiguity.
84
+ */
85
+ export function parseJudgeResponse(raw: string): BashClassification {
86
+ let s = raw.trim();
87
+ const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/);
88
+ if (fenced) s = fenced[1].trim();
89
+
90
+ try {
91
+ const obj = JSON.parse(s);
92
+ const c = obj?.classification;
93
+ if ((JUDGE_CLASSES as readonly string[]).includes(c)) return c as BashClassification;
94
+ } catch {
95
+ /* fall through to regex fallback */
96
+ }
97
+
98
+ const alt = JUDGE_CLASSES.join("|");
99
+ const m = s.match(new RegExp(`\\b(${alt})\\b`));
100
+ if (m) return m[1] as BashClassification;
101
+ return "unsure";
102
+ }
103
+
104
+ /**
105
+ * Run the judge on a single bash command. Always resolves (never rejects);
106
+ * any failure collapses to `unsure` so the caller's skip logic stays safe.
107
+ */
108
+ export async function classifyBashCommand(
109
+ runner: JudgeRunner,
110
+ command: string,
111
+ opts: JudgeOptions,
112
+ ): Promise<BashClassification> {
113
+ if (!command || typeof command !== "string") return "unsure";
114
+ try {
115
+ const { text } = await runner(command, opts);
116
+ return parseJudgeResponse(text);
117
+ } catch (err: any) {
118
+ log(`judge: classify failed (${err?.message ?? err}) → unsure`);
119
+ return "unsure";
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Production judge runner: spawns a fresh in-memory pi session, sends the
125
+ * classifier prompt, captures the assistant response, and cleans up.
126
+ *
127
+ * Mirrors the session lifecycle from `reviewer.ts` but without any tools
128
+ * (the judge is pure text-in-text-out — no file reading, no exploration).
129
+ */
130
+ export const defaultJudgeRunner: JudgeRunner = async (command, opts) => {
131
+ const timeoutMs = opts.timeoutMs ?? 10_000;
132
+ const authStorage = AuthStorage.create();
133
+ const modelRegistry = ModelRegistry.create(authStorage);
134
+
135
+ const [provider, modelId] = opts.model.split("/", 2);
136
+ if (!provider || !modelId) throw new Error(`bad judge model id: ${opts.model}`);
137
+ const model = modelRegistry.find(provider, modelId);
138
+ if (!model) throw new Error(`judge model not found: ${opts.model}`);
139
+
140
+ const { session } = await createAgentSession({
141
+ cwd: opts.cwd,
142
+ sessionManager: SessionManager.inMemory(),
143
+ authStorage,
144
+ modelRegistry,
145
+ tools: [],
146
+ });
147
+
148
+ let text = "";
149
+ let unsub = () => {};
150
+ let timer: ReturnType<typeof setTimeout> | undefined;
151
+
152
+ try {
153
+ await session.setModel(model);
154
+ session.setThinkingLevel("off");
155
+
156
+ unsub = session.subscribe((ev: AgentSessionEvent) => {
157
+ if (ev.type === "message_start" && (ev.message as any)?.role === "assistant") text = "";
158
+ if (ev.type === "message_update" && ev.assistantMessageEvent.type === "text_delta") {
159
+ text += ev.assistantMessageEvent.delta;
160
+ }
161
+ });
162
+
163
+ // Race: signal-abort | timeout | prompt-resolves.
164
+ await new Promise<void>((resolve, reject) => {
165
+ let settled = false;
166
+ const abortH = () => {
167
+ if (settled) return;
168
+ settled = true;
169
+ if (timer) clearTimeout(timer);
170
+ session.abort().finally(() => reject(new Error("aborted")));
171
+ };
172
+ if (opts.signal.aborted) return abortH();
173
+ opts.signal.addEventListener("abort", abortH, { once: true });
174
+
175
+ timer = setTimeout(() => {
176
+ if (settled) return;
177
+ settled = true;
178
+ opts.signal.removeEventListener("abort", abortH);
179
+ session.abort().finally(() => reject(new Error("judge timeout")));
180
+ }, timeoutMs);
181
+
182
+ session.prompt(PROMPT + command).then(
183
+ () => {
184
+ if (settled) return;
185
+ settled = true;
186
+ if (timer) clearTimeout(timer);
187
+ opts.signal.removeEventListener("abort", abortH);
188
+ resolve();
189
+ },
190
+ (err) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ if (timer) clearTimeout(timer);
194
+ opts.signal.removeEventListener("abort", abortH);
195
+ reject(err);
196
+ },
197
+ );
198
+ });
199
+
200
+ return { text };
201
+ } finally {
202
+ try {
203
+ unsub();
204
+ } catch {
205
+ /* ignore */
206
+ }
207
+ try {
208
+ session.dispose();
209
+ } catch {
210
+ /* ignore */
211
+ }
212
+ }
213
+ };
package/logger.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * logger.ts — File logger for pi-hard-no
3
+ *
4
+ * Two outputs under ~/.pi/.hardno/:
5
+ * review.log — free-text timestamped lines (rotates at 1MB)
6
+ * reviews/*.json — one structured JSON file per completed review
7
+ *
8
+ * Uses sync writes to guarantee output even in complex async flows.
9
+ */
10
+
11
+ import {
12
+ appendFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ renameSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ const LOG_DIR = join(homedir(), ".pi", ".hardno");
25
+ const LOG_FILE = join(LOG_DIR, "review.log");
26
+ const LOG_OLD = join(LOG_DIR, "review.log.old");
27
+ const REVIEWS_DIR = join(LOG_DIR, "reviews");
28
+ const MAX_LOG_SIZE = 1_000_000; // 1MB
29
+
30
+ let initialized = false;
31
+
32
+ function ensureDirs() {
33
+ if (initialized) return;
34
+ try {
35
+ mkdirSync(LOG_DIR, { recursive: true });
36
+ mkdirSync(REVIEWS_DIR, { recursive: true });
37
+ initialized = true;
38
+ } catch {
39
+ // best effort
40
+ }
41
+ }
42
+
43
+ function maybeRotate() {
44
+ try {
45
+ const s = statSync(LOG_FILE);
46
+ if (s.size > MAX_LOG_SIZE) {
47
+ try {
48
+ renameSync(LOG_FILE, LOG_OLD);
49
+ } catch {
50
+ /* ok */
51
+ }
52
+ }
53
+ } catch {
54
+ // file doesn't exist yet
55
+ }
56
+ }
57
+
58
+ function ts(): string {
59
+ return new Date().toISOString();
60
+ }
61
+
62
+ function safeStringify(a: any): string {
63
+ if (typeof a === "string") return a;
64
+ try {
65
+ return JSON.stringify(a);
66
+ } catch {
67
+ return String(a);
68
+ }
69
+ }
70
+
71
+ export { safeStringify };
72
+
73
+ export function log(...args: any[]) {
74
+ ensureDirs();
75
+ const line = `[${ts()}] ${args.map(safeStringify).join(" ")}\n`;
76
+ try {
77
+ appendFileSync(LOG_FILE, line);
78
+ } catch {
79
+ // best effort
80
+ }
81
+ }
82
+
83
+ /** Log and also rotate if needed (call once per review cycle) */
84
+ export function logRotate(...args: any[]) {
85
+ maybeRotate();
86
+ log(...args);
87
+ }
88
+
89
+ // ── Structured review history ──────────────────────
90
+
91
+ export interface ReviewToolCall {
92
+ name: string;
93
+ args?: any;
94
+ timestamp: string;
95
+ }
96
+
97
+ export interface ReviewLogEntry {
98
+ timestamp: string;
99
+ /** Unique id for this review cycle (e.g. "r-a3f71c08"). Matches the prefix used in review.log lines. */
100
+ reviewId?: string;
101
+ durationMs: number;
102
+ model: string;
103
+ thinkingLevel: string;
104
+ isLgtm: boolean;
105
+ promptLength: number;
106
+ rawText: string;
107
+ cleanedText: string;
108
+ filesReviewed: string[];
109
+ toolCalls: ReviewToolCall[];
110
+ label?: string;
111
+ }
112
+
113
+ /**
114
+ * Write a structured JSON record for a single review.
115
+ * Filename: <timestamp>_<hardno|issues>[_<reviewId>].json
116
+ * The reviewId suffix is appended when provided so logs from the same
117
+ * review cycle can be correlated across review.log and reviews/*.json.
118
+ */
119
+ export function logReview(entry: ReviewLogEntry): string | null {
120
+ ensureDirs();
121
+ const safeTs = entry.timestamp.replace(/[:.]/g, "-");
122
+ const verdict = entry.isLgtm ? "lgtm" : "issues";
123
+ const idSuffix = entry.reviewId ? `_${entry.reviewId}` : "";
124
+ const filename = `${safeTs}_${verdict}${idSuffix}.json`;
125
+ const fullPath = join(REVIEWS_DIR, filename);
126
+ try {
127
+ writeFileSync(fullPath, JSON.stringify(entry, null, 2));
128
+ return fullPath;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Remove all pi-hard-no log/review history files.
136
+ * Wipes `review.log`, the rotated `review.log.old`, and every
137
+ * `reviews/*.json` structured record. Does NOT touch user config
138
+ * (settings.json, review-rules.md, etc.) — only the append-only
139
+ * history pi-hard-no owns.
140
+ *
141
+ * Returns a summary of what was removed.
142
+ */
143
+ export function cleanLogs(): { logsRemoved: number; reviewsRemoved: number } {
144
+ let logsRemoved = 0;
145
+ let reviewsRemoved = 0;
146
+ for (const file of [LOG_FILE, LOG_OLD]) {
147
+ // rmSync({ force: true }) never throws for missing files, so a bare try/
148
+ // catch would over-count. Check existence first so the reported number
149
+ // reflects what was actually removed.
150
+ if (!existsSync(file)) continue;
151
+ try {
152
+ rmSync(file, { force: true });
153
+ logsRemoved++;
154
+ } catch {
155
+ /* permissions etc.; best-effort */
156
+ }
157
+ }
158
+ try {
159
+ const files = readdirSync(REVIEWS_DIR);
160
+ for (const f of files) {
161
+ if (!f.endsWith(".json")) continue;
162
+ try {
163
+ rmSync(join(REVIEWS_DIR, f), { force: true });
164
+ reviewsRemoved++;
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+ } catch {
170
+ /* reviews dir might not exist yet */
171
+ }
172
+ return { logsRemoved, reviewsRemoved };
173
+ }
174
+
175
+ export { LOG_FILE, LOG_DIR, REVIEWS_DIR };
@@ -0,0 +1,83 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { log } from "./logger";
4
+ import type { ReviewResult } from "./reviewer";
5
+
6
+ /**
7
+ * Format a review-id footer line for appending to a code-review message.
8
+ * Returns "" when no id is supplied, so call sites can unconditionally inline it.
9
+ *
10
+ * Single source of truth for the footer format — callers outside message-sender
11
+ * (e.g. the architect message in index.ts) should use this helper rather than
12
+ * inlining the markup, so the format stays consistent everywhere.
13
+ */
14
+ export function formatReviewIdFooter(reviewId: string | undefined): string {
15
+ if (!reviewId) return "";
16
+ return `\n\n_review-id: \`${reviewId}\`_`;
17
+ }
18
+
19
+ /**
20
+ * Format file paths as a compact tree.
21
+ */
22
+ function formatFileTree(files: string[]): string {
23
+ if (files.length === 0) return "";
24
+ const sorted = [...files].sort();
25
+ return sorted.map((f) => ` ${f}`).join("\n");
26
+ }
27
+
28
+ /**
29
+ * Send the appropriate review result message (LGTM or issues found).
30
+ */
31
+ export function sendReviewResult(
32
+ pi: ExtensionAPI,
33
+ result: ReviewResult,
34
+ label: string,
35
+ opts?: {
36
+ showLoopCount?: string;
37
+ reviewedFiles?: string[];
38
+ triggerTurn?: boolean;
39
+ /** Optional unique id for this review cycle, appended as a footer line for log correlation. */
40
+ reviewId?: string;
41
+ },
42
+ ): void {
43
+ // If no files were reviewed and it's LGTM, silently skip — nothing to report.
44
+ // Always show issues even with zero files (tool-call-only reviews can find bugs).
45
+ if (result.isLgtm && opts?.reviewedFiles && opts.reviewedFiles.length === 0) {
46
+ log(`reviewer: skipping LGTM message — zero reviewed files`);
47
+ return;
48
+ }
49
+
50
+ const duration = `${(result.durationMs / 1000).toFixed(1)}s`;
51
+ const reviewedFiles = opts?.reviewedFiles ?? [];
52
+ const fileList =
53
+ reviewedFiles.length > 0
54
+ ? `\n\n**Reviewed files:**\n\`\`\`\n${formatFileTree(reviewedFiles)}\n\`\`\``
55
+ : "";
56
+ // Footer line with the review id, placed under the reviewed-files block (or under the header when no files).
57
+ // Format: `_review-id: r-abcdef01_` — small/italic, unobtrusive, but visible if scanning.
58
+ // The agent sees this literally in the message content so logs in ~/.pi/.hardno can be correlated.
59
+ const idFooter = formatReviewIdFooter(opts?.reviewId);
60
+
61
+ if (result.isLgtm) {
62
+ log(`reviewer: LGTM (${duration}, tools=${result.toolCalls.length})`);
63
+ pi.sendMessage(
64
+ {
65
+ customType: "code-review",
66
+ content: `✅ **Automated Code Review**${label ? ` (${label})` : ""} — ${duration}\n\nReview found no issues. Looks good!${fileList}${idFooter}\n\nIf you were waiting to push until after reviews were done — all reviews are done, no issues found. Safe to push.`,
67
+ display: true,
68
+ },
69
+ { triggerTurn: opts?.triggerTurn ?? true, deliverAs: "followUp" },
70
+ );
71
+ } else {
72
+ log(`reviewer: issues found (${duration}, tools=${result.toolCalls.length})`);
73
+ const loopInfo = opts?.showLoopCount ? ` (${opts.showLoopCount})` : "";
74
+ pi.sendMessage(
75
+ {
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.`,
78
+ display: true,
79
+ },
80
+ { triggerTurn: opts?.triggerTurn ?? true, deliverAs: "followUp" },
81
+ );
82
+ }
83
+ }