@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/commands.ts ADDED
@@ -0,0 +1,635 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { type AutoReviewSettings, configDirs } from "./settings";
4
+ import { buildReviewPrompt } from "./prompt";
5
+ import {
6
+ clampCommitCount,
7
+ computeReviewTimeoutMs,
8
+ createReviewId,
9
+ shouldDiffAllCommits,
10
+ truncateDiff,
11
+ } from "./helpers";
12
+ import { runReviewSession } from "./reviewer";
13
+ import { sendReviewResult } from "./message-sender";
14
+ import { isBinaryPath } from "./changes";
15
+ import { LARGE_LIMITS, buildPerFileContext, listDiffFiles } from "./context";
16
+ import { filterIgnored } from "./ignore";
17
+ import { log } from "./logger";
18
+ import {
19
+ SCAFFOLD_SETTINGS,
20
+ SCAFFOLD_REVIEW_RULES,
21
+ SCAFFOLD_AUTO_REVIEW,
22
+ SCAFFOLD_ARCHITECT_RULES,
23
+ SCAFFOLD_IGNORE,
24
+ } from "./scaffold";
25
+
26
+ type ReviewCallbacks = {
27
+ onActivity: (desc: string) => void;
28
+ onToolCall: (toolName: string, targetPath: string | null) => void;
29
+ };
30
+
31
+ type CommandContext = {
32
+ ui: any;
33
+ hasUI?: boolean;
34
+ cwd: string;
35
+ };
36
+
37
+ export type ManualReviewController = {
38
+ readonly isReviewing: boolean;
39
+ cancel: () => void;
40
+ reset: (ctx: CommandContext) => void;
41
+ };
42
+
43
+ export interface RegisterCommandsOptions {
44
+ pi: ExtensionAPI;
45
+ getSettings: () => AutoReviewSettings;
46
+ getCustomRules: () => string | null;
47
+ setCustomRules: (rules: string | null) => void;
48
+ getAutoReviewRules: () => string | null;
49
+ getIgnorePatterns: () => string[] | null;
50
+ getLastUserMessage: () => string | null;
51
+ getDetectedGitRoots: () => Set<string>;
52
+ toggleReview: (ctx: CommandContext) => void | Promise<void>;
53
+ startReviewWidget: (ctx: CommandContext, files: string[], timeoutMs?: number) => ReviewCallbacks;
54
+ finishReview: (ctx: CommandContext, resetTracking?: boolean) => void;
55
+ updateStatus: (ctx: CommandContext) => void;
56
+ }
57
+
58
+ export function registerReviewCommands(opts: RegisterCommandsOptions): ManualReviewController {
59
+ let reviewAbort: AbortController | null = null;
60
+ let isReviewing = false;
61
+
62
+ /**
63
+ * Run a git command in a specific directory.
64
+ * Always uses -C to ensure commands run in the right repo,
65
+ * not the process cwd (which may be ~ while the user switched to a repo).
66
+ */
67
+ function gitExec(cwd: string, args: string[], timeout = 5000) {
68
+ return opts.pi.exec("git", ["-C", cwd, ...args], { timeout });
69
+ }
70
+
71
+ function buildReviewOptions(
72
+ signal: AbortSignal,
73
+ cwd: string,
74
+ filesReviewed: string[],
75
+ reviewId: string,
76
+ onActivity?: (desc: string) => void,
77
+ onToolCall?: (toolName: string, targetPath: string | null) => void,
78
+ ) {
79
+ const settings = opts.getSettings();
80
+ return {
81
+ signal,
82
+ cwd,
83
+ model: settings.model,
84
+ thinkingLevel: settings.thinkingLevel,
85
+ timeoutMs: computeReviewTimeoutMs(settings.reviewTimeoutMs, filesReviewed.length),
86
+ filesReviewed,
87
+ reviewId,
88
+ onActivity,
89
+ onToolCall,
90
+ };
91
+ }
92
+
93
+ function beginManualReview(ctx: CommandContext): AbortSignal {
94
+ isReviewing = true;
95
+ reviewAbort = new AbortController();
96
+ opts.updateStatus(ctx);
97
+ return reviewAbort.signal;
98
+ }
99
+
100
+ function throwIfCancelled(signal: AbortSignal) {
101
+ if (signal.aborted) throw new Error("Review cancelled");
102
+ }
103
+
104
+ function finishManualReview(ctx: CommandContext) {
105
+ isReviewing = false;
106
+ reviewAbort = null;
107
+ opts.finishReview(ctx, false);
108
+ }
109
+
110
+ function cancelInProgress() {
111
+ if (!reviewAbort) return;
112
+ reviewAbort.abort();
113
+ isReviewing = false;
114
+ reviewAbort = null;
115
+ }
116
+
117
+ registerConfigCommands(opts);
118
+
119
+ opts.pi.registerCommand("review", {
120
+ description: "Toggle review, or '/review <N>' to review last N commits",
121
+ handler: async (args, ctx) => {
122
+ const trimmed = (args ?? "").trim();
123
+
124
+ if (!trimmed || !/^\d+$/.test(trimmed)) {
125
+ await opts.toggleReview(ctx);
126
+ return;
127
+ }
128
+
129
+ const count = parseInt(trimmed, 10);
130
+ if (count <= 0) {
131
+ ctx.ui.notify("Usage: /review <N> where N > 0", "warning");
132
+ return;
133
+ }
134
+
135
+ ctx.ui.notify("Reviewing commits…", "info");
136
+
137
+ if (isReviewing && reviewAbort) {
138
+ log("Cancelling in-progress review for /review N");
139
+ cancelInProgress();
140
+ }
141
+
142
+ const signal = beginManualReview(ctx);
143
+
144
+ try {
145
+ const countResult = await gitExec(ctx.cwd, ["rev-list", "--count", "HEAD"]);
146
+ if (countResult.code !== 0) log(`git rev-list failed: ${countResult.stderr.trim()}`);
147
+
148
+ const totalCommits = parseInt(countResult.stdout.trim(), 10) || 0;
149
+ if (totalCommits === 0) {
150
+ ctx.ui.notify("No commits found in this repo.", "warning");
151
+ return;
152
+ }
153
+
154
+ const { effectiveCount, wasClamped } = clampCommitCount(count, totalCommits);
155
+ if (wasClamped) ctx.ui.notify(`Repo has ${totalCommits} commits. Reviewing all.`, "info");
156
+
157
+ const diffRange: string[] = [];
158
+ if (shouldDiffAllCommits(effectiveCount, totalCommits)) {
159
+ const emptyTree = (
160
+ await gitExec(ctx.cwd, ["hash-object", "-t", "tree", "/dev/null"])
161
+ ).stdout.trim();
162
+ diffRange.push(emptyTree, "HEAD");
163
+ } else {
164
+ diffRange.push(`HEAD~${effectiveCount}`, "HEAD");
165
+ }
166
+
167
+ let changedFiles = await listDiffFiles(opts.pi, ctx.cwd, ...diffRange);
168
+
169
+ const ignorePatterns = opts.getIgnorePatterns();
170
+ if (ignorePatterns && ignorePatterns.length > 0) {
171
+ const before = changedFiles.length;
172
+ changedFiles = filterIgnored(changedFiles, ignorePatterns);
173
+ if (changedFiles.length < before) {
174
+ const skipped = before - changedFiles.length;
175
+ ctx.ui.notify(`Filtered ${skipped} ignored file(s)`, "info");
176
+ }
177
+ }
178
+
179
+ if (changedFiles.length === 0) {
180
+ ctx.ui.notify(
181
+ `No reviewable changes in last ${effectiveCount} commit(s) (all ignored).`,
182
+ "info",
183
+ );
184
+ return;
185
+ }
186
+
187
+ const scopedDiffArgs = ["diff", ...diffRange, "--", ...changedFiles];
188
+ const diffResult = await gitExec(ctx.cwd, scopedDiffArgs, 15000);
189
+ if (diffResult.code !== 0) {
190
+ ctx.ui.notify(`git diff failed: ${diffResult.stderr.slice(0, 200)}`, "error");
191
+ return;
192
+ }
193
+
194
+ const diff = diffResult.stdout.trim();
195
+ if (!diff) {
196
+ ctx.ui.notify(`No changes in last ${effectiveCount} commit(s).`, "info");
197
+ return;
198
+ }
199
+
200
+ const commitLog = (
201
+ await gitExec(ctx.cwd, ["log", "--oneline", `-${effectiveCount}`])
202
+ ).stdout.trim();
203
+ const truncatedDiff = truncateDiff(diff, LARGE_LIMITS.maxDiffSize);
204
+ const commitLabel = `last ${effectiveCount} commit${effectiveCount > 1 ? "s" : ""}`;
205
+
206
+ const prompt = `${buildReviewPrompt(opts.getAutoReviewRules(), opts.getCustomRules(), opts.getLastUserMessage())}\n\n---\n\nReview the following git diff (${commitLabel}):\n\nCommits:\n${commitLog}\n\nDiff:\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
207
+ throwIfCancelled(signal);
208
+ const reviewId = createReviewId();
209
+ log(`[${reviewId}] manual /review ${effectiveCount}: ${changedFiles.length} files`);
210
+ const manualTimeoutMs = computeReviewTimeoutMs(
211
+ opts.getSettings().reviewTimeoutMs,
212
+ changedFiles.length,
213
+ );
214
+ const { onActivity, onToolCall } = opts.startReviewWidget(
215
+ ctx,
216
+ changedFiles,
217
+ manualTimeoutMs,
218
+ );
219
+ const result = await runReviewSession(
220
+ prompt,
221
+ buildReviewOptions(signal, ctx.cwd, changedFiles, reviewId, onActivity, onToolCall),
222
+ );
223
+
224
+ sendReviewResult(opts.pi, result, commitLabel, { reviewId });
225
+ } catch (err: any) {
226
+ if (err?.message === "Review cancelled") {
227
+ ctx.ui.notify("Review cancelled", "info");
228
+ } else {
229
+ log(`ERROR: commit review failed: ${err?.message ?? err}`);
230
+ ctx.ui.notify(`Review failed: ${err?.message ?? err}`, "error");
231
+ }
232
+ } finally {
233
+ finishManualReview(ctx);
234
+ }
235
+ },
236
+ });
237
+
238
+ opts.pi.registerCommand("review-all", {
239
+ description: "Review all changes in the repo (pending diff, last commit, or all files in cwd)",
240
+ handler: async (_args, ctx) => {
241
+ if (isReviewing && reviewAbort) {
242
+ log("Cancelling in-progress review for /review-all");
243
+ cancelInProgress();
244
+ }
245
+
246
+ const signal = beginManualReview(ctx);
247
+
248
+ try {
249
+ const { resolve } = await import("node:path");
250
+
251
+ const gitCheck = await gitExec(ctx.cwd, ["rev-parse", "--show-toplevel"]);
252
+ let isGitRepo = gitCheck.code === 0;
253
+ let gitRoot = isGitRepo ? gitCheck.stdout.trim() : null;
254
+
255
+ // If cwd isn't a git repo, try the first detected git root from the session
256
+ // (e.g. the agent was working in ~/some-repo but cwd is ~)
257
+ if (!isGitRepo) {
258
+ const detectedRoots = opts.getDetectedGitRoots();
259
+ if (detectedRoots.size > 0) {
260
+ gitRoot = [...detectedRoots][0];
261
+ isGitRepo = true;
262
+ log(`review-all: cwd is not a git repo, using detected root: ${gitRoot}`);
263
+ }
264
+ }
265
+
266
+ let reviewFiles: string[] = [];
267
+ let prompt: string;
268
+
269
+ if (isGitRepo && gitRoot) {
270
+ const pendingDiff = await gitExec(gitRoot, ["diff", "HEAD"], 15000);
271
+ const hasPendingDiff = pendingDiff.code === 0 && pendingDiff.stdout.trim();
272
+
273
+ const pendingFiles = await listDiffFiles(opts.pi, gitRoot, "HEAD");
274
+
275
+ const untrackedResult = await gitExec(gitRoot, [
276
+ "ls-files",
277
+ "--others",
278
+ "--exclude-standard",
279
+ ]);
280
+ if (untrackedResult.code === 0 && untrackedResult.stdout.trim()) {
281
+ const untracked = untrackedResult.stdout.trim().split("\n").filter(Boolean);
282
+ const existing = new Set(pendingFiles);
283
+ for (const f of untracked) {
284
+ if (!existing.has(f)) pendingFiles.push(f);
285
+ }
286
+ }
287
+
288
+ if (hasPendingDiff || pendingFiles.length > 0) {
289
+ reviewFiles = pendingFiles;
290
+ const ignorePatterns = opts.getIgnorePatterns();
291
+ if (ignorePatterns && ignorePatterns.length > 0) {
292
+ reviewFiles = filterIgnored(reviewFiles, ignorePatterns);
293
+ }
294
+
295
+ if (reviewFiles.length === 0) {
296
+ ctx.ui.notify("No reviewable pending changes (all ignored).", "info");
297
+ return;
298
+ }
299
+
300
+ const fileSections = await buildPerFileContext(
301
+ opts.pi,
302
+ gitRoot,
303
+ reviewFiles,
304
+ ["HEAD"],
305
+ new Set(),
306
+ LARGE_LIMITS,
307
+ );
308
+
309
+ ctx.ui.notify(`Reviewing ${reviewFiles.length} pending file(s)…`, "info");
310
+ prompt = `${buildReviewPrompt(opts.getAutoReviewRules(), opts.getCustomRules(), opts.getLastUserMessage())}\n\n---\n\nReview all pending changes in the repo.\n\n## Files to review\n\nRead each file with read(path) to see its full contents.\n\n${fileSections.join("\n\n---\n\n")}`;
311
+ } else {
312
+ const countResult = await gitExec(gitRoot, ["rev-list", "--count", "HEAD"]);
313
+ const totalCommits = parseInt(countResult.stdout.trim(), 10) || 0;
314
+ if (totalCommits === 0) {
315
+ ctx.ui.notify("No pending changes and no commits to review.", "info");
316
+ return;
317
+ }
318
+
319
+ let diffArgs: string[];
320
+ if (totalCommits === 1) {
321
+ const emptyTree = (
322
+ await gitExec(gitRoot, ["hash-object", "-t", "tree", "/dev/null"])
323
+ ).stdout.trim();
324
+ diffArgs = [emptyTree, "HEAD"];
325
+ } else {
326
+ diffArgs = ["HEAD~1", "HEAD"];
327
+ }
328
+
329
+ reviewFiles = await listDiffFiles(opts.pi, gitRoot, ...diffArgs);
330
+
331
+ const ignorePatterns = opts.getIgnorePatterns();
332
+ if (ignorePatterns && ignorePatterns.length > 0) {
333
+ reviewFiles = filterIgnored(reviewFiles, ignorePatterns);
334
+ }
335
+
336
+ if (reviewFiles.length === 0) {
337
+ ctx.ui.notify("No reviewable files in last commit (all ignored).", "info");
338
+ return;
339
+ }
340
+
341
+ const commitLog = (await gitExec(gitRoot, ["log", "--oneline", "-1"])).stdout.trim();
342
+
343
+ const fileSections = await buildPerFileContext(
344
+ opts.pi,
345
+ gitRoot,
346
+ reviewFiles,
347
+ diffArgs,
348
+ new Set(),
349
+ LARGE_LIMITS,
350
+ );
351
+
352
+ ctx.ui.notify(`Reviewing last commit (${commitLog})…`, "info");
353
+ prompt = `${buildReviewPrompt(opts.getAutoReviewRules(), opts.getCustomRules(), opts.getLastUserMessage())}\n\n---\n\nReview the last commit: ${commitLog}\n\n## Files to review\n\nRead each file with read(path) to see its full contents.\n\n${fileSections.join("\n\n---\n\n")}`;
354
+ }
355
+ } else {
356
+ // ── Path C: not a git repo — refuse to scan home or root directories ──
357
+ const { homedir } = await import("node:os");
358
+ const home = homedir();
359
+ if (ctx.cwd === home || ctx.cwd === "/" || ctx.cwd === "/tmp") {
360
+ ctx.ui.notify(
361
+ `Cannot review: cwd is ${ctx.cwd} (not a project directory).\n\n` +
362
+ `Run /review-all from inside a git repo, or use /review <N> to review specific commits.`,
363
+ "warning",
364
+ );
365
+ return;
366
+ }
367
+
368
+ const findResult = await opts.pi.exec(
369
+ "find",
370
+ [
371
+ ctx.cwd,
372
+ "-maxdepth",
373
+ "5",
374
+ "-type",
375
+ "f",
376
+ "-not",
377
+ "-path",
378
+ "*/node_modules/*",
379
+ "-not",
380
+ "-path",
381
+ "*/.git/*",
382
+ "-not",
383
+ "-path",
384
+ "*/dist/*",
385
+ "-not",
386
+ "-path",
387
+ "*/build/*",
388
+ "-not",
389
+ "-name",
390
+ "*.min.*",
391
+ ],
392
+ { timeout: 10000 },
393
+ );
394
+ if (findResult.code !== 0 || !findResult.stdout.trim()) {
395
+ ctx.ui.notify("No files found in current directory.", "warning");
396
+ return;
397
+ }
398
+
399
+ reviewFiles = findResult.stdout
400
+ .trim()
401
+ .split("\n")
402
+ .filter(Boolean)
403
+ .filter((f) => !isBinaryPath(f));
404
+
405
+ const ignorePatterns = opts.getIgnorePatterns();
406
+ if (ignorePatterns && ignorePatterns.length > 0) {
407
+ reviewFiles = filterIgnored(reviewFiles, ignorePatterns);
408
+ }
409
+
410
+ if (reviewFiles.length === 0) {
411
+ ctx.ui.notify("No reviewable files found (all ignored or binary).", "info");
412
+ return;
413
+ }
414
+
415
+ const fileSections = reviewFiles.map((f) => {
416
+ const fullPath = resolve(ctx.cwd, f);
417
+ return `### ${fullPath}\n**Full path:** \`${fullPath}\``;
418
+ });
419
+
420
+ ctx.ui.notify(`Reviewing ${reviewFiles.length} file(s) in cwd…`, "info");
421
+ prompt = `${buildReviewPrompt(opts.getAutoReviewRules(), opts.getCustomRules(), opts.getLastUserMessage())}\n\n---\n\nReview all files in the project (not a git repo, no diffs available).\n\n## Files to review\n\nRead each file with read(path) to see its full contents.\n\n${fileSections.join("\n\n---\n\n")}`;
422
+ }
423
+
424
+ const fullPaths = reviewFiles.map((f) => {
425
+ if (f.startsWith("/")) return f;
426
+ return gitRoot ? `${gitRoot}/${f}` : resolve(ctx.cwd, f);
427
+ });
428
+
429
+ throwIfCancelled(signal);
430
+ const reviewId = createReviewId();
431
+ log(`[${reviewId}] manual /review-all: ${fullPaths.length} files`);
432
+ const allTimeoutMs = computeReviewTimeoutMs(
433
+ opts.getSettings().reviewTimeoutMs,
434
+ fullPaths.length,
435
+ );
436
+ const { onActivity, onToolCall } = opts.startReviewWidget(ctx, fullPaths, allTimeoutMs);
437
+ const result = await runReviewSession(
438
+ prompt,
439
+ buildReviewOptions(signal, ctx.cwd, fullPaths, reviewId, onActivity, onToolCall),
440
+ );
441
+
442
+ sendReviewResult(opts.pi, result, "all changes", { reviewId });
443
+ } catch (err: any) {
444
+ if (err?.message === "Review cancelled") {
445
+ ctx.ui.notify("Review cancelled", "info");
446
+ } else {
447
+ log(`ERROR: review-all failed: ${err?.message ?? err}`);
448
+ ctx.ui.notify(`Review failed: ${err?.message ?? err}`, "error");
449
+ }
450
+ } finally {
451
+ finishManualReview(ctx);
452
+ }
453
+ },
454
+ });
455
+
456
+ return {
457
+ get isReviewing() {
458
+ return isReviewing;
459
+ },
460
+ cancel: cancelInProgress,
461
+ reset: (ctx: CommandContext) => {
462
+ cancelInProgress();
463
+ opts.finishReview(ctx, false);
464
+ },
465
+ };
466
+ }
467
+
468
+ function registerConfigCommands(opts: RegisterCommandsOptions) {
469
+ opts.pi.registerCommand("scaffold-review-files", {
470
+ description:
471
+ "Create .hardno/ config templates in a git repo. Usage: /scaffold-review-files [path]",
472
+ handler: async (args, ctx) => {
473
+ const { mkdirSync, writeFileSync, existsSync } = await import("node:fs");
474
+ const { join, resolve } = await import("node:path");
475
+
476
+ const targetBase = args?.trim() ? resolve(ctx.cwd, args.trim()) : ctx.cwd;
477
+
478
+ const gitCheck = await opts.pi.exec(
479
+ "git",
480
+ ["-C", targetBase, "rev-parse", "--show-toplevel"],
481
+ {
482
+ timeout: 5000,
483
+ },
484
+ );
485
+ if (gitCheck.code !== 0) {
486
+ const msg =
487
+ `Not a git repository: ${targetBase}\n\n` +
488
+ `Usage:\n` +
489
+ ` /scaffold-review-files — scaffold in current directory\n` +
490
+ ` /scaffold-review-files /path/to/repo — scaffold in a specific git repo`;
491
+ if (ctx.hasUI) ctx.ui.notify(msg, "error");
492
+ log(`scaffold: refused — not a git repo: ${targetBase}`);
493
+ return;
494
+ }
495
+
496
+ const gitRoot = gitCheck.stdout.trim();
497
+ const dir = join(gitRoot, ".hardno");
498
+ mkdirSync(dir, { recursive: true });
499
+
500
+ const files: Record<string, string> = {
501
+ "settings.json": SCAFFOLD_SETTINGS,
502
+ "auto-review.md": SCAFFOLD_AUTO_REVIEW,
503
+ "review-rules.md": SCAFFOLD_REVIEW_RULES,
504
+ "architect.md": SCAFFOLD_ARCHITECT_RULES,
505
+ ignore: SCAFFOLD_IGNORE,
506
+ };
507
+
508
+ let created = 0;
509
+ let skipped = 0;
510
+ for (const [name, content] of Object.entries(files)) {
511
+ const path = join(dir, name);
512
+ if (existsSync(path)) {
513
+ skipped++;
514
+ log(`scaffold: skipped ${name} (already exists)`);
515
+ } else {
516
+ writeFileSync(path, content);
517
+ created++;
518
+ log(`scaffold: created ${name}`);
519
+ }
520
+ }
521
+
522
+ const msg =
523
+ created > 0
524
+ ? `Created ${created} file(s) in ${dir}${skipped > 0 ? ` (${skipped} already existed)` : ""}`
525
+ : `All files already exist in ${dir}`;
526
+
527
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
528
+ log(`scaffold: ${msg}`);
529
+ },
530
+ });
531
+
532
+ opts.pi.registerCommand("hardno-rules", {
533
+ description: "Edit .hardno/review-rules.md in pi's built-in editor",
534
+ handler: async (_args, ctx) => {
535
+ const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import("node:fs");
536
+ const { join } = await import("node:path");
537
+
538
+ const [localDir, globalDir] = configDirs(ctx.cwd);
539
+ let filePath: string | null = null;
540
+ let fileContent: string | null = null;
541
+
542
+ for (const dir of [localDir, globalDir]) {
543
+ const candidate = join(dir, "review-rules.md");
544
+ if (existsSync(candidate)) {
545
+ filePath = candidate;
546
+ try {
547
+ fileContent = readFileSync(candidate, "utf8");
548
+ } catch (err: any) {
549
+ log(`hardno-rules: cannot read ${candidate}: ${err?.message}`);
550
+ if (ctx.hasUI) ctx.ui.notify(`Cannot read ${candidate}: ${err?.message}`, "error");
551
+ return;
552
+ }
553
+ break;
554
+ }
555
+ }
556
+
557
+ if (!filePath) {
558
+ if (!ctx.hasUI) return;
559
+ const ok = await ctx.ui.confirm(
560
+ "No review-rules.md found",
561
+ `Create ${localDir}/review-rules.md from template?`,
562
+ );
563
+ if (!ok) return;
564
+
565
+ mkdirSync(localDir, { recursive: true });
566
+ filePath = join(localDir, "review-rules.md");
567
+ fileContent = SCAFFOLD_REVIEW_RULES;
568
+ writeFileSync(filePath, fileContent);
569
+ log(`hardno-rules: created ${filePath}`);
570
+ }
571
+
572
+ if (!ctx.hasUI) return;
573
+
574
+ const edited = await ctx.ui.editor(`Edit ${filePath}`, fileContent!);
575
+
576
+ if (edited === undefined) {
577
+ ctx.ui.notify("Cancelled — no changes saved", "info");
578
+ return;
579
+ }
580
+
581
+ if (edited === fileContent) {
582
+ ctx.ui.notify("No changes made", "info");
583
+ return;
584
+ }
585
+
586
+ writeFileSync(filePath, edited);
587
+ opts.setCustomRules(edited.trim() || null);
588
+ log(`hardno-rules: saved and reloaded ${filePath}`);
589
+ ctx.ui.notify(`Saved ${filePath}`, "info");
590
+ },
591
+ });
592
+
593
+ opts.pi.registerCommand("add-review-rule", {
594
+ description: "Prepend a custom rule to .hardno/review-rules.md",
595
+ handler: async (args, ctx) => {
596
+ const rule = (args ?? "").trim();
597
+ if (!rule) {
598
+ if (ctx.hasUI) ctx.ui.notify("Usage: /add-review-rule <rule text>", "warning");
599
+ return;
600
+ }
601
+
602
+ const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import("node:fs");
603
+ const { join } = await import("node:path");
604
+
605
+ const [localDir] = configDirs(ctx.cwd);
606
+ const filePath = join(localDir, "review-rules.md");
607
+
608
+ let existing = "";
609
+ if (existsSync(filePath)) {
610
+ try {
611
+ existing = readFileSync(filePath, "utf8");
612
+ } catch (err: any) {
613
+ log(`add-review-rule: cannot read ${filePath}: ${err?.message}`);
614
+ if (ctx.hasUI) ctx.ui.notify(`Cannot read ${filePath}: ${err?.message}`, "error");
615
+ return;
616
+ }
617
+ } else {
618
+ mkdirSync(localDir, { recursive: true });
619
+ }
620
+
621
+ const newContent = `- ${rule}\n${existing}`;
622
+ writeFileSync(filePath, newContent);
623
+ opts.setCustomRules(newContent.trim() || null);
624
+ log(`add-review-rule: prepended rule to ${filePath}`);
625
+
626
+ const lines = newContent.split("\n");
627
+ const preview = lines.slice(0, 10).join("\n");
628
+ const ellipsis = lines.length > 10 ? "\n. . ." : "";
629
+
630
+ if (ctx.hasUI) {
631
+ ctx.ui.notify(`Rule added to ${filePath}\n\n${preview}${ellipsis}`, "info");
632
+ }
633
+ },
634
+ });
635
+ }