pi-critique 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # pi-critique
2
2
 
3
- Structured AI critique for writing and code in [pi](https://github.com/badlogic/pi-mono). Submits a critique prompt to the model, which returns numbered critiques (C1, C2, ...) with inline markers in the original document. Pairs well with [pi-annotated-reply](https://github.com/omaclaren/pi-annotated-reply) and [pi-markdown-preview](https://github.com/omaclaren/pi-markdown-preview) but works standalone.
3
+ Structured AI critique for writing and code in [pi](https://github.com/badlogic/pi-mono). Submits a critique prompt to the model, which returns numbered critiques (C1, C2, ...) with inline markers in the document text. Pairs well with [pi-annotated-reply](https://github.com/omaclaren/pi-annotated-reply) and [pi-markdown-preview](https://github.com/omaclaren/pi-markdown-preview) but works standalone.
4
+
5
+ **Non-destructive by default:** `/critique` is intended to analyze, not edit, your source file.
4
6
 
5
7
  ## Commands
6
8
 
7
9
  | Command | Description |
8
10
  |---------|-------------|
9
11
  | `/critique` | Critique the last assistant response |
10
- | `/critique <path>` | Critique a file |
12
+ | `/critique <path>` | Critique a file (non-destructive; original file is unchanged) |
11
13
  | `/critique --code` | Force code review lens |
12
14
  | `/critique --writing` | Force writing critique lens |
13
15
  | `/critique --no-inline` | Critiques list only, no annotated document |
@@ -31,6 +33,13 @@ The model adapts critique types to the genre:
31
33
 
32
34
  Types are not fixed — the model chooses what fits the content.
33
35
 
36
+ ## Non-destructive behaviour
37
+
38
+ - For normal files, the extension reads content and sends it to the model for critique. It does not directly edit your source file.
39
+ - For large files (see below), the model is instructed to write annotations to a separate file: `<filename>.critique.<ext>`.
40
+ - During auto-submitted `/critique <path>` runs, a safety guard blocks write/edit tool calls to prevent accidental in-place edits.
41
+ - To actually apply changes to the original file, run a separate explicit editing step (for example via `/reply` + a follow-up prompt).
42
+
34
43
  ## Lenses
35
44
 
36
45
  The extension auto-detects whether content is code or writing based on file extension. Override with `--code` or `--writing`.
@@ -41,6 +50,8 @@ Code files (`.ts`, `.py`, `.rs`, `.go`, etc.) get a code review prompt. Writing
41
50
 
42
51
  Files over 500 lines are handled differently to save tokens: the extension passes the file path to the model (rather than embedding the content), and the model reads the file with its tools and writes an annotated copy to `<filename>.critique.<ext>` on disk.
43
52
 
53
+ The original file path is preserved; annotations are written to the `*.critique.*` copy.
54
+
44
55
  ## Example output
45
56
 
46
57
  ```markdown
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { basename, extname, isAbsolute, join, resolve } from "node:path";
4
4
 
@@ -195,6 +195,21 @@ function expandHome(pathInput: string): string {
195
195
  return join(home, pathInput.slice(2));
196
196
  }
197
197
 
198
+ function normalizePathForComparison(pathInput: string, cwd: string): string {
199
+ const withoutAt = pathInput.startsWith("@") ? pathInput.slice(1) : pathInput;
200
+ const expanded = expandHome(withoutAt.trim());
201
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
202
+ }
203
+
204
+ function pathsEqual(a: string, b: string): boolean {
205
+ if (process.platform === "win32") return a.toLowerCase() === b.toLowerCase();
206
+ return a === b;
207
+ }
208
+
209
+ type CritiqueWriteGuard =
210
+ | { mode: "deny-all"; sourcePath: string }
211
+ | { mode: "allow-annotated-only"; sourcePath: string; annotatedPath: string };
212
+
198
213
  function detectLens(filePath?: string): Lens {
199
214
  if (!filePath) return "writing";
200
215
  const ext = extname(filePath).toLowerCase();
@@ -230,9 +245,8 @@ function getLastAssistantMarkdown(ctx: ExtensionCommandContext): string | undefi
230
245
  return undefined;
231
246
  }
232
247
 
233
- function readFileContent(filePath: string, cwd: string): { ok: true; content: string; label: string } | { ok: false; message: string } {
234
- const expanded = expandHome(filePath.trim());
235
- const resolved = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
248
+ function readFileContent(filePath: string, cwd: string): { ok: true; content: string; label: string; resolvedPath: string } | { ok: false; message: string } {
249
+ const resolved = normalizePathForComparison(filePath, cwd);
236
250
 
237
251
  try {
238
252
  const stats = statSync(resolved);
@@ -249,7 +263,7 @@ function readFileContent(filePath: string, cwd: string): { ok: true; content: st
249
263
  if (content.includes("\u0000")) {
250
264
  return { ok: false, message: `File appears to be binary: ${filePath}` };
251
265
  }
252
- return { ok: true, content, label: filePath };
266
+ return { ok: true, content, label: filePath, resolvedPath: resolved };
253
267
  } catch (error) {
254
268
  const msg = error instanceof Error ? error.message : String(error);
255
269
  return { ok: false, message: `Failed to read file: ${msg}` };
@@ -332,6 +346,38 @@ function parseArgs(args: string): ParsedArgs {
332
346
  }
333
347
 
334
348
  export default function (pi: ExtensionAPI) {
349
+ let critiqueWriteGuard: CritiqueWriteGuard | undefined;
350
+
351
+ pi.on("agent_end", async () => {
352
+ critiqueWriteGuard = undefined;
353
+ });
354
+
355
+ pi.on("tool_call", async (event, ctx) => {
356
+ if (!critiqueWriteGuard) return;
357
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
358
+
359
+ const inputPath = (event.input as { path?: unknown } | undefined)?.path;
360
+ if (typeof inputPath !== "string" || !inputPath.trim()) {
361
+ return { block: true, reason: "Blocked by /critique safety guard: write/edit path is missing." };
362
+ }
363
+
364
+ const targetPath = normalizePathForComparison(inputPath, ctx.cwd);
365
+
366
+ if (critiqueWriteGuard.mode === "deny-all") {
367
+ return {
368
+ block: true,
369
+ reason: `Blocked by /critique safety guard: critique runs are non-destructive (source: ${critiqueWriteGuard.sourcePath}). Use a separate follow-up prompt to apply edits.`,
370
+ };
371
+ }
372
+
373
+ if (!pathsEqual(targetPath, critiqueWriteGuard.annotatedPath)) {
374
+ return {
375
+ block: true,
376
+ reason: `Blocked by /critique safety guard: only the annotated output path is writable (${critiqueWriteGuard.annotatedPath}); source preserved (${critiqueWriteGuard.sourcePath}).`,
377
+ };
378
+ }
379
+ });
380
+
335
381
  pi.registerCommand("critique", {
336
382
  description: "Critique a file or the last response. Usage: /critique [path] [--code|--writing] [--no-inline] [--edit]",
337
383
  handler: async (args, ctx) => {
@@ -361,9 +407,11 @@ export default function (pi: ExtensionAPI) {
361
407
  }
362
408
 
363
409
  await ctx.waitForIdle();
410
+ critiqueWriteGuard = undefined;
364
411
 
365
412
  let content: string;
366
413
  let label: string;
414
+ let sourcePath: string | undefined;
367
415
 
368
416
  if (parsed.file) {
369
417
  const result = readFileContent(parsed.file, ctx.cwd);
@@ -373,6 +421,7 @@ export default function (pi: ExtensionAPI) {
373
421
  }
374
422
  content = result.content;
375
423
  label = result.label;
424
+ sourcePath = result.resolvedPath;
376
425
  } else {
377
426
  const markdown = getLastAssistantMarkdown(ctx);
378
427
  if (!markdown) {
@@ -389,19 +438,24 @@ export default function (pi: ExtensionAPI) {
389
438
 
390
439
  // Large files: pass filepath, model reads and writes annotated copy to disk
391
440
  if (isLargeFile) {
392
- const expanded = expandHome(parsed.file!.trim());
393
- const resolvedPath = isAbsolute(expanded) ? expanded : resolve(ctx.cwd, expanded);
441
+ const resolvedPath = sourcePath ?? normalizePathForComparison(parsed.file!, ctx.cwd);
394
442
  const ext = extname(resolvedPath);
395
443
  const base = resolvedPath.slice(0, resolvedPath.length - ext.length);
396
444
  const annotatedPath = `${base}.critique${ext}`;
445
+ const normalizedAnnotatedPath = normalizePathForComparison(annotatedPath, ctx.cwd);
397
446
  const prompt = buildLargeFilePrompt(lens, resolvedPath, annotatedPath);
398
447
 
399
448
  if (parsed.edit) {
400
449
  ctx.ui.setEditorText(prompt);
401
450
  ctx.ui.notify(`Critique prompt (${lens}) for ${label} loaded into editor. Edit and submit when ready.`, "info");
402
451
  } else {
452
+ critiqueWriteGuard = {
453
+ mode: "allow-annotated-only",
454
+ sourcePath: resolvedPath,
455
+ annotatedPath: normalizedAnnotatedPath,
456
+ };
403
457
  pi.sendUserMessage(prompt);
404
- ctx.ui.notify(`Critiquing ${label} (${lens}, ${contentLines} lines). Annotated copy → ${annotatedPath}`, "info");
458
+ ctx.ui.notify(`Critiquing ${label} (${lens}, ${contentLines} lines). Original preserved; annotated copy → ${annotatedPath}`, "info");
405
459
  }
406
460
  return;
407
461
  }
@@ -416,8 +470,9 @@ export default function (pi: ExtensionAPI) {
416
470
  ctx.ui.setEditorText(prompt);
417
471
  ctx.ui.notify(`Critique prompt (${lens}) for ${label} loaded into editor. Edit and submit when ready.`, "info");
418
472
  } else {
473
+ critiqueWriteGuard = sourcePath ? { mode: "deny-all", sourcePath } : undefined;
419
474
  pi.sendUserMessage(prompt);
420
- ctx.ui.notify(`Critiquing ${label} (${lens}${parsed.inline ? ", inline" : ""})... Respond with [accept C1], [reject C2: reason], etc.`, "info");
475
+ ctx.ui.notify(`Critiquing ${label} (${lens}${parsed.inline ? ", inline" : ""})... Original file unchanged. Respond with [accept C1], [reject C2: reason], etc.`, "info");
421
476
  }
422
477
  },
423
478
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-critique",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Structured AI critique for writing and code. Pairs well with annotated-reply and markdown-preview but works standalone.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,14 @@
17
17
  ]
18
18
  },
19
19
  "peerDependencies": {
20
- "@mariozechner/pi-coding-agent": "*"
20
+ "@earendil-works/pi-coding-agent": "*"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.3.0",
27
+ "typescript": "^5.7.3",
28
+ "@earendil-works/pi-coding-agent": "^0.74.0"
21
29
  }
22
30
  }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true,
9
+ "types": ["node"]
10
+ },
11
+ "include": ["index.ts"]
12
+ }