@mrclrchtr/supi-review 2.8.0 → 3.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.
Files changed (44) hide show
  1. package/README.md +71 -180
  2. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  3. package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
  5. package/package.json +3 -3
  6. package/src/config.ts +30 -21
  7. package/src/git-command.ts +78 -0
  8. package/src/git.ts +306 -505
  9. package/src/history/collect.ts +43 -120
  10. package/src/model.ts +3 -1
  11. package/src/review-path.ts +85 -0
  12. package/src/review-result.ts +43 -92
  13. package/src/review.ts +154 -290
  14. package/src/session/review-plan-store.ts +20 -51
  15. package/src/target/packet.ts +46 -369
  16. package/src/tool/agent-review-schemas.ts +101 -104
  17. package/src/tool/agent-review-tools.ts +268 -309
  18. package/src/tool/child-failure-diagnostics.ts +1 -1
  19. package/src/tool/child-resource-loader.ts +37 -0
  20. package/src/tool/child-session-runner.ts +83 -0
  21. package/src/tool/output-page.ts +47 -0
  22. package/src/tool/planner-runner.ts +69 -0
  23. package/src/tool/review-runner.ts +42 -257
  24. package/src/tool/review-system-prompt.ts +12 -110
  25. package/src/tool/review-tools.ts +119 -0
  26. package/src/tool/review-workflow.ts +309 -0
  27. package/src/tool/runner-helpers.ts +3 -8
  28. package/src/tool/schemas.ts +75 -62
  29. package/src/tui/common.ts +175 -0
  30. package/src/tui/prepare.ts +132 -0
  31. package/src/tui/run.ts +160 -0
  32. package/src/types.ts +153 -275
  33. package/src/history/synthesize.ts +0 -121
  34. package/src/tool/agent-review-workflow.ts +0 -398
  35. package/src/tool/brief-runner.ts +0 -180
  36. package/src/tool/guidance.ts +0 -21
  37. package/src/tool/review-handlers.ts +0 -314
  38. package/src/tool/snapshot-tools.ts +0 -117
  39. package/src/ui/flow.ts +0 -189
  40. package/src/ui/format-content.ts +0 -143
  41. package/src/ui/renderer.ts +0 -274
  42. package/src/ui/review-plan-inspector.ts +0 -391
  43. package/src/ui/review-tool-format.ts +0 -138
  44. package/src/ui/review-tool-renderer.ts +0 -234
@@ -1,135 +1,58 @@
1
1
  import type { SessionContext } from "@earendil-works/pi-coding-agent";
2
- import { extractAssistantText } from "../tool/runner-helpers.ts";
2
+ import { extractVisibleText } from "../tool/runner-helpers.ts";
3
3
 
4
- type ResolvedSessionMessage = SessionContext["messages"][number];
4
+ const MAX_PLANNER_CONTEXT_CHARS = 8_000;
5
+ type Message = SessionContext["messages"][number];
5
6
 
