@diegopetrucci/pi-review 0.1.4 → 0.1.8

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.
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.6
package/README.md CHANGED
@@ -31,7 +31,7 @@ Then reload pi:
31
31
 
32
32
  - Behavior is intentionally kept equivalent to the upstream source, with only packaging/attribution changes for this repository.
33
33
  - PR review requires `gh` access and a clean working tree for tracked files.
34
- - If the project is trusted and a `REVIEW_GUIDELINES.md` file exists next to the repo's `.pi` directory, its contents are appended to the review prompt.
34
+ - If the project is trusted and a `REVIEW_GUIDELINES.md` file exists next to the repo's `<pi-config-dir>` directory, its contents are appended to the review prompt. `<pi-config-dir>` is Pi's runtime config directory name (`CONFIG_DIR_NAME`; `.pi` by default).
35
35
 
36
36
  ## License and attribution
37
37
 
package/index.ts CHANGED
@@ -29,14 +29,14 @@
29
29
  * - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode)
30
30
  *
31
31
  * Project-specific review guidelines:
32
- * - If the project is trusted and a REVIEW_GUIDELINES.md file exists in the
33
- * same directory as .pi, its contents are appended to the review prompt.
32
+ * - If the project is trusted and a REVIEW_GUIDELINES.md file exists next to
33
+ * the project config directory, its contents are appended to the review prompt.
34
34
  *
35
35
  * Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
36
36
  */
37
37
 
