@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.
package/index.ts ADDED
@@ -0,0 +1,892 @@
1
+ /**
2
+ * pi-hard-no — Pi extension
3
+ *
4
+ * After each agent turn that modifies files, spawns a fresh pi instance
5
+ * to do a code review. Feeds the review feedback back to the main agent
6
+ * as a steering message so it can decide whether to fix anything.
7
+ *
8
+ * Configuration (optional, in cwd/.hardno/ or ~/.pi/.hardno/, local takes precedence):
9
+ * settings.json — { "maxReviewLoops": 100, "toggleShortcut": "alt+r", "cancelShortcut": "alt+x" }
10
+ * review-rules.md — custom review rules appended to prompt
11
+ *
12
+ * UX:
13
+ * - Status bar shows hard-no on/off + pending file count
14
+ * - Alt+R toggles review on/off (configurable: toggleShortcut)
15
+ * - Alt+X or /cancel-review cancels an in-progress review (cancelShortcut configurable, default: none)
16
+ * - Ctrl+Alt+R also cancels (terminals that support it)
17
+ * - /review command toggles, /review <N> reviews last N commits
18
+ *
19
+ * Install:
20
+ * pi install npm:@inceptionstack/pi-hard-no
21
+ * or: cp index.ts ~/.pi/agent/extensions/pi-hard-no.ts
22
+ */
23
+
24
+ import { type ExtensionAPI, isToolCallEventType } from "@mariozechner/pi-coding-agent";
25
+
26
+ import {
27
+ type AutoReviewSettings,
28
+ DEFAULT_SETTINGS,
29
+ loadSettings,
30
+ loadReviewRules,
31
+ loadAutoReviewRules,
32
+ loadShortcutSettingsSync,
33
+ } from "./settings";
34
+ import { runReviewSession } from "./reviewer";
35
+ import { classifyBashCommand, defaultJudgeRunner } from "./judge";
36
+ import { JudgeSkipChain } from "./judge-skip-chain";
37
+ import { isSpawnedSubSession } from "./session-kind";
38
+ import { sendReviewResult, formatReviewIdFooter } from "./message-sender";
39
+ import { type TrackedToolCall, isFileModifyingTool, collectModifiedPaths } from "./changes";
40
+ import { getBestReviewContent } from "./context";
41
+ import { loadIgnorePatterns } from "./ignore";
42
+ import { loadArchitectRules } from "./architect";
43
+ import { findGitRoot, resolveAllGitRoots } from "./git-roots";
44
+ import { cleanLogs, log, logRotate } from "./logger";
45
+ import { ReviewOrchestrator, type ReviewOutcome } from "./orchestrator";
46
+ import { registerReviewCommands, type ManualReviewController } from "./commands";
47
+ import {
48
+ startReviewDisplay,
49
+ inferArchModules,
50
+ buildArchDiagram,
51
+ type ReviewDisplayHandle,
52
+ } from "./review-display";
53
+
54
+ const MAX_TRACKED_FILES = 1000;
55
+
56
+ // ── Extension ────────────────────────────────────────
57
+
58
+ export default function (pi: ExtensionAPI) {
59
+ let architectRules: string | null = null;
60
+
61
+ let settings: AutoReviewSettings = { ...DEFAULT_SETTINGS };
62
+ let customRules: string | null = null;
63
+ let autoReviewRules: string | null = null;
64
+ let ignorePatterns: string[] | null = null;
65
+
66
+ let reviewDisplay: ReviewDisplayHandle | null = null;
67
+ let manualReviews: ManualReviewController | null = null;
68
+
69
+ let agentToolCalls: TrackedToolCall[] = [];
70
+ const modifiedFiles = new Set<string>();
71
+ const detectedGitRoots = new Set<string>(); // git repos discovered from file paths or bash git commands
72
+ const pendingArgs = new Map<string, { name: string; input: any }>();
73
+ let lastUserMessage: string | null = null; // captured from before_agent_start
74
+
75
+ // Load shortcut config synchronously at init (before session_start)
76
+ // so registerShortcut() uses the configured keys.
77
+ const shortcutConfig = loadShortcutSettingsSync(process.cwd());
78
+
79
+ const orchestrator = new ReviewOrchestrator({
80
+ runner: runReviewSession,
81
+ contentBuilder: (input) =>
82
+ getBestReviewContent(
83
+ pi,
84
+ input.agentToolCalls,
85
+ input.onStatus,
86
+ input.ignorePatterns,
87
+ input.gitRoots,
88
+ input.limits,
89
+ ),
90
+ // Judge wiring: closure over the default runner so the orchestrator
91
+ // stays test-mockable (tests pass their own `judge` fn). When the user
92
+ // hasn't enabled `judgeEnabled` in settings, the orchestrator skips
93
+ // this entirely — zero runtime cost.
94
+ judge: (command, opts) => classifyBashCommand(defaultJudgeRunner, command, opts),
95
+ });
96
+
97
+ // ── Helpers ──────────────────────────────────────
98
+
99
+ /**
100
+ * Start the visual review progress widget and return callbacks
101
+ * for activity updates and tool call tracking.
102
+ */
103
+ function startReviewWidget(
104
+ ctx: { ui: any; hasUI?: boolean },
105
+ files: string[],
106
+ timeoutMs = 0,
107
+ ): {
108
+ onActivity: (desc: string) => void;
109
+ onToolCall: (toolName: string, targetPath: string | null) => void;
110
+ } {
111
+ const noOp = () => {};
112
+ if (!ctx.hasUI) return { onActivity: noOp, onToolCall: noOp };
113
+
114
+ reviewDisplay = startReviewDisplay(ctx.ui, {
115
+ files,
116
+ activeFile: null,
117
+ activity: "starting…",
118
+ loopCount: orchestrator.currentLoopCount,
119
+ maxLoops: settings.maxReviewLoops,
120
+ model: settings.model,
121
+ startTime: Date.now(),
122
+ timeoutMs,
123
+ toolCounts: new Map(),
124
+ lastToolDesc: new Map(),
125
+ totalToolCalls: 0,
126
+ isArchitect: false,
127
+ archDiagram: null,
128
+ archActiveModule: null,
129
+ });
130
+
131
+ return {
132
+ onActivity: (desc: string) => {
133
+ if (reviewDisplay) reviewDisplay.update({ activity: desc });
134
+ },
135
+ onToolCall: (toolName: string, targetPath: string | null) => {
136
+ if (reviewDisplay) reviewDisplay.recordToolCall(toolName, targetPath);
137
+ },
138
+ };
139
+ }
140
+
141
+ function resetTrackingState(ctx: { ui: any; hasUI?: boolean }) {
142
+ agentToolCalls = [];
143
+ modifiedFiles.clear();
144
+ // NOTE: detectedGitRoots is NOT cleared here — it's session-level state.
145
+ // It tracks repos the agent has worked in across turns, used by /review-all
146
+ // when ctx.cwd isn't itself a git repo.
147
+ pendingArgs.clear();
148
+ fileCapWarned = false;
149
+ // Don't clear skipStatusShowing here — finishReview calls resetTrackingState
150
+ // right after renderOutcome sets the skip status. The flag is only cleared
151
+ // in two places: the top of runAutoReview when the next review cycle
152
+ // starts, and tool_execution_start when the agent performs real file
153
+ // activity. (It used to also clear on agent_start, but that made the skip
154
+ // indicator vanish on the next user prompt — removed.)
155
+ updateStatus(ctx);
156
+ }
157
+
158
+ /**
159
+ * Clean up after a review completes (success, error, or cancel).
160
+ * Pass resetTracking=false for /review N which doesn't track files.
161
+ */
162
+ function finishReview(ctx: { ui: any; hasUI?: boolean }, resetTracking = true) {
163
+ if (reviewDisplay) {
164
+ reviewDisplay.stop();
165
+ reviewDisplay = null;
166
+ }
167
+ if (resetTracking) {
168
+ resetTrackingState(ctx);
169
+ } else {
170
+ updateStatus(ctx);
171
+ }
172
+ }
173
+
174
+ /** Safely access ctx.ui, returning null if the context is stale. */
175
+ function safeGetUi(ctx: { ui: any; hasUI?: boolean }): any | null {
176
+ try {
177
+ return ctx.hasUI ? ctx.ui : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /** Check if there are pending file modifications awaiting review. */
184
+ function hasPendingFiles(): boolean {
185
+ if (!orchestrator.isEnabled) return false;
186
+ const realFiles = new Set(modifiedFiles);
187
+ realFiles.delete("(bash file op)");
188
+ return realFiles.size > 0;
189
+ }
190
+
191
+ function updateStatus(ctx: { ui: any; hasUI?: boolean }) {
192
+ // Don't overwrite a skip status message unless there's real activity
193
+ if (skipStatusShowing) return;
194
+ const ui = safeGetUi(ctx);
195
+ if (!ui) return;
196
+ const theme = ui.theme;
197
+ const label = theme.fg("accent", "hard-no");
198
+ const state = orchestrator.isEnabled ? theme.fg("success", "on") : theme.fg("dim", "off");
199
+
200
+ // Determine if push is currently blocked
201
+ const pushBlocked =
202
+ orchestrator.isEnabled &&
203
+ (orchestrator.isReviewing || orchestrator.lastHadIssues || hasPendingFiles());
204
+ const pushTag = pushBlocked ? ` ${theme.fg("error", "🔒 push blocked")}` : "";
205
+
206
+ // Judge indicator. Dim when on (it's a subtle assist); hidden when off.
207
+ // `⚖` (scales) reads as "judge" without needing a word.
208
+ const judgeTag = settings.judgeEnabled ? ` ${theme.fg("dim", "⚖ judge")}` : "";
209
+
210
+ if (manualReviews?.isReviewing || orchestrator.isReviewing) {
211
+ const cancelHint = shortcutConfig.cancelShortcut
212
+ ? `${shortcutConfig.cancelShortcut} or /cancel-review`
213
+ : "/cancel-review";
214
+ ui.setStatus(
215
+ "code-review",
216
+ `${label} ${theme.fg("warning", "reviewing…")}${pushTag}${judgeTag} ${theme.fg("dim", `(${cancelHint})`)}`,
217
+ );
218
+ return;
219
+ }
220
+
221
+ if (modifiedFiles.size > 0 || agentToolCalls.length > 0) {
222
+ // Include paths extracted from tool call args (e.g. edit path, bash file refs)
223
+ const toolPaths = collectModifiedPaths(agentToolCalls);
224
+ const allPaths = new Set([...modifiedFiles, ...toolPaths]);
225
+ allPaths.delete("(bash file op)");
226
+ const count = allPaths.size;
227
+ if (count > 0) {
228
+ const verb = orchestrator.isEnabled
229
+ ? theme.fg("muted", "will review")
230
+ : theme.fg("muted", "pending");
231
+ const issueIndicator = orchestrator.lastHadIssues
232
+ ? ` ${theme.fg("error", "issues found")}`
233
+ : "";
234
+ ui.setStatus(
235
+ "code-review",
236
+ `${label} ${state}${issueIndicator}${pushTag}${judgeTag} · ${verb} ${theme.fg("accent", String(count))} ${theme.fg("muted", count === 1 ? "file" : "files")} ${theme.fg("dim", "(Alt+R toggle)")}`,
237
+ );
238
+ return;
239
+ }
240
+ }
241
+
242
+ const issueIndicator = orchestrator.lastHadIssues
243
+ ? ` ${theme.fg("error", "issues found")}`
244
+ : "";
245
+ ui.setStatus(
246
+ "code-review",
247
+ `${label} ${state}${issueIndicator}${pushTag}${judgeTag} ${theme.fg("dim", "(Alt+R toggle)")}`,
248
+ );
249
+ }
250
+
251
+ let isToggling = false;
252
+ let fileCapWarned = false;
253
+ let skipStatusShowing = false;
254
+
255
+ // Loop safeguard for judge-skip chains. Each judge_read_only outcome that
256
+ // we follow up with triggerTurn bumps the counter; it resets on any other
257
+ // outcome type (completed / error / cancelled / max_loops / other skip
258
+ // reasons). If we hit the cap we still post the chat message but stop
259
+ // triggering new turns so a runaway "agent keeps exploring, judge keeps
260
+ // skipping" chain can't loop forever. State + message formatting live in
261
+ // judge-skip-chain.ts so they can be unit-tested without the pi SDK.
262
+ const judgeSkipChain = new JudgeSkipChain();
263
+
264
+ async function toggleReview(ctx: {
265
+ ui: any;
266
+ hasUI?: boolean;
267
+ cwd: string;
268
+ isIdle?: () => boolean;
269
+ }) {
270
+ if (isToggling) return;
271
+ isToggling = true;
272
+
273
+ try {
274
+ orchestrator.setEnabled(!orchestrator.isEnabled);
275
+ if (orchestrator.isEnabled) {
276
+ if (ctx.hasUI) ctx.ui.notify(`Review: on`, "info");
277
+ // Only prompt to review if agent is idle and there are pending files.
278
+ // If agent is mid-turn, silently enable — review triggers at next agent_end.
279
+ const idle = ctx.isIdle?.() ?? true;
280
+ if (modifiedFiles.size > 0 && ctx.hasUI && idle) {
281
+ const count = modifiedFiles.size;
282
+ const ok = await ctx.ui.confirm(
283
+ "Run review now?",
284
+ `${count} file${count > 1 ? "s" : ""} changed while review was off. Review them now?`,
285
+ { timeout: 30000 },
286
+ );
287
+ if (ok) {
288
+ await runAutoReview(ctx, "toggle");
289
+ return;
290
+ } else {
291
+ // User declined — clear pending so they don't get re-prompted
292
+ resetTrackingState(ctx);
293
+ }
294
+ }
295
+ } else {
296
+ if (ctx.hasUI) ctx.ui.notify(`Review: off`, "info");
297
+ }
298
+ updateStatus(ctx);
299
+ } finally {
300
+ isToggling = false;
301
+ }
302
+ }
303
+
304
+ async function runAutoReview(ctx: { ui: any; hasUI?: boolean; cwd: string }, source: string) {
305
+ try {
306
+ const allRoots = await resolveAllGitRoots(
307
+ pi,
308
+ ctx.cwd,
309
+ modifiedFiles,
310
+ collectModifiedPaths(agentToolCalls),
311
+ detectedGitRoots,
312
+ );
313
+
314
+ skipStatusShowing = false; // Review starting clears skip message
315
+ logRotate(source === "auto" ? "=== review start (auto) ===" : "=== review start ===");
316
+ log("cwd:", ctx.cwd);
317
+ log("gitRoots:", [...allRoots]);
318
+ log("modifiedFiles:", [...modifiedFiles]);
319
+ log("agentToolCalls:", agentToolCalls.length);
320
+
321
+ let reviewCallbacks: ReturnType<typeof startReviewWidget> | null = null;
322
+ const hadIssuesBefore = orchestrator.lastHadIssues;
323
+ const outcome = await orchestrator.handleAgentEnd({
324
+ agentToolCalls,
325
+ modifiedFiles,
326
+ gitRoots: allRoots,
327
+ cwd: ctx.cwd,
328
+ settings,
329
+ customRules,
330
+ autoReviewRules,
331
+ ignorePatterns,
332
+ architectRules,
333
+ lastUserMessage,
334
+ onActivity: (desc) => reviewCallbacks?.onActivity(desc),
335
+ onToolCall: (toolName, targetPath) => reviewCallbacks?.onToolCall(toolName, targetPath),
336
+ onArchitectActivity: (desc) => {
337
+ if (reviewDisplay) reviewDisplay.update({ activity: `architect: ${desc}` });
338
+ },
339
+ onArchitectToolCall: (toolName, targetPath) => {
340
+ if (reviewDisplay) reviewDisplay.recordToolCall(toolName, targetPath);
341
+ },
342
+ onContentReady: (files, _loopCount, timeoutMs) => {
343
+ try {
344
+ updateStatus(ctx);
345
+ reviewCallbacks = startReviewWidget(ctx, files, timeoutMs);
346
+ } catch (err: any) {
347
+ log(`WARNING: onContentReady callback failed: ${err?.message ?? err}`);
348
+ }
349
+ },
350
+ onArchitectStart: (files, timeoutMs) => {
351
+ try {
352
+ if (!reviewDisplay) return;
353
+ const ui = safeGetUi(ctx);
354
+ const uiTheme = ui?.theme;
355
+ if (!uiTheme?.fg || !uiTheme?.bold) {
356
+ reviewDisplay.setArchitectMode(files, undefined, timeoutMs);
357
+ return;
358
+ }
359
+ const modules = inferArchModules(files);
360
+ const theme = {
361
+ fg: uiTheme.fg.bind(uiTheme) as (c: string, t: string) => string,
362
+ bold: uiTheme.bold.bind(uiTheme) as (t: string) => string,
363
+ };
364
+ const archDiagram = buildArchDiagram(modules, null, theme);
365
+ reviewDisplay.setArchitectMode(files, archDiagram, timeoutMs);
366
+ } catch (err: any) {
367
+ log(`WARNING: onArchitectStart callback failed: ${err?.message ?? err}`);
368
+ log(`WARNING stack: ${err?.stack ?? "(no stack)"}`);
369
+ }
370
+ },
371
+ fileExists: async (path) => {
372
+ const result = await pi.exec("test", ["-e", path], { timeout: 3000 });
373
+ return result.code === 0;
374
+ },
375
+ });
376
+
377
+ if (outcome.type === "max_loops" && ctx.hasUI) {
378
+ ctx.ui.notify(
379
+ `Review: max loops reached (${settings.maxReviewLoops}). Toggle /review to reset.`,
380
+ "warning",
381
+ );
382
+ }
383
+ if (outcome.type === "cancelled" && ctx.hasUI) ctx.ui.notify("Review cancelled", "info");
384
+ if (outcome.type === "error") {
385
+ const errMsg = outcome.error.message;
386
+ log(`ERROR: Review failed: ${errMsg}`);
387
+ log(`ERROR stack: ${outcome.error.stack ?? "(no stack)"}`);
388
+ if (ctx.hasUI) ctx.ui.notify(`Review error: ${errMsg.slice(0, 200)}`, "error");
389
+ }
390
+
391
+ renderOutcome(outcome, ctx, hadIssuesBefore);
392
+ } catch (err: any) {
393
+ const errMsg = err?.message ?? String(err);
394
+ log(`ERROR: Review failed (outer): ${errMsg}`);
395
+ log(`ERROR stack (outer): ${err?.stack ?? "(no stack)"}`);
396
+ if (ctx.hasUI) ctx.ui.notify(`Review error: ${errMsg.slice(0, 200)}`, "error");
397
+ renderOutcome({ type: "error", error: err instanceof Error ? err : new Error(errMsg) }, ctx);
398
+ } finally {
399
+ finishReview(ctx);
400
+ }
401
+ }
402
+
403
+ // ── Tool call tracking ─────────────────────────────
404
+
405
+ pi.on("tool_execution_start", async (event, ctx) => {
406
+ pendingArgs.set(event.toolCallId, { name: event.toolName, input: event.args });
407
+
408
+ if (isFileModifyingTool(event.toolName)) {
409
+ skipStatusShowing = false; // Real file activity clears skip message
410
+ if (modifiedFiles.size < MAX_TRACKED_FILES) {
411
+ if (event.args?.path) modifiedFiles.add(event.args.path);
412
+ else modifiedFiles.add("(bash file op)");
413
+ } else if (!fileCapWarned) {
414
+ fileCapWarned = true;
415
+ log(`File tracking cap reached (${MAX_TRACKED_FILES}). Additional files won't be tracked.`);
416
+ }
417
+ updateStatus(ctx);
418
+ }
419
+
420
+ // Detect git repo roots from bash git commands
421
+ if (event.toolName === "bash") {
422
+ const cmd = event.args?.command ?? "";
423
+ if (/\bgit\b/.test(cmd)) {
424
+ // Extract -C <dir> if present
425
+ const cFlag = cmd.match(/git\s+-C\s+(\S+)/);
426
+ if (cFlag) {
427
+ const root = await findGitRoot(pi, cFlag[1]);
428
+ if (root) detectedGitRoots.add(root);
429
+ } else {
430
+ // Try cwd
431
+ const root = await findGitRoot(pi, ctx.cwd);
432
+ if (root) detectedGitRoots.add(root);
433
+ }
434
+ }
435
+ }
436
+ });
437
+
438
+ pi.on("tool_execution_end", async (event) => {
439
+ const pending = pendingArgs.get(event.toolCallId);
440
+ pendingArgs.delete(event.toolCallId);
441
+ const rawContent = event.result?.content;
442
+ agentToolCalls.push({
443
+ name: event.toolName,
444
+ input: pending?.input ?? {},
445
+ result: Array.isArray(rawContent)
446
+ ? rawContent
447
+ .filter((c: any) => c.type === "text")
448
+ .map((c: any) => c.text)
449
+ .join("\n")
450
+ .slice(0, 2000)
451
+ : undefined,
452
+ });
453
+ });
454
+
455
+ // ── Push guard: block git push when review is needed ──
456
+
457
+ /** Determine why push should be blocked, or null if push is allowed. */
458
+ function getPushBlockReason(): string | null {
459
+ if (!orchestrator.isEnabled) return null;
460
+ if (orchestrator.isReviewing) return "a code review is in progress";
461
+ if (orchestrator.lastHadIssues) return "the last review found unresolved issues";
462
+ if (hasPendingFiles()) return "files have been modified but not yet reviewed";
463
+ return null;
464
+ }
465
+
466
+ pi.on("tool_call", async (event, _ctx) => {
467
+ if (!isToolCallEventType("bash", event)) return;
468
+ const cmd = event.input.command ?? "";
469
+ // Match push as a git subcommand (allows flags like --no-pager, -C, -c between git and push)
470
+ // Excludes git stash push (stash operation, not remote push)
471
+ if (!/\bgit\s+(?:\S+\s+)*?push\b/.test(cmd) || /\bgit\s+stash\s+push\b/.test(cmd)) return;
472
+
473
+ const reason = getPushBlockReason();
474
+ if (!reason) return;
475
+
476
+ const hasOtherCommands = /&&|\|\||;/.test(cmd);
477
+ const hint = hasOtherCommands
478
+ ? " Your command had other parts chained with the push — re-run them without the push."
479
+ : "";
480
+
481
+ return {
482
+ block: true,
483
+ reason: `Push blocked: ${reason}.${hint} Push will be allowed after all reviews pass.`,
484
+ };
485
+ });
486
+
487
+ pi.on("before_agent_start", async (event) => {
488
+ if (event.prompt) {
489
+ lastUserMessage = event.prompt;
490
+ }
491
+ });
492
+
493
+ pi.on("agent_start", async (_event, ctx) => {
494
+ // Don't clear `skipStatusShowing` here. The skip indicator should persist
495
+ // across turns until:
496
+ // (a) a new review cycle starts — cleared in runAutoReview, or
497
+ // (b) the agent performs real file activity — cleared in
498
+ // tool_execution_start for file-modifying tools.
499
+ // Clearing it on every agent_start was making the indicator vanish as
500
+ // soon as the user sent their next prompt, even though nothing had
501
+ // actually changed review-wise.
502
+ resetTrackingState(ctx);
503
+ });
504
+
505
+ // ── Auto-review on agent_end ───────────────────────
506
+
507
+ function renderOutcome(
508
+ outcome: ReviewOutcome,
509
+ ctx: { ui: any; hasUI?: boolean },
510
+ hadIssuesBefore = false,
511
+ ) {
512
+ switch (outcome.type) {
513
+ case "skipped": {
514
+ // Show a brief status hint for skip reasons that indicate "nothing to review"
515
+ const ui = safeGetUi(ctx);
516
+ if (ui && outcome.reason !== "disabled") {
517
+ const theme = ui.theme;
518
+ const label = theme.fg("accent", "hard-no");
519
+ const reason =
520
+ outcome.reason === "no_file_changes" || outcome.reason === "no_real_files"
521
+ ? "no file changes"
522
+ : outcome.reason === "no_meaningful_changes" ||
523
+ outcome.reason === "fallback_too_small"
524
+ ? "no files to review"
525
+ : outcome.reason === "formatting_only"
526
+ ? "formatting only"
527
+ : outcome.reason === "duplicate_content"
528
+ ? "no new changes"
529
+ : outcome.reason === "judge_read_only"
530
+ ? "judge: read-only turn"
531
+ : null;
532
+ if (reason) {
533
+ skipStatusShowing = true;
534
+ // Visible "✓ review skipped" with reason dim next to it. Using the
535
+ // success color signals the "nothing needed reviewing" outcome as
536
+ // a positive (vs the dim gray it used to be, which was easy to
537
+ // miss). Status persists until the next review cycle starts
538
+ // (skipStatusShowing=false is set in runAutoReview) or real file
539
+ // activity in tool_execution_start.
540
+ ui.setStatus(
541
+ "code-review",
542
+ `${label} ${theme.fg("success", "✓ review skipped")} ${theme.fg("dim", `— ${reason}`)}`,
543
+ );
544
+ }
545
+ }
546
+
547
+ // If the previous review had issues and this skip means they're resolved,
548
+ // trigger a turn so the agent can continue working.
549
+ if (
550
+ hadIssuesBefore &&
551
+ (outcome.reason === "no_meaningful_changes" || outcome.reason === "fallback_too_small")
552
+ ) {
553
+ pi.sendMessage(
554
+ {
555
+ customType: "code-review",
556
+ content: `✅ **Review issues resolved** — previous issues are no longer present. You can continue working.`,
557
+ display: true,
558
+ },
559
+ { triggerTurn: true, deliverAs: "followUp" },
560
+ );
561
+ }
562
+
563
+ // Judge skips are worth surfacing in chat, not just the status bar:
564
+ // (a) the judge actually did work (an LLM call) — the user should see
565
+ // their opt-in feature operate
566
+ // (b) the status bar is transient; chat is a persistent audit trail
567
+ // We also triggerTurn so the agent can continue naturally (e.g. after
568
+ // a read-only `git status` the user may have asked for a push-if-clean
569
+ // flow; the agent needs to be woken up to proceed).
570
+ //
571
+ // Loop safeguard: `JudgeSkipChain` caps consecutive judge-skip
572
+ // triggers so a runaway "agent keeps exploring, judge keeps skipping"
573
+ // chain can't loop forever. Once the cap is hit we still post the
574
+ // chat message but suppress `triggerTurn` so the agent halts and
575
+ // waits for user input. User can /review-judge-toggle off or prompt
576
+ // manually. State + message formatting live in judge-skip-chain.ts.
577
+ if (outcome.reason === "judge_read_only") {
578
+ const { content, triggerTurn } = judgeSkipChain.handleJudgeSkip(settings.judgeModel);
579
+ pi.sendMessage(
580
+ {
581
+ customType: "code-review",
582
+ content,
583
+ display: true,
584
+ },
585
+ { triggerTurn, deliverAs: "followUp" },
586
+ );
587
+ } else {
588
+ // Non-judge skip reason — reset the chain counter so a later
589
+ // judge-skip gets the full benefit of the cap again.
590
+ judgeSkipChain.reset();
591
+ }
592
+ return;
593
+ }
594
+ case "cancelled":
595
+ judgeSkipChain.reset();
596
+ return;
597
+ case "max_loops":
598
+ judgeSkipChain.reset();
599
+ return;
600
+ case "error": {
601
+ judgeSkipChain.reset();
602
+ const errMsg = outcome.error.message;
603
+ pi.sendMessage(
604
+ {
605
+ customType: "code-review",
606
+ content: `⚠️ **Review failed**\n\n${errMsg}\n\nThe review could not complete. Check the logs in ~/.pi/.hardno/review.log for details. If this is a timeout, consider increasing reviewTimeoutMs in .hardno/settings.json.`,
607
+ display: true,
608
+ },
609
+ { triggerTurn: false, deliverAs: "followUp" },
610
+ );
611
+ return;
612
+ }
613
+ case "completed": {
614
+ judgeSkipChain.reset();
615
+ const hasArchitectStep = Boolean(outcome.architect);
616
+ const hasArchitectFailure = Boolean(outcome.architectFailure);
617
+ const hasFollowUp = hasArchitectStep || hasArchitectFailure;
618
+ // Always trigger a turn for ISSUES_FOUND so agent can fix.
619
+ // Also trigger for LGTM so agent can continue (push, etc.).
620
+ // Skip triggering only when architect (success or failure) follows — it sends its own message.
621
+ sendReviewResult(pi, outcome.senior.result, outcome.senior.label ?? "", {
622
+ showLoopCount: outcome.senior.loopInfo,
623
+ reviewedFiles: outcome.files,
624
+ triggerTurn: !hasFollowUp,
625
+ reviewId: outcome.senior.reviewId,
626
+ });
627
+
628
+ if (outcome.architectFailure) {
629
+ // Architect was supposed to run but failed (e.g. timed out). Make it visible
630
+ // instead of silently swallowing — the senior review already passed so the
631
+ // follow-up context for the agent is "big-picture check didn't finish".
632
+ const failure = outcome.architectFailure;
633
+ const architectIdFooter = formatReviewIdFooter(failure.reviewId);
634
+ const errMsg = failure.error.message || String(failure.error);
635
+ pi.sendMessage(
636
+ {
637
+ customType: "code-review",
638
+ content: `🏗️ **Architect Review failed**\n\nThe cross-file architecture review did not complete: \`${errMsg.slice(0, 300)}\`\n\nIndividual file reviews passed, but the big-picture check didn't finish. You may want to — at your discretion — rerun the review, inspect cross-file consistency manually, or proceed.${architectIdFooter}`,
639
+ display: true,
640
+ },
641
+ { triggerTurn: true, deliverAs: "followUp" },
642
+ );
643
+ return;
644
+ }
645
+
646
+ if (!outcome.architect) return;
647
+
648
+ const architectResult = outcome.architect.result;
649
+ const architectIdFooter = formatReviewIdFooter(outcome.architect.reviewId);
650
+ if (architectResult.isLgtm) {
651
+ pi.sendMessage(
652
+ {
653
+ customType: "code-review",
654
+ content: `🏗️ **Architect Review**\n\nFinal architecture review found no issues. Everything fits together.${architectIdFooter}\n\nIf you were waiting to push until after reviews were done — all reviews are done, no issues found. Safe to push.`,
655
+ display: true,
656
+ },
657
+ { triggerTurn: true, deliverAs: "followUp" },
658
+ );
659
+ } else {
660
+ pi.sendMessage(
661
+ {
662
+ customType: "code-review",
663
+ content: `🏗️ **Architect Review**\n\nFinal architecture review found potential issues:\n\n${architectResult.text}${architectIdFooter}\n\nPlease review these findings. These are big-picture concerns that individual reviews may have missed.\n\n⚠️ **Do NOT push to remote yet.** Fix any issues first.`,
664
+ display: true,
665
+ },
666
+ { triggerTurn: true, deliverAs: "followUp" },
667
+ );
668
+ }
669
+ }
670
+ }
671
+ }
672
+
673
+ pi.on("agent_end", async (event, ctx) => {
674
+ // First guard: if pi-hard-no is loaded into a spawned sub-session (e.g. the
675
+ // reviewer session created by runReviewSession), do nothing. Without
676
+ // this, our handler recursively triggers a review inside the reviewer
677
+ // session, then crashes with "ctx is stale" once reviewer.ts disposes
678
+ // that session. See session-kind.ts for the full rationale + detection.
679
+ if (isSpawnedSubSession(pi)) return;
680
+
681
+ // Don't interfere if a toggle-review is in progress (confirm dialog open)
682
+ if (isToggling) return;
683
+
684
+ // Reentrancy guard: if a review is already running (e.g. still winding down
685
+ // after cancel), don't start another one.
686
+ if (orchestrator.isReviewing) {
687
+ log("agent_end: skipping — review still in progress");
688
+ return;
689
+ }
690
+
691
+ // Don't review if the agent was aborted (Esc pressed)
692
+ const messages = (event as any).messages ?? [];
693
+ const lastAssistant = [...messages].reverse().find((m: any) => m.role === "assistant");
694
+ if (lastAssistant?.stopReason === "aborted") {
695
+ updateStatus(ctx);
696
+ return;
697
+ }
698
+
699
+ if (!orchestrator.isEnabled) {
700
+ // Keep tracking state (modifiedFiles, agentToolCalls) so we can
701
+ // offer to review when the user toggles review back on.
702
+ // Just update the status bar to show pending file count.
703
+ updateStatus(ctx);
704
+ return;
705
+ }
706
+
707
+ await runAutoReview(ctx, "auto");
708
+ });
709
+
710
+ // ── Shortcuts ──────────────────────────────────────
711
+
712
+ // Cancel handler — shared by shortcut + command
713
+ function cancelReview(ctx: { ui: any; hasUI?: boolean }, source: string) {
714
+ if (manualReviews?.isReviewing || orchestrator.isReviewing) {
715
+ log(`Cancel requested via ${source}`);
716
+ manualReviews?.cancel();
717
+ orchestrator.cancel();
718
+ if (ctx.hasUI) ctx.ui.notify("Review cancelled", "info");
719
+ }
720
+ }
721
+
722
+ // Register cancel shortcut only if user explicitly configured one.
723
+ // Default is no shortcut — /cancel-review command is the reliable cross-terminal method.
724
+ if (shortcutConfig.cancelShortcut) {
725
+ pi.registerShortcut(shortcutConfig.cancelShortcut as any, {
726
+ description: "Cancel in-progress code review",
727
+ handler: async (ctx) => cancelReview(ctx, shortcutConfig.cancelShortcut),
728
+ });
729
+ }
730
+
731
+ // Also register ctrl+alt+r as a fallback (for terminals that support it)
732
+ if (shortcutConfig.cancelShortcut !== "ctrl+alt+r") {
733
+ pi.registerShortcut("ctrl+alt+r", {
734
+ description: "Cancel in-progress code review (fallback)",
735
+ handler: async (ctx) => cancelReview(ctx, "Ctrl+Alt+R"),
736
+ });
737
+ }
738
+
739
+ pi.registerShortcut("ctrl+alt+shift+r", {
740
+ description: "Full reset: cancel review, reset loop count, clear tracked files",
741
+ handler: async (ctx) => {
742
+ log("Full reset via Ctrl+Alt+Shift+R");
743
+ manualReviews?.cancel();
744
+ orchestrator.reset();
745
+ detectedGitRoots.clear(); // full reset clears session-level state too
746
+ skipStatusShowing = false;
747
+ judgeSkipChain.reset();
748
+ if (reviewDisplay) {
749
+ reviewDisplay.stop();
750
+ reviewDisplay = null;
751
+ }
752
+ resetTrackingState(ctx);
753
+ if (ctx.hasUI) ctx.ui.notify("Review fully reset", "info");
754
+ },
755
+ });
756
+
757
+ // Register configurable toggle shortcut (default: alt+r)
758
+ pi.registerShortcut(shortcutConfig.toggleShortcut as any, {
759
+ description: "Toggle automatic code review",
760
+ handler: async (ctx) => toggleReview(ctx),
761
+ });
762
+
763
+ // ── /cancel-review command ─────────────────────────
764
+
765
+ pi.registerCommand("cancel-review", {
766
+ description: "Cancel an in-progress code review",
767
+ handler: async (_args, ctx) => {
768
+ if (manualReviews?.isReviewing || orchestrator.isReviewing) {
769
+ cancelReview(ctx, "/cancel-review");
770
+ } else {
771
+ if (ctx.hasUI) ctx.ui.notify("No review in progress", "info");
772
+ }
773
+ },
774
+ });
775
+
776
+ // ── /review-judge-toggle command ────────────────
777
+ //
778
+ // In-memory toggle for the duplicate-review suppressor. Does NOT persist
779
+ // to settings.json — matches the pattern of /review (Alt+R) which is also
780
+ // a session-level toggle. To make the change permanent, the user edits
781
+ // `.hardno/settings.json` themselves.
782
+ pi.registerCommand("review-judge-toggle", {
783
+ description: "Toggle the duplicate-review suppressor (judge) for this session",
784
+ handler: async (_args, ctx) => {
785
+ settings.judgeEnabled = !settings.judgeEnabled;
786
+ const state = settings.judgeEnabled ? "on" : "off";
787
+ log(`judge toggled ${state} via /review-judge-toggle`);
788
+ if (ctx.hasUI) {
789
+ ctx.ui.notify(
790
+ settings.judgeEnabled
791
+ ? `Judge: on (skipping redundant reviews on read-only turns, using ${settings.judgeModel.split("/").pop()})`
792
+ : "Judge: off (every file-changing turn triggers a full review)",
793
+ "info",
794
+ );
795
+ }
796
+ // Toggling is explicit user activity — clear any persistent skip
797
+ // indicator so the new `⚖ judge` state renders immediately.
798
+ // Without this, `updateStatus` early-returns when `skipStatusShowing`
799
+ // is true (left over from a prior judge_read_only/no_meaningful_changes
800
+ // skip) and the user sees the old status until the next real activity.
801
+ skipStatusShowing = false;
802
+ updateStatus(ctx);
803
+ },
804
+ });
805
+
806
+ // ── /review-clean-logs command ──────────────
807
+ //
808
+ // Wipes ~/.pi/.hardno/review.log (+ .old) and every structured reviews/*.json.
809
+ // Does NOT touch user config (settings.json, review-rules.md, etc.) — only
810
+ // the append-only history pi-hard-no owns. Useful when testing changes to the
811
+ // review pipeline without noise from prior runs.
812
+ pi.registerCommand("review-clean-logs", {
813
+ description:
814
+ "Wipe pi-hard-no review logs (review.log + reviews/*.json); leaves config untouched",
815
+ handler: async (_args, ctx) => {
816
+ const { logsRemoved, reviewsRemoved } = cleanLogs();
817
+ const summary = `Cleared ${logsRemoved} log file${logsRemoved === 1 ? "" : "s"} and ${reviewsRemoved} review record${reviewsRemoved === 1 ? "" : "s"}`;
818
+ log(`review-clean-logs: ${summary}`);
819
+ if (ctx.hasUI) ctx.ui.notify(summary, "info");
820
+ },
821
+ });
822
+ manualReviews = registerReviewCommands({
823
+ pi,
824
+ getSettings: () => settings,
825
+ getCustomRules: () => customRules,
826
+ setCustomRules: (rules) => {
827
+ customRules = rules;
828
+ },
829
+ getAutoReviewRules: () => autoReviewRules,
830
+ getIgnorePatterns: () => ignorePatterns,
831
+ getLastUserMessage: () => lastUserMessage,
832
+ getDetectedGitRoots: () => detectedGitRoots,
833
+ toggleReview,
834
+ startReviewWidget,
835
+ finishReview,
836
+ updateStatus,
837
+ });
838
+
839
+ // ── Session lifecycle ──────────────────────────────
840
+
841
+ pi.on("session_start", async (_event, ctx) => {
842
+ orchestrator.reset();
843
+ detectedGitRoots.clear(); // session-level: clear on new session
844
+ judgeSkipChain.reset();
845
+
846
+ const [rules, autoRules, settingsResult, patterns, rRules] = await Promise.all([
847
+ loadReviewRules(ctx.cwd),
848
+ loadAutoReviewRules(ctx.cwd),
849
+ loadSettings(ctx.cwd),
850
+ loadIgnorePatterns(ctx.cwd),
851
+ loadArchitectRules(ctx.cwd),
852
+ ]);
853
+
854
+ customRules = rules;
855
+ autoReviewRules = autoRules;
856
+ ignorePatterns = patterns;
857
+ architectRules = rRules;
858
+ settings = settingsResult.settings;
859
+
860
+ if (autoReviewRules) log("Loaded auto-review rules from .hardno/auto-review.md");
861
+ if (customRules) log("Loaded custom rules from .hardno/review-rules.md");
862
+ if (architectRules) log("Loaded architect rules from .hardno/architect.md");
863
+ if (ignorePatterns)
864
+ log(`Loaded ${ignorePatterns.length} ignore pattern(s) from .hardno/ignore`);
865
+ for (const err of settingsResult.errors) {
866
+ log(err);
867
+ if (ctx.hasUI) ctx.ui.notify(err, "warning");
868
+ }
869
+ if (settingsResult.errors.length === 0) {
870
+ if (settings.maxReviewLoops !== DEFAULT_SETTINGS.maxReviewLoops) {
871
+ log(`maxReviewLoops = ${settings.maxReviewLoops}`);
872
+ }
873
+ log(`reviewer model: ${settings.model}, thinking: ${settings.thinkingLevel}`);
874
+ }
875
+
876
+ updateStatus(ctx);
877
+ });
878
+
879
+ pi.on("session_shutdown", async () => {
880
+ manualReviews?.cancel();
881
+ orchestrator.cancel();
882
+ skipStatusShowing = false;
883
+ judgeSkipChain.reset();
884
+ if (reviewDisplay) {
885
+ reviewDisplay.stop();
886
+ reviewDisplay = null;
887
+ }
888
+ agentToolCalls = [];
889
+ modifiedFiles.clear();
890
+ pendingArgs.clear();
891
+ });
892
+ }