6
- const DEFAULT_MAX_CHARS = 8_000;
7
-
8
- export interface SerializeSessionContextOptions {
9
- maxChars?: number;
7
+ interface PlannerRow {
8
+ kind: "summary" | "visible";
9
+ text: string;
10
10
  }
11
11
 
12
- /**
13
- * Serialize the resolved LLM-visible session context into a compaction-style
14
- * readable transcript for the brief synthesizer.
15
- *
16
- * This mirrors the overall approach Pi uses for compaction:
17
- * - messages are labeled by role and rendered in chronological order
18
- * - compaction and branch summaries appear with their own labels
19
- * - the output is bounded so the synthesizer stays predictable on long sessions
20
- * - no heuristic ranking or scoring is applied
21
- */
22
- export function serializeSessionContext(
23
- messages: ResolvedSessionMessage[],
24
- options?: SerializeSessionContextOptions,
25
- ): string {
26
- const maxChars = options?.maxChars ?? DEFAULT_MAX_CHARS;
27
- if (messages.length === 0) return "";
28
-
29
- const entries = messages
30
- .map((message) => serializeEntry(message))
31
- .filter(
32
- (entry): entry is { label: string; text: string; isSummary: boolean } => entry !== undefined,
33
- );
34
-
35
- if (entries.length === 0) return "";
36
-
37
- // Separate summary entries (compaction/branch summaries) from regular entries
38
- const summaryEntries = entries.filter((e) => e.isSummary);
39
- const regularEntries = entries.filter((e) => !e.isSummary);
40
-
41
- // Estimate the total size if we keep everything
42
- const allLines = entries.map(formatEntry);
43
- const totalSize = measureLines(allLines);
44
-
45
- if (totalSize <= maxChars) {
46
- return allLines.join("\n");
12
+ function toPlannerRow(message: Message): PlannerRow | undefined {
13
+ if (message.role === "compactionSummary") {
14
+ return { kind: "summary", text: `[Summary]\n${message.summary}` };
47
15
  }
48
-
49
- // Build the output: always include summaries, then as many recent regular entries as fit
50
- const summaryLines = summaryEntries.map(formatEntry);
51
-
52
- // Walk regular entries from newest to oldest, keeping as many as fit
53
- const keptRegular: typeof regularEntries = [];
54
- let size = measureLines(summaryLines);
55
-
56
- for (let i = regularEntries.length - 1; i >= 0; i--) {
57
- const line = formatEntry(regularEntries[i]);
58
- const lineLen = line.length + 1; // +1 for newline
59
- if (keptRegular.length > 0 && size + lineLen > maxChars) {
60
- break;
61
- }
62
- keptRegular.unshift(regularEntries[i]);
63
- size += lineLen;
64
- }
65
-
66
- // Build final output: summaries first, then kept recent entries
67
- const keptLines = [...summaryLines, ...keptRegular.map(formatEntry)];
68
-
69
- // If still nothing fits with summaries alone, truncate the summaries
70
- if (keptLines.length > 0) {
71
- const output = keptLines.join("\n");
72
- if (output.length <= maxChars) return output;
16
+ if (message.role === "branchSummary") {
17
+ return { kind: "summary", text: `[Branch summary]\n${message.summary}` };
73
18
  }
74
-
75
- // Last resort: truncate the full output
76
- return keepFirstLines(keptLines, maxChars);
77
- }
78
-
79
- function formatEntry(entry: { label: string; text: string }): string {
80
- return `[${entry.label}]\n${entry.text}`;
19
+ if (message.role !== "user" && message.role !== "assistant") return undefined;
20
+ const text = extractVisibleText(message.content)?.trim();
21
+ if (!text) return undefined;
22
+ const label = message.role === "user" ? "User" : "Assistant";
23
+ return { kind: "visible", text: `[${label}]\n${text}` };
81
24
  }
82
25
 
83
- function measureLines(lines: string[]): number {
84
- return lines.reduce((acc, line) => acc + line.length + 1, 0); // +1 for newline
85
- }
86
-
87
- function keepFirstLines(lines: string[], maxChars: number): string {
88
- const kept: string[] = [];
26
+ function takeRecentRows(rows: string[], maxChars: number): string {
27
+ const recent: string[] = [];
89
28
  let size = 0;
90
- for (const line of lines) {
91
- const lineLen = line.length + 1;
92
- if (size + lineLen > maxChars) break;
93
- kept.push(line);
94
- size += lineLen;
95
- }
96
- return kept.join("\n");
97
- }
98
-
99
- function serializeEntry(
100
- message: ResolvedSessionMessage,
101
- ): { label: string; text: string; isSummary: boolean } | undefined {
102
- switch (message.role) {
103
- case "user":
104
- case "assistant": {
105
- const text = normalizeText(extractAssistantText(message.content) ?? "");
106
- if (!text) return undefined;
107
- return {
108
- label: message.role === "user" ? "User" : "Assistant",
109
- text,
110
- isSummary: false,
111
- };
112
- }
113
- case "custom": {
114
- const text = normalizeText(extractAssistantText(message.content) ?? "");
115
- if (!text) return undefined;
116
- return { label: "Custom", text, isSummary: false };
117
- }
118
- case "compactionSummary": {
119
- const text = normalizeText(message.summary);
120
- if (!text) return undefined;
121
- return { label: "Compaction summary", text, isSummary: true };
122
- }
123
- case "branchSummary": {
124
- const text = normalizeText(message.summary);
125
- if (!text) return undefined;
126
- return { label: "Branch summary", text, isSummary: true };
29
+ for (let index = rows.length - 1; index >= 0; index--) {
30
+ const row = rows[index];
31
+ const separator = recent.length > 0 ? 1 : 0;
32
+ const remaining = maxChars - size - separator;
33
+ if (row.length > remaining) {
34
+ if (recent.length === 0 && remaining > 0) recent.unshift(row.slice(0, remaining));
35
+ break;
127
36
  }
128
- default:
129
- return undefined;
37
+ recent.unshift(row);
38
+ size += row.length + separator;
130
39
  }
40
+ return recent.join("\n");
131
41
  }
132
42
 
133
- function normalizeText(text: string): string {
134
- return text.replace(/\s+/g, " ").trim();
43
+ /** Build a small planning-only projection of summaries and recent visible conversation. */
44
+ export function collectPlannerContext(messages: Message[]): string {
45
+ const rows = messages.flatMap((message) => {
46
+ const row = toPlannerRow(message);
47
+ return row ? [row] : [];
48
+ });
49
+ const summaries = rows
50
+ .filter((row) => row.kind === "summary")
51
+ .map((row) => row.text)
52
+ .join("\n")
53
+ .slice(0, MAX_PLANNER_CONTEXT_CHARS);
54
+ const visible = rows.filter((row) => row.kind === "visible").map((row) => row.text);
55
+ const separator = summaries && visible.length > 0 ? 1 : 0;
56
+ const recent = takeRecentRows(visible, MAX_PLANNER_CONTEXT_CHARS - summaries.length - separator);
57
+ return [summaries, recent].filter(Boolean).join("\n");
135
58
  }
package/src/model.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getSelectableModels } from "@mrclrchtr/supi-core/model-selection";
3
3
  import type { ReviewModelSelection } from "./types.ts";
4
4
 
5
- /** Sentinel that resolves to the model active when review preparation starts. */
5
+ /** Sentinel that resolves to the model active when an agent review operation starts. */
6
6
  export const CURRENT_SESSION_REVIEW_MODEL = "current";
7
7
 
8
8
  /** Build the canonical `provider/modelId` string used throughout the review flow. */
@@ -15,6 +15,7 @@ export { toCanonicalModelId } from "@mrclrchtr/supi-core/model-selection";
15
15
  */
16
16
  export function getSelectableReviewModels(
17
17
  ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
18
+ /** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
18
19
  enabledModelPatterns?: string[],
19
20
  ): ReviewModelSelection[] {
20
21
  return getSelectableModels(ctx, enabledModelPatterns) as ReviewModelSelection[];
@@ -48,6 +49,7 @@ export function getCurrentReviewModel(
48
49
  export function resolveAgentReviewModel(
49
50
  ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
50
51
  configuredModelId: string,
52
+ /** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
51
53
  enabledModelPatterns?: string[],
52
54
  ): ReviewModelSelection | undefined {
53
55
  const modelId = configuredModelId.trim();
@@ -0,0 +1,85 @@
1
+ import { lstat, readFile, readlink, realpath } from "node:fs/promises";
2
+ import { dirname, posix, relative, resolve, sep, win32 } from "node:path";
3
+
4
+ /** Verified repository-relative review path that cannot lexically escape the worktree. */
5
+ export interface SafeReviewPath {
6
+ absolute: string;
7
+ path: string;
8
+ }
9
+
10
+ /** Normalize a portable repository-relative path without touching the filesystem. */
11
+ export function normalizeRepositoryRelativePath(path: string): string {
12
+ if (!path || path.includes("\0") || posix.isAbsolute(path) || win32.isAbsolute(path)) {
13
+ throw new Error(`Review path must stay inside the repository: ${path}`);
14
+ }
15
+ const normalized = posix.normalize(path.replaceAll("\\", "/"));
16
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
17
+ throw new Error(`Review path must stay inside the repository: ${path}`);
18
+ }
19
+ return normalized;
20
+ }
21
+
22
+ /** Resolve a repository-relative tool path without allowing lexical escape. */
23
+ export function resolveReviewPath(cwd: string, path: string): SafeReviewPath {
24
+ const normalizedInput = normalizeRepositoryRelativePath(path);
25
+ const absolute = resolve(cwd, normalizedInput);
26
+ const normalized = relative(resolve(cwd), absolute);
27
+ if (!normalized || normalized === ".." || normalized.startsWith(`..${sep}`)) {
28
+ throw new Error(`Review path must stay inside the repository: ${path}`);
29
+ }
30
+ return { absolute, path: normalized.split(sep).join("/") };
31
+ }
32
+
33
+ function isMissingPath(error: unknown): boolean {
34
+ return (
35
+ !!error &&
36
+ typeof error === "object" &&
37
+ "code" in error &&
38
+ ((error as { code?: unknown }).code === "ENOENT" ||
39
+ (error as { code?: unknown }).code === "ENOTDIR")
40
+ );
41
+ }
42
+
43
+ function assertInsideRepository(repository: string, candidate: string, originalPath: string): void {
44
+ const rel = relative(repository, candidate);
45
+ if (rel === ".." || rel.startsWith(`..${sep}`)) {
46
+ throw new Error(`Review path must stay inside the repository: ${originalPath}`);
47
+ }
48
+ }
49
+
50
+ /** Read a working-tree file without following an intermediate symlink outside the repository. */
51
+ export async function readWorkingTreeFile(cwd: string, path: string): Promise<string | undefined> {
52
+ const safe = resolveReviewPath(cwd, path);
53
+ try {
54
+ const [repository, parent, stat] = await Promise.all([
55
+ realpath(cwd),
56
+ realpath(dirname(safe.absolute)),
57
+ lstat(safe.absolute),
58
+ ]);
59
+ assertInsideRepository(repository, parent, path);
60
+ if (stat.isSymbolicLink()) return await readlink(safe.absolute);
61
+ if (!stat.isFile()) return undefined;
62
+ const resolvedFile = await realpath(safe.absolute);
63
+ assertInsideRepository(repository, resolvedFile, path);
64
+ return await readFile(safe.absolute, "utf8");
65
+ } catch (error) {
66
+ if (isMissingPath(error)) return undefined;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ /** Remove deleted paths from a Git-produced repository-relative path list. */
72
+ export async function filterExistingReviewPaths(cwd: string, paths: string[]): Promise<string[]> {
73
+ const existing = await Promise.all(
74
+ paths.map(async (path) => {
75
+ try {
76
+ await lstat(resolveReviewPath(cwd, path).absolute);
77
+ return path;
78
+ } catch (error) {
79
+ if (isMissingPath(error)) return undefined;
80
+ throw error;
81
+ }
82
+ }),
83
+ );
84
+ return existing.filter((path): path is string => path !== undefined);
85
+ }
@@ -1,98 +1,49 @@
1
- import type {
2
- NormalizedReviewOutput,
3
- RawReviewResult,
4
- ReviewItem,
5
- ReviewItemCategory,
6
- ReviewItemEffort,
7
- ReviewItemImpact,
8
- ReviewItemRecommendedAction,
9
- ReviewOutputEvent,
10
- ReviewResult,
11
- ReviewSummary,
12
- } from "./types.ts";
1
+ import { normalizeRepositoryRelativePath } from "./review-path.ts";
2
+ import type { NormalizedReviewSubmission, ReviewSubmission } from "./types.ts";
13
3
 
14
- const actionRank: Record<ReviewItemRecommendedAction, number> = {
15
- "must-fix": 0,
16
- "should-fix": 1,
17
- consider: 2,
18
- };
19
-
20
- const impactRank: Record<ReviewItemImpact, number> = {
21
- high: 0,
22
- medium: 1,
23
- low: 2,
24
- };
25
-
26
- const effortRank: Record<ReviewItemEffort, number> = {
27
- low: 0,
28
- medium: 1,
29
- high: 2,
30
- };
31
-
32
- /** Normalize raw reviewer output into the host-owned review contract. */
33
- export function normalizeReviewOutput(output: ReviewOutputEvent): NormalizedReviewOutput {
34
- const items = [...(Array.isArray(output.items) ? output.items : [])].sort(compareReviewItems);
35
- const summary = summarizeReviewItems(items);
36
-
37
- return {
38
- ...output,
39
- items,
40
- overall_correctness: summary.actions.mustFix > 0 ? "PATCH HAS ISSUES" : "PATCH IS CORRECT",
41
- summary,
42
- };
4
+ function requireText(value: string, label: string): string {
5
+ const normalized = value.trim();
6
+ if (!normalized) throw new Error(`${label} must not be blank.`);
7
+ return normalized;
43
8
  }
44
9
 
45
- /** Normalize a raw reviewer result for rendering and follow-up handling. */
46
- export function normalizeReviewResult(result: RawReviewResult): ReviewResult {
47
- if (result.kind !== "success") {
48
- return result;
49
- }
50
-
10
+ /** Derive the machine verdict while preserving reviewer-authored finding order. */
11
+ export function normalizeReviewSubmission(
12
+ submission: ReviewSubmission,
13
+ ): NormalizedReviewSubmission {
51
14
  return {
52
- ...result,
53
- output: normalizeReviewOutput(result.output),
54
- };
55
- }
56
-
57
- function compareReviewItems(left: ReviewItem, right: ReviewItem): number {
58
- const actionDiff = actionRank[left.recommended_action] - actionRank[right.recommended_action];
59
- if (actionDiff !== 0) return actionDiff;
60
-
61
- const impactDiff = impactRank[left.impact] - impactRank[right.impact];
62
- if (impactDiff !== 0) return impactDiff;
63
-
64
- const effortDiff = effortRank[left.effort] - effortRank[right.effort];
65
- if (effortDiff !== 0) return effortDiff;
66
-
67
- return right.confidence_score - left.confidence_score;
68
- }
69
-
70
- function summarizeReviewItems(items: ReviewItem[]): ReviewSummary {
71
- const categories: Partial<Record<ReviewItemCategory, number>> = {};
72
- const summary: ReviewSummary = {
73
- actions: {
74
- mustFix: 0,
75
- shouldFix: 0,
76
- consider: 0,
77
- },
78
- categories,
15
+ summary: requireText(submission.summary, "Review summary"),
16
+ findings: submission.findings.map((finding, index) => {
17
+ if (
18
+ !Number.isFinite(finding.confidence) ||
19
+ finding.confidence < 0 ||
20
+ finding.confidence > 1
21
+ ) {
22
+ throw new Error(`Finding ${index + 1} confidence must be between 0 and 1.`);
23
+ }
24
+ if (
25
+ finding.location &&
26
+ (!Number.isSafeInteger(finding.location.startLine) ||
27
+ !Number.isSafeInteger(finding.location.endLine) ||
28
+ finding.location.startLine < 1 ||
29
+ finding.location.endLine < finding.location.startLine)
30
+ ) {
31
+ throw new Error(`Finding ${index + 1} location must contain a valid ordered line range.`);
32
+ }
33
+ return {
34
+ ...finding,
35
+ title: requireText(finding.title, `Finding ${index + 1} title`),
36
+ description: requireText(finding.description, `Finding ${index + 1} description`),
37
+ location: finding.location
38
+ ? {
39
+ ...finding.location,
40
+ path: normalizeRepositoryRelativePath(
41
+ requireText(finding.location.path, `Finding ${index + 1} location path`),
42
+ ),
43
+ }
44
+ : undefined,
45
+ };
46
+ }),
47
+ verdict: submission.findings.some((finding) => finding.blocksAcceptance) ? "issues" : "pass",
79
48
  };
80
-
81
- for (const item of items) {
82
- switch (item.recommended_action) {
83
- case "must-fix":
84
- summary.actions.mustFix++;
85
- break;
86
- case "should-fix":
87
- summary.actions.shouldFix++;
88
- break;
89
- case "consider":
90
- summary.actions.consider++;
91
- break;
92
- }
93
-
94
- categories[item.category] = (categories[item.category] ?? 0) + 1;
95
- }
96
-
97
- return summary;
98
49
  }