38
38
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
39
- import { DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
39
+ import { CONFIG_DIR_NAME, DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
40
40
  import {
41
41
  Container,
42
42
  fuzzyFilter,
@@ -49,7 +49,6 @@ import {
49
49
  import path from "node:path";
50
50
  import { promises as fs } from "node:fs";
51
51
 
52
- const CONFIG_DIR_NAME = ".pi";
53
52
 
54
53
  // State to track fresh session review (where we branched from).
55
54
  // Module-level state means only one review can be active at a time.
@@ -60,6 +59,24 @@ let reviewLoopFixingEnabled = false;
60
59
  let reviewCustomInstructions: string | undefined = undefined;
61
60
  let reviewLoopInProgress = false;
62
61
 
62
+ function getReviewRuntimeState() {
63
+ return {
64
+ reviewOriginId,
65
+ endReviewInProgress,
66
+ reviewLoopFixingEnabled,
67
+ reviewCustomInstructions,
68
+ reviewLoopInProgress,
69
+ };
70
+ }
71
+
72
+ function resetReviewRuntimeState() {
73
+ reviewOriginId = undefined;
74
+ endReviewInProgress = false;
75
+ reviewLoopFixingEnabled = false;
76
+ reviewCustomInstructions = undefined;
77
+ reviewLoopInProgress = false;
78
+ }
79
+
63
80
  const REVIEW_STATE_TYPE = "review-session";
64
81
  const REVIEW_ANCHOR_TYPE = "review-anchor";
65
82
  const REVIEW_SETTINGS_TYPE = "review-settings";
@@ -88,7 +105,7 @@ function canReadProjectConfig(ctx: ProjectConfigContext): boolean {
88
105
 
89
106
  function setReviewWidget(ctx: ExtensionContext, active: boolean) {
90
107
  if (!ctx.hasUI) return;
91
- if (!active) {
108
+ if (!active || ctx.mode !== "tui") {
92
109
  ctx.ui.setWidget("review", undefined);
93
110
  return;
94
111
  }
@@ -630,18 +647,20 @@ async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
630
647
  function parsePrReference(ref: string): number | null {
631
648
  const trimmed = ref.trim();
632
649
 
633
- // Try as a number first
634
- const num = parseInt(trimmed, 10);
635
- if (!isNaN(num) && num > 0) {
636
- return num;
650
+ if (/^\d+$/.test(trimmed)) {
651
+ const num = Number(trimmed);
652
+ if (Number.isSafeInteger(num) && num > 0) {
653
+ return num;
654
+ }
637
655
  }
638
656
 
639
657
  // Try to extract from GitHub URL
640
658
  // Formats: https://github.com/owner/repo/pull/123
641
659
  // github.com/owner/repo/pull/123
642
- const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
660
+ const urlMatch = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/[^/]+\/[^/]+\/pull\/(\d+)(?:[/?#]|$)/i);
643
661
  if (urlMatch) {
644
- return parseInt(urlMatch[1], 10);
662
+ const num = Number(urlMatch[1]);
663
+ return Number.isSafeInteger(num) && num > 0 ? num : null;
645
664
  }
646
665
 
647
666
  return null;
@@ -763,6 +782,30 @@ async function buildReviewPrompt(
763
782
  /**
764
783
  * Get user-facing hint for the review target
765
784
  */
785
+ type ComposeReviewPromptOptions = {
786
+ customInstructions?: string;
787
+ extraInstruction?: string;
788
+ projectGuidelines?: string | null;
789
+ };
790
+
791
+ function composeReviewPrompt(prompt: string, options: ComposeReviewPromptOptions = {}): string {
792
+ let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`;
793
+
794
+ if (options.customInstructions?.trim()) {
795
+ fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${options.customInstructions.trim()}`;
796
+ }
797
+
798
+ if (options.extraInstruction?.trim()) {
799
+ fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`;
800
+ }
801
+
802
+ if (options.projectGuidelines?.trim()) {
803
+ fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${options.projectGuidelines.trim()}`;
804
+ }
805
+
806
+ return fullPrompt;
807
+ }
808
+
766
809
  function getUserFacingHint(target: ReviewTarget): string {
767
810
  switch (target.type) {
768
811
  case "uncommitted":
@@ -833,6 +876,130 @@ function sleep(ms: number): Promise<void> {
833
876
  return new Promise((resolve) => setTimeout(resolve, ms));
834
877
  }
835
878
 
879
+ function tokenizeArgs(value: string): string[] {
880
+ const tokens: string[] = [];
881
+ let current = "";
882
+ let quote: '"' | "'" | null = null;
883
+
884
+ for (let i = 0; i < value.length; i++) {
885
+ const char = value[i];
886
+
887
+ if (quote) {
888
+ if (char === "\\" && i + 1 < value.length) {
889
+ current += value[i + 1];
890
+ i += 1;
891
+ continue;
892
+ }
893
+ if (char === quote) {
894
+ quote = null;
895
+ continue;
896
+ }
897
+ current += char;
898
+ continue;
899
+ }
900
+
901
+ if (char === '"' || char === "'") {
902
+ quote = char;
903
+ continue;
904
+ }
905
+
906
+ if (/\s/.test(char)) {
907
+ if (current.length > 0) {
908
+ tokens.push(current);
909
+ current = "";
910
+ }
911
+ continue;
912
+ }
913
+
914
+ current += char;
915
+ }
916
+
917
+ if (current.length > 0) {
918
+ tokens.push(current);
919
+ }
920
+
921
+ return tokens;
922
+ }
923
+
924
+ function parseReviewPaths(value: string): string[] {
925
+ return tokenizeArgs(value)
926
+ .map((item) => item.trim())
927
+ .filter((item) => item.length > 0);
928
+ }
929
+
930
+ type ParsedReviewArgs = {
931
+ target: ReviewTarget | { type: "pr"; ref: string } | null;
932
+ extraInstruction?: string;
933
+ error?: string;
934
+ };
935
+
936
+ function parseArgs(args: string | undefined): ParsedReviewArgs {
937
+ if (!args?.trim()) return { target: null };
938
+
939
+ const rawParts = tokenizeArgs(args.trim());
940
+ const parts: string[] = [];
941
+ let extraInstruction: string | undefined;
942
+
943
+ for (let i = 0; i < rawParts.length; i++) {
944
+ const part = rawParts[i];
945
+ if (part === "--extra") {
946
+ const next = rawParts[i + 1];
947
+ if (!next) {
948
+ return { target: null, error: "Missing value for --extra" };
949
+ }
950
+ extraInstruction = next;
951
+ i += 1;
952
+ continue;
953
+ }
954
+
955
+ if (part.startsWith("--extra=")) {
956
+ extraInstruction = part.slice("--extra=".length);
957
+ continue;
958
+ }
959
+
960
+ parts.push(part);
961
+ }
962
+
963
+ if (parts.length === 0) {
964
+ return { target: null, extraInstruction };
965
+ }
966
+
967
+ const subcommand = parts[0]?.toLowerCase();
968
+
969
+ switch (subcommand) {
970
+ case "uncommitted":
971
+ return { target: { type: "uncommitted" }, extraInstruction };
972
+
973
+ case "branch": {
974
+ const branch = parts[1];
975
+ if (!branch) return { target: null, extraInstruction };
976
+ return { target: { type: "baseBranch", branch }, extraInstruction };
977
+ }
978
+
979
+ case "commit": {
980
+ const sha = parts[1];
981
+ if (!sha) return { target: null, extraInstruction };
982
+ const title = parts.slice(2).join(" ") || undefined;
983
+ return { target: { type: "commit", sha, title }, extraInstruction };
984
+ }
985
+
986
+ case "folder": {
987
+ const paths = parts.slice(1).map((item) => item.trim()).filter((item) => item.length > 0);
988
+ if (paths.length === 0) return { target: null, extraInstruction };
989
+ return { target: { type: "folder", paths }, extraInstruction };
990
+ }
991
+
992
+ case "pr": {
993
+ const ref = parts[1];
994
+ if (!ref) return { target: null, extraInstruction };
995
+ return { target: { type: "pr", ref }, extraInstruction };
996
+ }
997
+
998
+ default:
999
+ return { target: null, extraInstruction };
1000
+ }
1001
+ }
1002
+
836
1003
  async function waitForLoopTurnToStart(ctx: ExtensionContext, previousAssistantId?: string): Promise<boolean> {
837
1004
  const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS;
838
1005
 
@@ -863,6 +1030,69 @@ type ReviewPresetValue =
863
1030
  | typeof TOGGLE_LOOP_FIXING_VALUE
864
1031
  | typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
865
1032
 
1033
+ const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch.
1034
+ Create a structured handoff that can be used immediately to implement fixes.
1035
+
1036
+ You MUST summarize the review that happened in this branch so findings can be acted on.
1037
+ Do not omit findings: include every actionable issue that was identified.
1038
+
1039
+ Required sections (in order):
1040
+
1041
+ ## Review Scope
1042
+ - What was reviewed (files/paths, changes, and scope)
1043
+
1044
+ ## Verdict
1045
+ - "correct" or "needs attention"
1046
+
1047
+ ## Findings
1048
+ For EACH finding, include:
1049
+ - Priority tag ([P0]..[P3]) and short title
1050
+ - File location (\`path/to/file.ext:line\`)
1051
+ - Why it matters (brief)
1052
+ - What should change (brief, actionable)
1053
+
1054
+ ## Fix Queue
1055
+ 1. Ordered implementation checklist (highest priority first)
1056
+
1057
+ ## Constraints & Preferences
1058
+ - Any constraints or preferences mentioned during review
1059
+ - Or "(none)"
1060
+
1061
+ ## Human Reviewer Callouts (Non-Blocking)
1062
+ Include only applicable callouts (no yes/no lines):
1063
+ - **This change adds a database migration:** <files/details>
1064
+ - **This change introduces a new dependency:** <package(s)/details>
1065
+ - **This change changes a dependency (or the lockfile):** <files/package(s)/details>
1066
+ - **This change modifies auth/permission behavior:** <what changed and where>
1067
+ - **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
1068
+ - **This change includes irreversible or destructive operations:** <operation and scope>
1069
+
1070
+ If none apply, write "- (none)".
1071
+
1072
+ These are informational callouts for humans and are not fix items by themselves.
1073
+
1074
+ Preserve exact file paths, function names, and error messages where available.`;
1075
+
1076
+ export const __test__ = {
1077
+ applyReviewSettings,
1078
+ applyReviewState,
1079
+ buildReviewPrompt,
1080
+ composeReviewPrompt,
1081
+ getReviewRuntimeState,
1082
+ getReviewSettings,
1083
+ getReviewState,
1084
+ hasBlockingReviewFindings,
1085
+ hasNeedsAttentionVerdict,
1086
+ getUserFacingHint,
1087
+ parseArgs,
1088
+ parsePrReference,
1089
+ parseReviewPaths,
1090
+ REVIEW_SUMMARY_PROMPT,
1091
+ loadProjectReviewGuidelines,
1092
+ resetReviewRuntimeState,
1093
+ tokenizeArgs,
1094
+ };
1095
+
866
1096
  export default function reviewExtension(pi: ExtensionAPI) {
867
1097
  function persistReviewSettings() {
868
1098
  pi.appendEntry(REVIEW_SETTINGS_TYPE, {
@@ -1276,13 +1506,6 @@ export default function reviewExtension(pi: ExtensionAPI) {
1276
1506
  }
1277
1507
 
1278
1508
 
1279
- function parseReviewPaths(value: string): string[] {
1280
- return value
1281
- .split(/\s+/)
1282
- .map((item) => item.trim())
1283
- .filter((item) => item.length > 0);
1284
- }
1285
-
1286
1509
  /**
1287
1510
  * Show folder input
1288
1511
  */
@@ -1433,20 +1656,11 @@ export default function reviewExtension(pi: ExtensionAPI) {
1433
1656
  const hint = getUserFacingHint(target);
1434
1657
  const projectGuidelines = canReadProjectConfig(ctx) ? await loadProjectReviewGuidelines(ctx.cwd) : null;
1435
1658
 
1436
- // Combine the review rubric with the specific prompt
1437
- let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\nPlease perform a code review with the following focus:\n\n${prompt}`;
1438
-
1439
- if (reviewCustomInstructions) {
1440
- fullPrompt += `\n\nShared custom review instructions (applies to all reviews):\n\n${reviewCustomInstructions}`;
1441
- }
1442
-
1443
- if (options?.extraInstruction?.trim()) {
1444
- fullPrompt += `\n\nAdditional user-provided review instruction:\n\n${options.extraInstruction.trim()}`;
1445
- }
1446
-
1447
- if (projectGuidelines) {
1448
- fullPrompt += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`;
1449
- }
1659
+ const fullPrompt = composeReviewPrompt(prompt, {
1660
+ customInstructions: reviewCustomInstructions,
1661
+ extraInstruction: options?.extraInstruction,
1662
+ projectGuidelines,
1663
+ });
1450
1664
 
1451
1665
  const modeHint = useFreshSession ? " (fresh session)" : "";
1452
1666
  ctx.ui.notify(`Starting review: ${hint}${modeHint}`, "info");
@@ -1456,129 +1670,6 @@ export default function reviewExtension(pi: ExtensionAPI) {
1456
1670
  return true;
1457
1671
  }
1458
1672
 
1459
- /**
1460
- * Parse command arguments for direct invocation
1461
- * Returns the target or a special marker for PR that needs async handling
1462
- */
1463
- type ParsedReviewArgs = {
1464
- target: ReviewTarget | { type: "pr"; ref: string } | null;
1465
- extraInstruction?: string;
1466
- error?: string;
1467
- };
1468
-
1469
- function tokenizeArgs(value: string): string[] {
1470
- const tokens: string[] = [];
1471
- let current = "";
1472
- let quote: '"' | "'" | null = null;
1473
-
1474
- for (let i = 0; i < value.length; i++) {
1475
- const char = value[i];
1476
-
1477
- if (quote) {
1478
- if (char === "\\" && i + 1 < value.length) {
1479
- current += value[i + 1];
1480
- i += 1;
1481
- continue;
1482
- }
1483
- if (char === quote) {
1484
- quote = null;
1485
- continue;
1486
- }
1487
- current += char;
1488
- continue;
1489
- }
1490
-
1491
- if (char === '"' || char === "'") {
1492
- quote = char;
1493
- continue;
1494
- }
1495
-
1496
- if (/\s/.test(char)) {
1497
- if (current.length > 0) {
1498
- tokens.push(current);
1499
- current = "";
1500
- }
1501
- continue;
1502
- }
1503
-
1504
- current += char;
1505
- }
1506
-
1507
- if (current.length > 0) {
1508
- tokens.push(current);
1509
- }
1510
-
1511
- return tokens;
1512
- }
1513
-
1514
- function parseArgs(args: string | undefined): ParsedReviewArgs {
1515
- if (!args?.trim()) return { target: null };
1516
-
1517
- const rawParts = tokenizeArgs(args.trim());
1518
- const parts: string[] = [];
1519
- let extraInstruction: string | undefined;
1520
-
1521
- for (let i = 0; i < rawParts.length; i++) {
1522
- const part = rawParts[i];
1523
- if (part === "--extra") {
1524
- const next = rawParts[i + 1];
1525
- if (!next) {
1526
- return { target: null, error: "Missing value for --extra" };
1527
- }
1528
- extraInstruction = next;
1529
- i += 1;
1530
- continue;
1531
- }
1532
-
1533
- if (part.startsWith("--extra=")) {
1534
- extraInstruction = part.slice("--extra=".length);
1535
- continue;
1536
- }
1537
-
1538
- parts.push(part);
1539
- }
1540
-
1541
- if (parts.length === 0) {
1542
- return { target: null, extraInstruction };
1543
- }
1544
-
1545
- const subcommand = parts[0]?.toLowerCase();
1546
-
1547
- switch (subcommand) {
1548
- case "uncommitted":
1549
- return { target: { type: "uncommitted" }, extraInstruction };
1550
-
1551
- case "branch": {
1552
- const branch = parts[1];
1553
- if (!branch) return { target: null, extraInstruction };
1554
- return { target: { type: "baseBranch", branch }, extraInstruction };
1555
- }
1556
-
1557
- case "commit": {
1558
- const sha = parts[1];
1559
- if (!sha) return { target: null, extraInstruction };
1560
- const title = parts.slice(2).join(" ") || undefined;
1561
- return { target: { type: "commit", sha, title }, extraInstruction };
1562
- }
1563
-
1564
-
1565
- case "folder": {
1566
- const paths = parseReviewPaths(parts.slice(1).join(" "));
1567
- if (paths.length === 0) return { target: null, extraInstruction };
1568
- return { target: { type: "folder", paths }, extraInstruction };
1569
- }
1570
-
1571
- case "pr": {
1572
- const ref = parts[1];
1573
- if (!ref) return { target: null, extraInstruction };
1574
- return { target: { type: "pr", ref }, extraInstruction };
1575
- }
1576
-
1577
- default:
1578
- return { target: null, extraInstruction };
1579
- }
1580
- }
1581
-
1582
1673
  /**
1583
1674
  * Handle PR checkout and return a ReviewTarget (or null on failure)
1584
1675
  */
@@ -1754,7 +1845,7 @@ export default function reviewExtension(pi: ExtensionAPI) {
1754
1845
  pi.registerCommand("review", {
1755
1846
  description: "Review code changes (PR, uncommitted, branch, commit, or folder)",
1756
1847
  handler: async (args, ctx) => {
1757
- if (!ctx.hasUI) {
1848
+ if (ctx.mode !== "tui") {
1758
1849
  ctx.ui.notify("Review requires interactive mode", "error");
1759
1850
  return;
1760
1851
  }
@@ -1859,50 +1950,6 @@ export default function reviewExtension(pi: ExtensionAPI) {
1859
1950
  },
1860
1951
  });
1861
1952
 
1862
- // Custom prompt for review summaries - focuses on preserving actionable findings
1863
- const REVIEW_SUMMARY_PROMPT = `We are leaving a code-review branch and returning to the main coding branch.
1864
- Create a structured handoff that can be used immediately to implement fixes.
1865
-
1866
- You MUST summarize the review that happened in this branch so findings can be acted on.
1867
- Do not omit findings: include every actionable issue that was identified.
1868
-
1869
- Required sections (in order):
1870
-
1871
- ## Review Scope
1872
- - What was reviewed (files/paths, changes, and scope)
1873
-
1874
- ## Verdict
1875
- - "correct" or "needs attention"
1876
-
1877
- ## Findings
1878
- For EACH finding, include:
1879
- - Priority tag ([P0]..[P3]) and short title
1880
- - File location (\`path/to/file.ext:line\`)
1881
- - Why it matters (brief)
1882
- - What should change (brief, actionable)
1883
-
1884
- ## Fix Queue
1885
- 1. Ordered implementation checklist (highest priority first)
1886
-
1887
- ## Constraints & Preferences
1888
- - Any constraints or preferences mentioned during review
1889
- - Or "(none)"
1890
-
1891
- ## Human Reviewer Callouts (Non-Blocking)
1892
- Include only applicable callouts (no yes/no lines):
1893
- - **This change adds a database migration:** <files/details>
1894
- - **This change introduces a new dependency:** <package(s)/details>
1895
- - **This change changes a dependency (or the lockfile):** <files/package(s)/details>
1896
- - **This change modifies auth/permission behavior:** <what changed and where>
1897
- - **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
1898
- - **This change includes irreversible or destructive operations:** <operation and scope>
1899
-
1900
- If none apply, write "- (none)".
1901
-
1902
- These are informational callouts for humans and are not fix items by themselves.
1903
-
1904
- Preserve exact file paths, function names, and error messages where available.`;
1905
-
1906
1953
  const REVIEW_FIX_FINDINGS_PROMPT = `Use the latest review summary in this session and implement the review findings now.
1907
1954
 
1908
1955
  Instructions:
@@ -1954,7 +2001,7 @@ Instructions:
1954
2001
  originId: string,
1955
2002
  showLoader: boolean,
1956
2003
  ): Promise<{ cancelled: boolean; error?: string } | null> {
1957
- if (showLoader && ctx.hasUI) {
2004
+ if (showLoader && ctx.mode === "tui") {
1958
2005
  return ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
1959
2006
  const loader = new BorderedLoader(tui, theme, "Returning and summarizing review branch...");
1960
2007
  loader.onAbort = () => done(null);
@@ -2062,6 +2109,13 @@ Instructions:
2062
2109
  return;
2063
2110
  }
2064
2111
 
2112
+ if (!getActiveReviewOrigin(ctx)) {
2113
+ if (!getReviewState(ctx)?.active) {
2114
+ ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
2115
+ }
2116
+ return;
2117
+ }
2118
+
2065
2119
  if (endReviewInProgress) {
2066
2120
  ctx.ui.notify("/end-review is already running", "info");
2067
2121
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-review",
3
- "version": "0.1.4",
3
+ "version": "0.1.8",
4
4
  "description": "A standalone pi extension that adds /review and /end-review commands adapted from mitsuhiko/agent-stuff.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,5 +33,9 @@
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-coding-agent": "*",
35
35
  "@earendil-works/pi-tui": "*"
36
+ },
37
+ "type": "module",
38
+ "engines": {
39
+ "node": ">=22.19.0"
36
40
  }
37
41
  }