Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"packages/pi-continuous-learning": "0.13.2",
"packages/pi-red-green": "0.2.1",
"packages/pi-compass": "0.2.0",
"packages/pi-simplify": "0.2.0"
"packages/pi-simplify": "0.2.0",
"packages/pi-code-review": "0.1.0"
}
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ packages/
pi-simplify/ # Pi extension: code simplification (/simplify command)
src/ # TypeScript source + tests (*.test.ts alongside source)
CHANGELOG.md # Release history (managed by release-please)
pi-code-review/ # Pi extension: automated language-aware code review
src/ # TypeScript source + tests (*.test.ts alongside source)
CHANGELOG.md # Release history (managed by release-please)
```

## Commands (run from repo root)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A monorepo of [Pi](https://github.com/nicholasgasior/pi-coding-agent) extensions
| [pi-red-green](packages/pi-red-green) | TDD enforcement for agent sessions: RED-GREEN-REFACTOR state machine with phase-specific prompt injection and test run detection | [![npm](https://img.shields.io/npm/v/pi-red-green)](https://www.npmjs.com/package/pi-red-green) |
| [pi-compass](packages/pi-compass) | Codebase navigation: generates structured codemaps and interactive code tours for faster agent onboarding | [![npm](https://img.shields.io/npm/v/pi-compass)](https://www.npmjs.com/package/pi-compass) |
| [pi-simplify](packages/pi-simplify) | Code simplification: reviews recently changed files for clarity, consistency, and maintainability | [![npm](https://img.shields.io/npm/v/pi-simplify)](https://www.npmjs.com/package/pi-simplify) |
| [pi-code-review](packages/pi-code-review) | Automated code review: language-aware review after edits with structured findings | [![npm](https://img.shields.io/npm/v/pi-code-review)](https://www.npmjs.com/package/pi-code-review) |

## Development

Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions packages/pi-code-review/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# pi-code-review

A [Pi](https://github.com/nicholasgasior/pi-coding-agent) extension that provides automated, language-aware code review after the agent writes or modifies files.

## Installation

```bash
pi install npm:pi-code-review
```

## Features

### Automatic review (zero cost)

After each turn where the agent edits files, a language-aware review checklist is injected into the system prompt. The agent self-reviews before proceeding, catching type safety issues, error handling gaps, security concerns, and naming problems.

Supports: TypeScript, Python, Go, Rust, Java, PHP.

### On-demand review (`/review`)

Run a thorough code review with structured findings:

```
/review # review all uncommitted changes
/review --staged # only staged changes
/review --ref=main # diff against main
/review src/foo.ts # specific files
```

When an Anthropic API key is available, `/review` uses a direct Haiku call for structured output with severity-leveled findings (CRITICAL / HIGH / MEDIUM / INFO). Without an API key, it falls back to a prompt-based review via the session agent.

## How it works

1. **Edit tracking**: hooks into `tool_execution_end` to collect files modified by Write/Edit tools during each turn
2. **Turn batching**: at `turn_end`, snapshots the accumulated edits (no per-edit overhead)
3. **Prompt injection**: at `before_agent_start`, injects a brief language-specific review checklist into the system prompt
4. **On-demand**: `/review` reads file contents, calls Haiku for structured analysis, and formats findings with severity, line numbers, and suggestions

## License

MIT
69 changes: 69 additions & 0 deletions packages/pi-code-review/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "pi-code-review",
"version": "0.1.0",
"description": "A Pi extension that provides automated, language-aware code review after the agent writes or modifies files.",
"type": "module",
"license": "MIT",
"author": "Matt Devy",
"repository": {
"type": "git",
"url": "https://github.com/MattDevy/pi-extensions.git",
"directory": "packages/pi-code-review"
},
"homepage": "https://github.com/MattDevy/pi-extensions/tree/main/packages/pi-code-review#readme",
"bugs": {
"url": "https://github.com/MattDevy/pi-extensions/issues"
},
"keywords": [
"pi-package",
"pi-extension",
"pi-coding-agent",
"code-review",
"ai",
"llm",
"ai-agent",
"coding-assistant",
"developer-tools"
],
"engines": {
"node": ">=18"
},
"files": [
"dist",
"src",
"!src/**/*.test.ts",
"README.md",
"LICENSE"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"pi": {
"extensions": [
"dist/index.js"
]
},
"scripts": {
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
"build": "npm run clean && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint src/",
"check": "vitest run && eslint src/ && tsc --noEmit",
"prepublishOnly": "npm run build && npm run check",
"prepack": "test -d dist || { echo 'Error: dist/ missing. Run npm run build first.' && exit 1; }"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "^0.62.0",
"@mariozechner/pi-ai": "^0.62.0",
"@mariozechner/pi-tui": "^0.62.0",
"@sinclair/typebox": "^0.34.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
136 changes: 136 additions & 0 deletions packages/pi-code-review/src/edit-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect } from "vitest";
import { createEditTracker } from "./edit-tracker.js";

describe("createEditTracker", () => {
it("tracks Write tool edits", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { file_path: "src/foo.ts" });

tracker.onTurnEnd(0);
const edits = tracker.getLastTurnEdits();
expect(edits).toEqual({
files: [{ path: "src/foo.ts", language: "typescript" }],
turnIndex: 0,
});
});

it("tracks Edit tool edits", () => {
const tracker = createEditTracker();

tracker.trackEdit("Edit", { file_path: "src/bar.py" });

tracker.onTurnEnd(1);
const edits = tracker.getLastTurnEdits();
expect(edits).toEqual({
files: [{ path: "src/bar.py", language: "python" }],
turnIndex: 1,
});
});

it("extracts path from result.path field", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { path: "main.go" });

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()?.files).toEqual([
{ path: "main.go", language: "go" },
]);
});

it("extracts path from string result via regex", () => {
const tracker = createEditTracker();

tracker.trackEdit("Edit", "File: src/lib.rs updated successfully");

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()?.files).toEqual([
{ path: "src/lib.rs", language: "rust" },
]);
});

it("deduplicates files edited multiple times in a turn", () => {
const tracker = createEditTracker();

tracker.trackEdit("Edit", { file_path: "src/foo.ts" });
tracker.trackEdit("Edit", { file_path: "src/foo.ts" });
tracker.trackEdit("Write", { file_path: "src/foo.ts" });

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()?.files).toHaveLength(1);
});

it("tracks multiple different files", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { file_path: "src/a.ts" });
tracker.trackEdit("Edit", { file_path: "src/b.py" });
tracker.trackEdit("Write", { file_path: "src/c.go" });

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()?.files).toHaveLength(3);
});

it("ignores non-Write/Edit tools", () => {
const tracker = createEditTracker();

tracker.trackEdit("Bash", { stdout: "ok" });
tracker.trackEdit("Read", { file_path: "src/foo.ts" });

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()).toBeNull();
});

it("ignores non-code files", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { file_path: "package.json" });
tracker.trackEdit("Edit", { file_path: "README.md" });

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()).toBeNull();
});

it("clears accumulator after onTurnEnd", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { file_path: "src/foo.ts" });
tracker.onTurnEnd(0);

tracker.trackEdit("Write", { file_path: "src/bar.ts" });
tracker.onTurnEnd(1);

const edits = tracker.getLastTurnEdits();
expect(edits?.files).toEqual([{ path: "src/bar.ts", language: "typescript" }]);
expect(edits?.turnIndex).toBe(1);
});

it("returns null when no edits in last turn", () => {
const tracker = createEditTracker();

tracker.onTurnEnd(0);

expect(tracker.getLastTurnEdits()).toBeNull();
});

it("clearLastTurnEdits removes snapshot", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", { file_path: "src/foo.ts" });
tracker.onTurnEnd(0);
tracker.clearLastTurnEdits();

expect(tracker.getLastTurnEdits()).toBeNull();
});

it("handles null/undefined result gracefully", () => {
const tracker = createEditTracker();

tracker.trackEdit("Write", null);
tracker.trackEdit("Edit", undefined);

tracker.onTurnEnd(0);
expect(tracker.getLastTurnEdits()).toBeNull();
});
});
68 changes: 68 additions & 0 deletions packages/pi-code-review/src/edit-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { detectLanguage } from "./language-detector.js";
import type { EditedFile, TurnEdits } from "./types.js";

const FILE_PATH_RE = /(?:File|file|path)[:\s]+(\S+)/;

type TrackedTool = "Write" | "Edit";
const TRACKED_TOOLS = new Set<TrackedTool>(["Write", "Edit"]);

function extractFilePath(result: unknown): string | null {
if (!result) return null;

if (typeof result === "object") {
const obj = result as Record<string, unknown>;
if (typeof obj["file_path"] === "string") return obj["file_path"];
if (typeof obj["path"] === "string") return obj["path"];
}

if (typeof result === "string") {
const match = result.match(FILE_PATH_RE);
return match?.[1] ?? null;
}

return null;
}

export interface EditTracker {
trackEdit(toolName: string, result: unknown): void;
onTurnEnd(turnIndex: number): void;
getLastTurnEdits(): TurnEdits | null;
clearLastTurnEdits(): void;
}

export function createEditTracker(): EditTracker {
const current = new Map<string, EditedFile>();
let lastTurn: TurnEdits | null = null;

return {
trackEdit(toolName: string, result: unknown): void {
if (!TRACKED_TOOLS.has(toolName as TrackedTool)) return;

const path = extractFilePath(result);
if (!path) return;

const language = detectLanguage(path);
if (!language) return;
if (current.has(path)) return;

current.set(path, { path, language });
},

onTurnEnd(turnIndex: number): void {
if (current.size === 0) {
lastTurn = null;
} else {
lastTurn = { files: [...current.values()], turnIndex };
}
current.clear();
},

getLastTurnEdits(): TurnEdits | null {
return lastTurn;
},

clearLastTurnEdits(): void {
lastTurn = null;
},
};
}
Loading
Loading