pi-workflow-engine 0.13.0 → 1.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.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +31 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { throwIfAborted } from "../cancellation.ts";
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
2
|
+
import { isFiniteNumber, isRecord } from "../guards.ts";
|
|
3
|
+
import type { CommentableReviewIssue, ReviewIssue } from "./review-issues.ts";
|
|
4
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
4
5
|
|
|
5
6
|
export interface ExecResultLike {
|
|
6
7
|
readonly stdout: string;
|
|
@@ -30,24 +31,16 @@ export type InlineCommentStatus =
|
|
|
30
31
|
| { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
|
|
31
32
|
| { readonly issueId: string; readonly status: "failed"; readonly reason: string };
|
|
32
33
|
|
|
33
|
-
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
|
|
34
|
-
|
|
35
34
|
export async function resolveGitHubPrContext(
|
|
36
35
|
exec: ExecLike,
|
|
37
36
|
cwd: string,
|
|
38
|
-
|
|
37
|
+
number: number,
|
|
39
38
|
signal?: AbortSignal,
|
|
40
39
|
): Promise<ResolveGitHubPrContextResult> {
|
|
41
|
-
const
|
|
42
|
-
const prArgs = parsedNumber
|
|
43
|
-
? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
|
|
44
|
-
: ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
|
|
45
|
-
const prView = await runJson(exec, cwd, prArgs, signal);
|
|
40
|
+
const prView = await runJson(exec, cwd, ["pr", "view", String(number), "--json", "headRefOid,url"], signal);
|
|
46
41
|
if (!prView.ok) return { ok: false, reason: prView.reason };
|
|
47
42
|
|
|
48
|
-
const number = numberField(prView.value, "number") ?? parsedNumber;
|
|
49
43
|
const headSha = stringField(prView.value, "headRefOid");
|
|
50
|
-
if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
|
|
51
44
|
if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
|
|
52
45
|
|
|
53
46
|
const url = stringField(prView.value, "url");
|
|
@@ -82,15 +75,13 @@ ${evidence}
|
|
|
82
75
|
Recommendation: ${finding.recommendation}`;
|
|
83
76
|
}
|
|
84
77
|
|
|
85
|
-
|
|
78
|
+
async function postInlineComment(
|
|
86
79
|
exec: ExecLike,
|
|
87
80
|
cwd: string,
|
|
88
81
|
prContext: GitHubPrContext,
|
|
89
|
-
issue:
|
|
82
|
+
issue: CommentableReviewIssue,
|
|
90
83
|
signal?: AbortSignal,
|
|
91
84
|
): Promise<InlineCommentStatus> {
|
|
92
|
-
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
93
|
-
|
|
94
85
|
const result = await runCommand(exec, cwd, [
|
|
95
86
|
"api",
|
|
96
87
|
`repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
|
|
@@ -113,7 +104,7 @@ export async function postInlineComments(
|
|
|
113
104
|
exec: ExecLike,
|
|
114
105
|
cwd: string,
|
|
115
106
|
prContext: GitHubPrContext,
|
|
116
|
-
issues: readonly
|
|
107
|
+
issues: readonly CommentableReviewIssue[],
|
|
117
108
|
signal?: AbortSignal,
|
|
118
109
|
): Promise<InlineCommentStatus[]> {
|
|
119
110
|
throwIfAborted(signal);
|
|
@@ -125,14 +116,14 @@ export async function postInlineComments(
|
|
|
125
116
|
const statuses: InlineCommentStatus[] = [];
|
|
126
117
|
for (const issue of issues) {
|
|
127
118
|
throwIfAborted(signal);
|
|
128
|
-
const key =
|
|
129
|
-
if (
|
|
119
|
+
const key = commentKey(buildInlineCommentBody(issue), issue.file, issue.line, prContext.headSha);
|
|
120
|
+
if (existing.keys.has(key)) {
|
|
130
121
|
statuses.push({ issueId: issue.id, status: "skipped", reason: "An identical inline comment already exists on this PR head." });
|
|
131
122
|
continue;
|
|
132
123
|
}
|
|
133
124
|
const status = await postInlineComment(exec, cwd, prContext, issue, signal);
|
|
134
125
|
statuses.push(status);
|
|
135
|
-
if (
|
|
126
|
+
if (status.status === "posted") existing.keys.add(key);
|
|
136
127
|
}
|
|
137
128
|
return statuses;
|
|
138
129
|
}
|
|
@@ -161,7 +152,7 @@ async function runJson(
|
|
|
161
152
|
try {
|
|
162
153
|
return { ok: true, value: JSON.parse(result.stdout) };
|
|
163
154
|
} catch (error) {
|
|
164
|
-
return { ok: false, reason:
|
|
155
|
+
return { ok: false, reason: `Invalid gh JSON: ${unknownErrorMessage(error)}` };
|
|
165
156
|
}
|
|
166
157
|
}
|
|
167
158
|
|
|
@@ -179,7 +170,7 @@ async function runCommand(
|
|
|
179
170
|
return { ok: true, stdout: result.stdout };
|
|
180
171
|
} catch (error) {
|
|
181
172
|
throwIfAborted(signal);
|
|
182
|
-
return { ok: false, reason: error
|
|
173
|
+
return { ok: false, reason: unknownErrorMessage(error) };
|
|
183
174
|
}
|
|
184
175
|
}
|
|
185
176
|
|
|
@@ -197,10 +188,6 @@ function parseExistingInlineCommentKeys(value: unknown): Set<string> | undefined
|
|
|
197
188
|
return keys;
|
|
198
189
|
}
|
|
199
190
|
|
|
200
|
-
function inlineCommentKey(issue: ReviewIssue, headSha: string): string | undefined {
|
|
201
|
-
return isCommentableIssue(issue) ? commentKey(buildInlineCommentBody(issue), issue.file, issue.line, headSha) : undefined;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
191
|
function commentKey(body: string, path: string, line: number, headSha: string): string {
|
|
205
192
|
return JSON.stringify([body, path, line, headSha]);
|
|
206
193
|
}
|
|
@@ -208,7 +195,7 @@ function commentKey(body: string, path: string, line: number, headSha: string):
|
|
|
208
195
|
function numberField(value: unknown, key: string): number | undefined {
|
|
209
196
|
if (!isRecord(value)) return undefined;
|
|
210
197
|
const field = value[key];
|
|
211
|
-
return
|
|
198
|
+
return isFiniteNumber(field) ? field : undefined;
|
|
212
199
|
}
|
|
213
200
|
|
|
214
201
|
function stringField(value: unknown, key: string): string | undefined {
|
|
@@ -243,7 +230,3 @@ function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
|
243
230
|
return undefined;
|
|
244
231
|
}
|
|
245
232
|
}
|
|
246
|
-
|
|
247
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
-
return typeof value === "object" && value !== null;
|
|
249
|
-
}
|
|
@@ -37,7 +37,7 @@ export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid:
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
interface PatchValidationOptions {
|
|
40
|
-
cwd: string; baseline: WorktreeBaseline;
|
|
40
|
+
cwd: string; baseline: WorktreeBaseline; baselineOid: string; patch: string;
|
|
41
41
|
evaluation: PatchEvaluation; signal?: AbortSignal;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -45,7 +45,6 @@ interface PatchValidationOptions {
|
|
|
45
45
|
export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
|
|
46
46
|
const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
|
|
47
47
|
validation.evaluation = options.evaluation;
|
|
48
|
-
if (validation.baselineFingerprint !== options.expectedFingerprint) return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
49
48
|
if (!options.patch.trim()) return validation;
|
|
50
49
|
const worktrees = new WorktreeRegistry(options.cwd);
|
|
51
50
|
let result = validation;
|
|
@@ -78,7 +77,7 @@ async function evaluateCandidateInWorktrees(
|
|
|
78
77
|
if ("error" in candidate) return { ...validation, reason: candidate.error };
|
|
79
78
|
if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
|
|
80
79
|
const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
|
|
81
|
-
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error
|
|
80
|
+
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error };
|
|
82
81
|
const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
|
|
83
82
|
const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
|
|
84
83
|
if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
|
|
@@ -94,7 +93,7 @@ async function evaluateCandidateInWorktrees(
|
|
|
94
93
|
const original = await worktrees.add(options.signal, options.baseline);
|
|
95
94
|
if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
|
|
96
95
|
const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
|
|
97
|
-
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error
|
|
96
|
+
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error}`); continue; }
|
|
98
97
|
const baselineResult = await runCheck(check, original.path, options.signal);
|
|
99
98
|
validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
|
|
100
99
|
throwIfAborted(options.signal);
|
|
@@ -104,7 +104,7 @@ async function handleCommentAction(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const exec = toExecLike(pi);
|
|
107
|
-
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context, ctx.signal);
|
|
107
|
+
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context.diffTarget.number, ctx.signal);
|
|
108
108
|
if (!resolved.ok) {
|
|
109
109
|
await queueCommentFallback(pi, ctx, commentable, context, baseline.ref, resolved.reason);
|
|
110
110
|
return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { repoPathCandidates } from "../advisory-evidence.ts";
|
|
2
|
+
import type { AdvisoryLocation } from "../advisory-schema.ts";
|
|
3
|
+
|
|
4
|
+
const C_QUOTE_ESCAPES: Readonly<Record<string, number>> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
|
|
5
|
+
|
|
6
|
+
/** Undo git's C-style quoting of unusual path names, e.g. `"b/caf\303\251.txt"`. */
|
|
7
|
+
function unquoteGitPath(path: string): string {
|
|
8
|
+
if (path.length < 2 || !path.startsWith('"') || !path.endsWith('"')) return path;
|
|
9
|
+
const bytes: number[] = [];
|
|
10
|
+
for (const [, escape, literal] of path.slice(1, -1).matchAll(/\\([0-7]{3}|.)|([^\\]+)/g)) {
|
|
11
|
+
if (literal !== undefined) bytes.push(...Buffer.from(literal));
|
|
12
|
+
else if (escape !== undefined) bytes.push(escape.length === 3 ? parseInt(escape, 8) : (C_QUOTE_ESCAPES[escape] ?? escape.charCodeAt(0)));
|
|
13
|
+
}
|
|
14
|
+
return Buffer.from(bytes).toString("utf8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
18
|
+
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
19
|
+
const byFile = new Map<string, Set<number>>();
|
|
20
|
+
let file: string | null = null;
|
|
21
|
+
let newLine = 0;
|
|
22
|
+
for (const raw of diff.split("\n")) {
|
|
23
|
+
if (raw.startsWith("+++ ")) {
|
|
24
|
+
const path = unquoteGitPath(raw.slice(4).trim());
|
|
25
|
+
file = path === "/dev/null" ? null : path.replace(/^b\//, "");
|
|
26
|
+
if (file && !byFile.has(file)) byFile.set(file, new Set());
|
|
27
|
+
} else if (raw.startsWith("@@")) {
|
|
28
|
+
const match = /\+(\d+)/.exec(raw);
|
|
29
|
+
newLine = match ? Number(match[1]) : 0;
|
|
30
|
+
} else if (file === null || raw.startsWith("---") || raw.startsWith("\\")) {
|
|
31
|
+
// file header, deletion, or "No newline" marker — record nothing
|
|
32
|
+
} else if (raw.startsWith("+")) {
|
|
33
|
+
byFile.get(file)!.add(newLine++);
|
|
34
|
+
} else if (!raw.startsWith("-")) {
|
|
35
|
+
newLine++; // context line advances the new-file counter; deletions do not
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return byFile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The location re-keyed to its repo-relative diff path when it is inside the diff, else undefined.
|
|
43
|
+
* The exact path wins; a stray `./`, `a/` or `b/` prefix is tolerated. File-level locations count
|
|
44
|
+
* if the file changed; lines get ±1 of fuzz.
|
|
45
|
+
*/
|
|
46
|
+
export function diffAnchor(changed: ReadonlyMap<string, ReadonlySet<number>>, location: AdvisoryLocation): AdvisoryLocation | undefined {
|
|
47
|
+
for (const file of repoPathCandidates(location.file)) {
|
|
48
|
+
const lines = changed.get(file);
|
|
49
|
+
if (!lines) continue;
|
|
50
|
+
const { line } = location;
|
|
51
|
+
return line == null || lines.has(line) || lines.has(line - 1) || lines.has(line + 1) ? { ...location, file } : undefined;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
@@ -94,7 +94,7 @@ export async function runReviewFixWorkflow(
|
|
|
94
94
|
tools: [...REVIEW_FIX_TOOLS],
|
|
95
95
|
toolHints: ["search"],
|
|
96
96
|
});
|
|
97
|
-
const validation = await evaluateReviewFix(api, { issue,
|
|
97
|
+
const validation = await evaluateReviewFix(api, { issue, baseline, isolated });
|
|
98
98
|
return {
|
|
99
99
|
findingId: issue.id,
|
|
100
100
|
result: isolated.result,
|
|
@@ -123,17 +123,14 @@ function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPrev
|
|
|
123
123
|
return "patch" in outcome;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** `baseline` was already revalidated against the reviewed snapshot identity by resolveReviewWorktreeBaseline. */
|
|
126
127
|
async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
|
|
127
|
-
issue: ReviewIssue;
|
|
128
|
+
issue: ReviewIssue; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
|
|
128
129
|
}): Promise<PatchValidation> {
|
|
129
|
-
const { issue,
|
|
130
|
+
const { issue, baseline, isolated } = input;
|
|
130
131
|
const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
|
|
131
132
|
if (!isolated.patch.trim()) return validation;
|
|
132
133
|
if (!isolated.baselineOid) return { ...validation, reason: "Candidate has no recorded baseline identity." };
|
|
133
|
-
if (!context?.snapshot) return { ...validation, reason: "Reviewed snapshot identity is unavailable." };
|
|
134
|
-
if (validation.baselineFingerprint !== context.snapshot.baselineFingerprint) {
|
|
135
|
-
return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
136
|
-
}
|
|
137
134
|
try {
|
|
138
135
|
const evaluated = await api.agent(
|
|
139
136
|
`Independently evaluate a candidate repair. Your fresh worktree contains the exact reviewed baseline plus the captured patch. The implementer's report is not validation evidence. Inspect the finding, callers and tests. Reject incorrect repairs; return blocked if required validation is unavailable. Select at most six focused deterministic checks with executable and argument arrays. Require at least one meaningful behavior check. If a regression test is applicable, supply a test-only baselinePatch and specific expectedFailure so the engine can prove it fails before the repair and passes after. Do not edit, install dependencies, commit or change branches. The engine will execute checks independently.\nFinding: ${JSON.stringify(serializeReviewIssue(issue))}\nBaseline: ${isolated.baselineOid}\nPatch SHA-256: ${validation.patchHash}\nPatch:\n${isolated.patch}`,
|
|
@@ -141,10 +138,11 @@ async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
|
|
|
141
138
|
label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
|
|
142
139
|
tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
|
|
143
140
|
);
|
|
141
|
+
// Drift here is usually a test byproduct in the evaluator's worktree, not evidence against the repair.
|
|
144
142
|
if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
|
|
145
|
-
return { ...validation, status: "
|
|
143
|
+
return { ...validation, status: "blocked", reason: "Evaluator session changed its workspace (candidate patch or baseline drifted); independent checks were not run.", evaluation: evaluated.result };
|
|
146
144
|
}
|
|
147
|
-
return await validateCandidatePatch({ cwd: api.cwd, baseline,
|
|
145
|
+
return await validateCandidatePatch({ cwd: api.cwd, baseline,
|
|
148
146
|
baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
|
|
149
147
|
} catch (error) {
|
|
150
148
|
if (isFatalWorkflowError(error, api.signal)) throw error;
|
|
@@ -1,133 +1,67 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { visibleWidth
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { dot, fit, GLYPH, hangingWrap, severityBadge, severityRank, truncatePath } from "../ui/kit.ts";
|
|
4
|
+
import type { ReviewIssue } from "./review-issues.ts";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/** Most severe first; report order (and so the stable R-number) breaks ties. */
|
|
7
|
+
export function sortIssuesForDisplay(issues: readonly ReviewIssue[]): ReviewIssue[] {
|
|
8
|
+
return issues
|
|
9
|
+
.map((issue, index) => ({ issue, index }))
|
|
10
|
+
.sort((a, b) => severityRank(a.issue.finding.severity) - severityRank(b.issue.finding.severity) || a.index - b.index)
|
|
11
|
+
.map(({ issue }) => issue);
|
|
8
12
|
}
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const CATEGORY_WIDTH = 8;
|
|
15
|
-
const LOCATION_WIDTH = 30;
|
|
16
|
-
const SUMMARY_WIDTH = 58;
|
|
17
|
-
|
|
18
|
-
export function renderIssuesTable(issues: readonly ReviewIssue[], theme: Theme, options: RenderIssuesTableOptions = {}): string {
|
|
19
|
-
const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
|
|
20
|
-
const visible = maxRows >= 0 ? issues.slice(0, maxRows) : issues;
|
|
21
|
-
const lines = [
|
|
22
|
-
renderRow(["ID", "Sev", "Conf", "Cat", "Location", "Summary"], theme.fg("dim", "│"), theme, true),
|
|
23
|
-
theme.fg("dim", renderSeparator()),
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
for (const issue of visible) {
|
|
27
|
-
lines.push(
|
|
28
|
-
renderRow(
|
|
29
|
-
[
|
|
30
|
-
issue.id,
|
|
31
|
-
issue.finding.severity,
|
|
32
|
-
issue.finding.confidence,
|
|
33
|
-
issue.finding.category,
|
|
34
|
-
formatIssueLocation(issue),
|
|
35
|
-
issue.finding.summary,
|
|
36
|
-
],
|
|
37
|
-
theme.fg("dim", "│"),
|
|
38
|
-
theme,
|
|
39
|
-
false,
|
|
40
|
-
issue,
|
|
41
|
-
),
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (issues.length > visible.length) {
|
|
46
|
-
lines.push(theme.fg("dim", `… ${issues.length - visible.length} more finding(s)`));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return lines.join("\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
|
|
53
|
-
return renderIssueDetailLines(issue, theme, 120).join("\n");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
|
|
57
|
-
const finding = issue.finding;
|
|
58
|
-
const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}${finding.verdict ? ` · ${finding.verdict}` : ""}`;
|
|
59
|
-
const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
|
|
60
|
-
lines.push(...fieldLines("Metadata", metadata, width, theme));
|
|
61
|
-
if (finding.sourceCandidateIds?.length) lines.push(...fieldLines("Sources", finding.sourceCandidateIds.join(", "), width, theme));
|
|
62
|
-
lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
|
|
63
|
-
lines.push(...fieldLines("Impact", finding.impact, width, theme));
|
|
64
|
-
lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
|
|
65
|
-
lines.push(...fieldLines("Recommendation", finding.recommendation, width, theme));
|
|
66
|
-
return lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(10, width)));
|
|
14
|
+
/** `src/cache.ts:13`, without the symbol; the detail view shows the symbol on its own. */
|
|
15
|
+
export function issuePath(issue: ReviewIssue): string {
|
|
16
|
+
if (!issue.file) return "no location";
|
|
17
|
+
return issue.line === undefined ? issue.file : `${issue.file}:${issue.line}`;
|
|
67
18
|
}
|
|
68
19
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
20
|
+
/**
|
|
21
|
+
* One aligned row: `HIGH R003 src/cache.ts:13 The parser accepts an empty value…`.
|
|
22
|
+
* `pathColumn` (from `findingPathColumn`) narrows the location column to the paths actually listed.
|
|
23
|
+
*/
|
|
24
|
+
export function renderFindingRow(issue: ReviewIssue, width: number, theme: Theme, pathColumn?: number): string {
|
|
25
|
+
const head = `${severityBadge(issue.finding.severity, theme)} ${theme.fg("dim", issue.id)}`;
|
|
26
|
+
const room = Math.max(0, width - visibleWidth(head) - 2);
|
|
27
|
+
const pathWidth = Math.min(pathColumn ?? Number.POSITIVE_INFINITY, maxPathWidth(room));
|
|
28
|
+
if (room < pathWidth + 10) return fit(`${head} ${theme.fg("text", issue.finding.summary)}`, width);
|
|
29
|
+
const path = theme.fg("accent", fit(truncatePath(issuePath(issue), pathWidth), pathWidth));
|
|
30
|
+
return fit(`${head} ${path} ${theme.fg("text", issue.finding.summary)}`, width);
|
|
78
31
|
}
|
|
79
32
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
separator: string,
|
|
83
|
-
theme: Theme,
|
|
84
|
-
header: boolean,
|
|
85
|
-
issue?: ReviewIssue,
|
|
86
|
-
): string {
|
|
87
|
-
const rendered = [
|
|
88
|
-
cell(cells[0], ID_WIDTH),
|
|
89
|
-
colorCell(cell(cells[1], SEVERITY_WIDTH), header ? "dim" : severityColor(issue?.finding.severity ?? "low"), theme),
|
|
90
|
-
colorCell(cell(cells[2], CONFIDENCE_WIDTH), header ? "dim" : confidenceColor(issue?.finding.confidence ?? "low"), theme),
|
|
91
|
-
cell(cells[3], CATEGORY_WIDTH),
|
|
92
|
-
cell(cells[4], LOCATION_WIDTH),
|
|
93
|
-
cell(cells[5], SUMMARY_WIDTH),
|
|
94
|
-
];
|
|
95
|
-
if (header) {
|
|
96
|
-
return rendered.map((entry) => theme.fg("dim", entry)).join(` ${separator} `);
|
|
97
|
-
}
|
|
98
|
-
return rendered.join(` ${separator} `);
|
|
33
|
+
function maxPathWidth(room: number): number {
|
|
34
|
+
return Math.min(Math.max(16, Math.floor(room * 0.38)), 34);
|
|
99
35
|
}
|
|
100
36
|
|
|
101
|
-
|
|
102
|
-
|
|
37
|
+
/** A location column as wide as the longest listed path, never below 12 columns. */
|
|
38
|
+
export function findingPathColumn(issues: readonly ReviewIssue[]): number {
|
|
39
|
+
return Math.max(12, ...issues.map((issue) => visibleWidth(issuePath(issue))));
|
|
103
40
|
}
|
|
104
41
|
|
|
105
|
-
|
|
106
|
-
return theme.fg(color, value);
|
|
107
|
-
}
|
|
42
|
+
const LABEL_WIDTH = 10;
|
|
108
43
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
44
|
+
/** The full finding: headline, wrapped summary, then aligned Location / Impact / Evidence / Fix sections. */
|
|
45
|
+
export function renderFindingDetail(issue: ReviewIssue, width: number, theme: Theme): string[] {
|
|
46
|
+
const finding = issue.finding;
|
|
47
|
+
const meta = [finding.category, `confidence ${finding.confidence}`, finding.verdict?.toLowerCase()].filter(Boolean).join(dot(theme));
|
|
48
|
+
const lines = [
|
|
49
|
+
`${severityBadge(finding.severity, theme)} ${theme.fg("accent", theme.bold(issue.id))}${dot(theme)}${theme.fg("muted", meta)}`,
|
|
50
|
+
...hangingWrap(theme.bold(finding.summary), width),
|
|
51
|
+
"",
|
|
52
|
+
];
|
|
53
|
+
const location = [theme.fg("accent", issuePath(issue)), issue.symbol ? theme.fg("muted", issue.symbol) : undefined].filter(Boolean).join(dot(theme));
|
|
54
|
+
lines.push(...labelledLines("Location", [location], width, theme));
|
|
55
|
+
lines.push(...labelledLines("Impact", [finding.impact], width, theme));
|
|
56
|
+
const evidence = finding.evidence.length > 0 ? finding.evidence.map((item) => `${GLYPH.bullet} ${item}`) : [theme.fg("dim", "none cited")];
|
|
57
|
+
lines.push(...labelledLines("Evidence", evidence, width, theme, " "));
|
|
58
|
+
lines.push(...labelledLines("Fix", [finding.recommendation], width, theme));
|
|
59
|
+
return lines;
|
|
122
60
|
}
|
|
123
61
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return "warning";
|
|
130
|
-
case "low":
|
|
131
|
-
return "muted";
|
|
132
|
-
}
|
|
62
|
+
/** A dim label column (10 wide) with each item wrapped beside it, so result sections align with finding details. */
|
|
63
|
+
export function labelledLines(label: string, items: readonly string[], width: number, theme: Theme, itemIndent = ""): string[] {
|
|
64
|
+
const first = theme.fg("dim", label.padEnd(LABEL_WIDTH, " "));
|
|
65
|
+
const rest = " ".repeat(LABEL_WIDTH);
|
|
66
|
+
return items.flatMap((item, index) => hangingWrap(item, width, index === 0 ? first : rest, rest + itemIndent));
|
|
133
67
|
}
|
|
@@ -23,7 +23,7 @@ Instructions:
|
|
|
23
23
|
- Do not edit files or make code changes.
|
|
24
24
|
- Prefer installed GitHub MCP/tools if present; otherwise use the GitHub CLI (gh).
|
|
25
25
|
- Before posting, resolve the current PR head and require it to equal the verified reviewed head \`${verifiedHead}\`; stop if it differs.
|
|
26
|
-
- With gh, resolve the PR using \`gh pr view\` and
|
|
26
|
+
- With gh, resolve the PR using \`gh pr view <context.diffTarget.number> --json headRefOid,url\` and take owner/repo from the PR \`url\` (the base repository, so fork PRs are commented upstream), then call \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` with \`commit_id\`, \`path\`, \`line\`, and \`side=RIGHT\`.
|
|
27
27
|
- Do not post duplicate comments.
|
|
28
28
|
- Do not post line-less findings as inline comments.
|
|
29
29
|
- Ask the user if the upstream PR cannot be identified.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { formatAdvisoryLocation, type AdvisoryFinding, type AdvisoryReport } from "../advisory-schema.ts";
|
|
2
|
+
import { isFiniteNumber } from "../guards.ts";
|
|
2
3
|
|
|
3
4
|
export type ReviewIssueAction = "fix" | "comment" | "close";
|
|
4
5
|
|
|
@@ -9,8 +10,6 @@ export interface ReviewIssueSelection {
|
|
|
9
10
|
|
|
10
11
|
export interface ReviewIssue {
|
|
11
12
|
readonly id: string;
|
|
12
|
-
readonly index: number;
|
|
13
|
-
readonly workflowName: string;
|
|
14
13
|
readonly file?: string;
|
|
15
14
|
readonly line?: number;
|
|
16
15
|
readonly symbol?: string;
|
|
@@ -35,13 +34,11 @@ export interface SerializedReviewIssue {
|
|
|
35
34
|
readonly recommendation: string;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
export function toReviewIssues(
|
|
37
|
+
export function toReviewIssues(report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
|
|
39
38
|
return report.findings.map((finding, index) => {
|
|
40
39
|
const location = finding.reviewAnchor ?? finding.locations[0];
|
|
41
40
|
return {
|
|
42
41
|
id: formatIssueId(index),
|
|
43
|
-
index,
|
|
44
|
-
workflowName: name,
|
|
45
42
|
file: location?.file,
|
|
46
43
|
line: location?.line,
|
|
47
44
|
symbol: location?.symbol,
|
|
@@ -52,9 +49,7 @@ export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findi
|
|
|
52
49
|
|
|
53
50
|
export function formatIssueLocation(issue: ReviewIssue): string {
|
|
54
51
|
if (!issue.file) return "(no location)";
|
|
55
|
-
|
|
56
|
-
const symbol = issue.symbol ? ` (${issue.symbol})` : "";
|
|
57
|
-
return `${issue.file}${line}${symbol}`;
|
|
52
|
+
return formatAdvisoryLocation({ file: issue.file, line: issue.line, symbol: issue.symbol });
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue {
|
|
@@ -76,10 +71,10 @@ export function serializeReviewIssue(issue: ReviewIssue): SerializedReviewIssue
|
|
|
76
71
|
};
|
|
77
72
|
}
|
|
78
73
|
|
|
79
|
-
export
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return typeof issue.file === "string" && issue.file.trim().length > 0 &&
|
|
74
|
+
export type CommentableReviewIssue = ReviewIssue & { readonly file: string; readonly line: number };
|
|
75
|
+
|
|
76
|
+
export function isCommentableIssue(issue: ReviewIssue): issue is CommentableReviewIssue {
|
|
77
|
+
return typeof issue.file === "string" && issue.file.trim().length > 0 && isFiniteNumber(issue.line);
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
function formatIssueId(index: number): string {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type, type Static } from "typebox";
|
|
2
2
|
import { Value } from "typebox/value";
|
|
3
3
|
import { AdvisoryReportWithStatsSchema } from "../advisory-schema.ts";
|
|
4
|
-
import { formatReviewDiffTarget,
|
|
4
|
+
import { formatReviewDiffTarget, isAllowedReviewDiffTarget, ReviewDiffTargetSchema } from "../review-diff-target.ts";
|
|
5
5
|
|
|
6
6
|
/** Atomic identity of the exact diff and post-change snapshot that were reviewed. */
|
|
7
7
|
export const ReviewSnapshotIdentitySchema = Type.Object({
|
|
@@ -27,13 +27,9 @@ export type ReviewSnapshotIdentity = Static<typeof ReviewSnapshotIdentitySchema>
|
|
|
27
27
|
export type ReviewContext = Static<typeof ReviewContextSchema>;
|
|
28
28
|
export type ReviewReport = Static<typeof ReviewReportSchema>;
|
|
29
29
|
|
|
30
|
+
/** The schema check covers the nested context; the diff target also has to round-trip through the allowlist parser. */
|
|
30
31
|
export function isReviewReport(value: unknown): value is ReviewReport {
|
|
31
|
-
|
|
32
|
-
return value.reviewContext === undefined || isReviewContext(value.reviewContext);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function isReviewContext(value: unknown): value is ReviewContext {
|
|
36
|
-
return Value.Check(ReviewContextSchema, value) && isReviewDiffTarget(value.diffTarget);
|
|
32
|
+
return Value.Check(ReviewReportSchema, value) && (value.reviewContext === undefined || isAllowedReviewDiffTarget(value.reviewContext.diffTarget));
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
/** Prompt-facing context retains the canonical display command without persisting duplicate identity. */
|