@mrclrchtr/supi-review 2.7.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 (49) hide show
  1. package/README.md +75 -163
  2. package/node_modules/@mrclrchtr/supi-core/README.md +1 -1
  3. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/settings/scoped-settings-list.ts +1 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +14 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-submenus.ts +31 -14
  9. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +1 -0
  10. package/package.json +3 -3
  11. package/src/config.ts +64 -0
  12. package/src/git-command.ts +78 -0
  13. package/src/git.ts +306 -505
  14. package/src/history/collect.ts +43 -120
  15. package/src/model.ts +35 -0
  16. package/src/review-path.ts +85 -0
  17. package/src/review-result.ts +43 -92
  18. package/src/review.ts +154 -288
  19. package/src/session/review-plan-store.ts +20 -51
  20. package/src/target/packet.ts +46 -369
  21. package/src/tool/agent-review-schemas.ts +101 -104
  22. package/src/tool/agent-review-tools.ts +269 -301
  23. package/src/tool/child-failure-diagnostics.ts +1 -1
  24. package/src/tool/child-resource-loader.ts +37 -0
  25. package/src/tool/child-session-runner.ts +83 -0
  26. package/src/tool/output-page.ts +47 -0
  27. package/src/tool/planner-runner.ts +69 -0
  28. package/src/tool/review-runner.ts +42 -257
  29. package/src/tool/review-system-prompt.ts +12 -110
  30. package/src/tool/review-tools.ts +119 -0
  31. package/src/tool/review-workflow.ts +309 -0
  32. package/src/tool/runner-helpers.ts +3 -8
  33. package/src/tool/schemas.ts +75 -62
  34. package/src/tui/common.ts +175 -0
  35. package/src/tui/prepare.ts +132 -0
  36. package/src/tui/run.ts +160 -0
  37. package/src/types.ts +153 -275
  38. package/src/history/synthesize.ts +0 -121
  39. package/src/tool/agent-review-workflow.ts +0 -398
  40. package/src/tool/brief-runner.ts +0 -180
  41. package/src/tool/guidance.ts +0 -21
  42. package/src/tool/review-handlers.ts +0 -314
  43. package/src/tool/snapshot-tools.ts +0 -117
  44. package/src/ui/flow.ts +0 -189
  45. package/src/ui/format-content.ts +0 -143
  46. package/src/ui/renderer.ts +0 -274
  47. package/src/ui/review-plan-inspector.ts +0 -391
  48. package/src/ui/review-tool-format.ts +0 -138
  49. 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,6 +2,9 @@ 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 an agent review operation starts. */
6
+ export const CURRENT_SESSION_REVIEW_MODEL = "current";
7
+
5
8
  /** Build the canonical `provider/modelId` string used throughout the review flow. */
6
9
  export { toCanonicalModelId } from "@mrclrchtr/supi-core/model-selection";
7
10
 
@@ -12,6 +15,7 @@ export { toCanonicalModelId } from "@mrclrchtr/supi-core/model-selection";
12
15
  */
13
16
  export function getSelectableReviewModels(
14
17
  ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
18
+ /** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
15
19
  enabledModelPatterns?: string[],
16
20
  ): ReviewModelSelection[] {
17
21
  return getSelectableModels(ctx, enabledModelPatterns) as ReviewModelSelection[];
@@ -35,3 +39,34 @@ export function getCurrentReviewModel(
35
39
  isCurrent: true,
36
40
  };
37
41
  }
42
+
43
+ /**
44
+ * Resolve the configured model for an agent-driven review.
45
+ *
46
+ * `current` preserves the historical behavior. Explicit canonical model ids
47
+ * must be both available and present in Pi's current scoped model set.
48
+ */
49
+ export function resolveAgentReviewModel(
50
+ ctx: Pick<ExtensionContext, "cwd" | "modelRegistry" | "model">,
51
+ configuredModelId: string,
52
+ /** Optional override for Pi's scoped model patterns; omit to use configured defaults. */
53
+ enabledModelPatterns?: string[],
54
+ ): ReviewModelSelection | undefined {
55
+ const modelId = configuredModelId.trim();
56
+ if (modelId === CURRENT_SESSION_REVIEW_MODEL) {
57
+ return getCurrentReviewModel(ctx);
58
+ }
59
+
60
+ const selection = getSelectableReviewModels(
61
+ { cwd: ctx.cwd, modelRegistry: ctx.modelRegistry, model: undefined },
62
+ enabledModelPatterns,
63
+ ).find((candidate) => candidate.canonicalId === modelId);
64
+ if (!selection) return undefined;
65
+
66
+ return {
67
+ ...selection,
68
+ isCurrent: ctx.model
69
+ ? `${ctx.model.provider}/${ctx.model.id}` === selection.canonicalId
70
+ : false,
71
+ };
72
+ }
@@ -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
  }