@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/LICENSE +21 -0
- package/README.md +287 -0
- package/architect.ts +128 -0
- package/changes.ts +404 -0
- package/commands.ts +635 -0
- package/context.ts +658 -0
- package/default-review-rules.md +150 -0
- package/git-roots.ts +94 -0
- package/helpers.ts +72 -0
- package/ignore.ts +105 -0
- package/index.ts +892 -0
- package/judge-skip-chain.ts +113 -0
- package/judge.ts +213 -0
- package/logger.ts +175 -0
- package/message-sender.ts +83 -0
- package/orchestrator.ts +521 -0
- package/package.json +55 -0
- package/prompt.ts +126 -0
- package/review-display.ts +571 -0
- package/reviewer.ts +433 -0
- package/scaffold.ts +120 -0
- package/session-kind.ts +139 -0
- package/settings.ts +332 -0
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
|
+
}
|