pi-workflow-engine 0.11.0 → 0.13.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 +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +270 -17
- package/package.json +1 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { initialPatchValidation, PatchEvaluationSchema, validateCandidatePatch, type PatchValidation } from "./patch-validation.ts";
|
|
2
|
+
import { isFatalWorkflowError } from "../cancellation.ts";
|
|
3
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
1
4
|
import type { ParallelSettledError } from "../concurrency.ts";
|
|
2
|
-
import type { LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
5
|
+
import type { IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowModule } from "../types.ts";
|
|
3
6
|
import type { WorktreeBaseline } from "../worktree.ts";
|
|
4
7
|
import { loadWorkflow } from "../workflow-module.ts";
|
|
5
8
|
import { serializeReviewIssue, type ReviewIssue } from "./review-issues.ts";
|
|
@@ -14,6 +17,7 @@ export interface ReviewFixPreview {
|
|
|
14
17
|
readonly result: string;
|
|
15
18
|
readonly patch: string;
|
|
16
19
|
readonly changed: boolean;
|
|
20
|
+
readonly validation: PatchValidation;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ReviewFixFailure {
|
|
@@ -28,7 +32,7 @@ export interface ReviewFixWorkflowResult {
|
|
|
28
32
|
readonly fixes: readonly ReviewFixOutcome[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase">;
|
|
35
|
+
export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase" | "cwd" | "signal">;
|
|
32
36
|
|
|
33
37
|
/** Build an ephemeral workflow that generates one isolated patch preview per finding. */
|
|
34
38
|
export function createReviewFixWorkflow(
|
|
@@ -40,9 +44,9 @@ export function createReviewFixWorkflow(
|
|
|
40
44
|
meta: {
|
|
41
45
|
name: "code-review-fix-previews",
|
|
42
46
|
description: "Generate isolated patch previews for selected code-review findings.",
|
|
43
|
-
phases: [{ title: REVIEW_FIX_PHASE }],
|
|
47
|
+
phases: [{ title: REVIEW_FIX_PHASE }, { title: "Validate patch previews" }],
|
|
44
48
|
},
|
|
45
|
-
default: (api) => runReviewFixWorkflow(api, issues, context),
|
|
49
|
+
default: (api) => runReviewFixWorkflow(api, issues, context, baseline),
|
|
46
50
|
};
|
|
47
51
|
return loadWorkflow(
|
|
48
52
|
module,
|
|
@@ -76,6 +80,7 @@ export async function runReviewFixWorkflow(
|
|
|
76
80
|
api: ReviewFixWorkflowApi,
|
|
77
81
|
issues: readonly ReviewIssue[],
|
|
78
82
|
context: ReviewContext | undefined,
|
|
83
|
+
baseline: WorktreeBaseline,
|
|
79
84
|
): Promise<ReviewFixWorkflowResult> {
|
|
80
85
|
api.phase(REVIEW_FIX_PHASE);
|
|
81
86
|
const settled = await api.parallel(
|
|
@@ -84,16 +89,18 @@ export async function runReviewFixWorkflow(
|
|
|
84
89
|
isolation: "worktree",
|
|
85
90
|
label: `fix:${issue.id}`,
|
|
86
91
|
phase: REVIEW_FIX_PHASE,
|
|
87
|
-
|
|
92
|
+
profile: "medium",
|
|
88
93
|
cacheKey: `review-fix:${issue.id}`,
|
|
89
94
|
tools: [...REVIEW_FIX_TOOLS],
|
|
90
95
|
toolHints: ["search"],
|
|
91
96
|
});
|
|
97
|
+
const validation = await evaluateReviewFix(api, { issue, context, baseline, isolated });
|
|
92
98
|
return {
|
|
93
99
|
findingId: issue.id,
|
|
94
100
|
result: isolated.result,
|
|
95
101
|
patch: isolated.patch,
|
|
96
102
|
changed: isolated.changed,
|
|
103
|
+
validation,
|
|
97
104
|
};
|
|
98
105
|
}),
|
|
99
106
|
{ settled: true },
|
|
@@ -103,11 +110,11 @@ export async function runReviewFixWorkflow(
|
|
|
103
110
|
entry.ok ? entry.value : { findingId: issues[index]!.id, error: entry.error },
|
|
104
111
|
);
|
|
105
112
|
const successful = fixes.filter(isReviewFixPreview);
|
|
106
|
-
const
|
|
113
|
+
const count = (status: PatchValidation["status"]) => successful.filter((fix) => fix.validation.status === status).length;
|
|
107
114
|
const failed = fixes.length - successful.length;
|
|
108
115
|
|
|
109
116
|
return {
|
|
110
|
-
summary:
|
|
117
|
+
summary: `${count("verified")} verified candidate(s); ${count("rejected")} rejected; ${count("blocked")} blocked; ${count("no-patch")} no-patch; ${failed} attempt(s) failed.`,
|
|
111
118
|
fixes,
|
|
112
119
|
};
|
|
113
120
|
}
|
|
@@ -115,3 +122,32 @@ export async function runReviewFixWorkflow(
|
|
|
115
122
|
function isReviewFixPreview(outcome: ReviewFixOutcome): outcome is ReviewFixPreview {
|
|
116
123
|
return "patch" in outcome;
|
|
117
124
|
}
|
|
125
|
+
|
|
126
|
+
async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
|
|
127
|
+
issue: ReviewIssue; context: ReviewContext | undefined; baseline: WorktreeBaseline; isolated: IsolatedAgentResult<string>;
|
|
128
|
+
}): Promise<PatchValidation> {
|
|
129
|
+
const { issue, context, baseline, isolated } = input;
|
|
130
|
+
const validation = initialPatchValidation(baseline, isolated.baselineOid, isolated.patch);
|
|
131
|
+
if (!isolated.patch.trim()) return validation;
|
|
132
|
+
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
|
+
try {
|
|
138
|
+
const evaluated = await api.agent(
|
|
139
|
+
`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}`,
|
|
140
|
+
{ isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
|
|
141
|
+
label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
|
|
142
|
+
tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
|
|
143
|
+
);
|
|
144
|
+
if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
|
|
145
|
+
return { ...validation, status: "rejected", reason: "Evaluator changed the candidate or used a different baseline.", evaluation: evaluated.result };
|
|
146
|
+
}
|
|
147
|
+
return await validateCandidatePatch({ cwd: api.cwd, baseline, expectedFingerprint: context.snapshot.baselineFingerprint,
|
|
148
|
+
baselineOid: isolated.baselineOid, patch: isolated.patch, evaluation: evaluated.result, signal: api.signal });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (isFatalWorkflowError(error, api.signal)) throw error;
|
|
151
|
+
return { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -55,9 +55,10 @@ export function renderIssueDetails(issue: ReviewIssue, theme: Theme): string {
|
|
|
55
55
|
|
|
56
56
|
export function renderIssueDetailLines(issue: ReviewIssue, theme: Theme, width: number): string[] {
|
|
57
57
|
const finding = issue.finding;
|
|
58
|
-
const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}`;
|
|
58
|
+
const metadata = `${finding.category} · severity ${finding.severity} · confidence ${finding.confidence}${finding.verdict ? ` · ${finding.verdict}` : ""}`;
|
|
59
59
|
const lines = [`${theme.fg("accent", issue.id)} ${theme.fg("text", finding.summary)}`];
|
|
60
60
|
lines.push(...fieldLines("Metadata", metadata, width, theme));
|
|
61
|
+
if (finding.sourceCandidateIds?.length) lines.push(...fieldLines("Sources", finding.sourceCandidateIds.join(", "), width, theme));
|
|
61
62
|
lines.push(...fieldLines("Location", formatIssueLocation(issue), width, theme, "accent"));
|
|
62
63
|
lines.push(...fieldLines("Impact", finding.impact, width, theme));
|
|
63
64
|
lines.push(...fieldLines("Evidence", finding.evidence.join("; ") || "(none cited)", width, theme));
|
|
@@ -37,7 +37,7 @@ export interface SerializedReviewIssue {
|
|
|
37
37
|
|
|
38
38
|
export function toReviewIssues(name: string, report: Pick<AdvisoryReport, "findings">): ReviewIssue[] {
|
|
39
39
|
return report.findings.map((finding, index) => {
|
|
40
|
-
const location = finding.locations[0];
|
|
40
|
+
const location = finding.reviewAnchor ?? finding.locations[0];
|
|
41
41
|
return {
|
|
42
42
|
id: formatIssueId(index),
|
|
43
43
|
index,
|
|
@@ -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, isReviewDiffTarget, ReviewDiffTargetSchema } from "../diff-
|
|
4
|
+
import { formatReviewDiffTarget, isReviewDiffTarget, 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({
|
|
@@ -3,6 +3,7 @@ import type { WorkflowRunOptions } from "../types.ts";
|
|
|
3
3
|
import { toReviewIssues, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
4
4
|
import { isReviewReport, type ReviewReport } from "./review-report.ts";
|
|
5
5
|
import { ReviewResultsViewer } from "./review-results-viewer.ts";
|
|
6
|
+
import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "../ui/workflow-viewer-layout.ts";
|
|
6
7
|
|
|
7
8
|
export type ReviewResultsPresentationDecision =
|
|
8
9
|
| {
|
|
@@ -46,6 +47,6 @@ export async function showReviewResultsViewer(
|
|
|
46
47
|
): Promise<ReviewIssueSelection> {
|
|
47
48
|
return await ctx.ui.custom<ReviewIssueSelection>(
|
|
48
49
|
(tui, theme, _keybindings, done) => new ReviewResultsViewer(issues, "code-review", tui, theme, done),
|
|
49
|
-
|
|
50
|
+
WORKFLOW_VIEWER_OVERLAY_OPTIONS,
|
|
50
51
|
);
|
|
51
52
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { matchesKey,
|
|
2
|
+
import { matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import { renderIssueDetailLines } from "./review-format.ts";
|
|
4
4
|
import { formatIssueLocation, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
5
5
|
import { truncateDisplay } from "../ui/workflow-format.ts";
|
|
6
|
+
import { fitWorkflowViewerRow, fitWorkflowViewerRows, workflowViewerHeight } from "../ui/workflow-viewer-layout.ts";
|
|
6
7
|
|
|
7
|
-
const VIEWPORT_HEIGHT_RATIO = 0.8;
|
|
8
|
-
const VIEWPORT_MARGIN_ROWS = 2;
|
|
9
8
|
const SPLIT_WIDTH = 96;
|
|
10
9
|
const LIST_MIN_WIDTH = 34;
|
|
11
10
|
const LIST_MAX_WIDTH = 48;
|
|
@@ -34,8 +33,8 @@ export class ReviewResultsViewer implements Component {
|
|
|
34
33
|
render(width: number): string[] {
|
|
35
34
|
const outerWidth = Math.max(4, width);
|
|
36
35
|
const contentWidth = Math.max(1, outerWidth - 4);
|
|
37
|
-
const innerHeight = Math.max(1, this.
|
|
38
|
-
const body =
|
|
36
|
+
const innerHeight = Math.max(1, workflowViewerHeight(this.tui.terminal.rows) - 2);
|
|
37
|
+
const body = fitWorkflowViewerRows(this.renderBody(contentWidth, innerHeight), innerHeight);
|
|
39
38
|
return [
|
|
40
39
|
this.theme.fg("borderAccent", `╭${"─".repeat(Math.max(0, outerWidth - 2))}╮`),
|
|
41
40
|
...body.map((line) => this.borderedRow(line, contentWidth)),
|
|
@@ -94,20 +93,13 @@ export class ReviewResultsViewer implements Component {
|
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
95
|
|
|
97
|
-
private modalHeight(): number {
|
|
98
|
-
const terminalRows = Math.max(1, this.tui.terminal.rows);
|
|
99
|
-
const availableRows = Math.max(1, terminalRows - VIEWPORT_MARGIN_ROWS);
|
|
100
|
-
const proportionalRows = Math.floor(terminalRows * VIEWPORT_HEIGHT_RATIO);
|
|
101
|
-
return Math.min(availableRows, Math.max(3, proportionalRows));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
96
|
private renderBody(width: number, height: number): string[] {
|
|
105
97
|
const warning = this.warning ? this.theme.fg("warning", this.warning) : "";
|
|
106
98
|
const chromeRows = 5;
|
|
107
99
|
const contentHeight = Math.max(1, height - chromeRows);
|
|
108
100
|
let content: string[];
|
|
109
101
|
if (this.issues.length === 0) {
|
|
110
|
-
content =
|
|
102
|
+
content = fitWorkflowViewerRows([this.theme.fg("success", "No findings.")], contentHeight);
|
|
111
103
|
} else if (width >= SPLIT_WIDTH) {
|
|
112
104
|
content = this.renderSplit(width, contentHeight);
|
|
113
105
|
} else {
|
|
@@ -118,7 +110,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
118
110
|
this.headerLine(width),
|
|
119
111
|
warning,
|
|
120
112
|
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
121
|
-
...
|
|
113
|
+
...fitWorkflowViewerRows(content, contentHeight),
|
|
122
114
|
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
123
115
|
this.helpLine(width),
|
|
124
116
|
];
|
|
@@ -131,7 +123,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
131
123
|
const details = this.renderDetailPane(detailWidth, height);
|
|
132
124
|
const divider = this.theme.fg("borderMuted", "│");
|
|
133
125
|
return Array.from({ length: height }, (_value, index) =>
|
|
134
|
-
`${
|
|
126
|
+
`${fitWorkflowViewerRow(list[index] ?? "", listWidth)} ${divider} ${fitWorkflowViewerRow(details[index] ?? "", detailWidth)}`,
|
|
135
127
|
);
|
|
136
128
|
}
|
|
137
129
|
|
|
@@ -171,7 +163,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
171
163
|
width,
|
|
172
164
|
);
|
|
173
165
|
const indicator = truncateDisplay(this.theme.fg("dim", `${range}${arrows ? ` ${arrows}` : ""}`), width);
|
|
174
|
-
return [heading, ...
|
|
166
|
+
return [heading, ...fitWorkflowViewerRows(visible, itemRows), indicator].slice(0, height);
|
|
175
167
|
}
|
|
176
168
|
|
|
177
169
|
private renderIssueRow(issue: ReviewIssue, index: number, width: number): string {
|
|
@@ -185,7 +177,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
185
177
|
private renderDetailPane(width: number, height: number): string[] {
|
|
186
178
|
if (height <= 0) return [];
|
|
187
179
|
const issue = this.issues[this.cursor];
|
|
188
|
-
if (!issue) return
|
|
180
|
+
if (!issue) return fitWorkflowViewerRows([this.theme.fg("dim", "No finding selected.")], height);
|
|
189
181
|
|
|
190
182
|
const bodyHeight = Math.max(0, height - 2);
|
|
191
183
|
const heading = truncateDisplay(
|
|
@@ -206,7 +198,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
206
198
|
this.theme.fg("dim", `${this.detailsExpanded ? "Lines" : "Collapsed"} ${range}${arrows ? ` ${arrows}` : ""}`),
|
|
207
199
|
width,
|
|
208
200
|
);
|
|
209
|
-
return [heading, ...
|
|
201
|
+
return [heading, ...fitWorkflowViewerRows(visible, bodyHeight), indicator].slice(0, height);
|
|
210
202
|
}
|
|
211
203
|
|
|
212
204
|
private headerLine(width: number): string {
|
|
@@ -229,7 +221,7 @@ export class ReviewResultsViewer implements Component {
|
|
|
229
221
|
}
|
|
230
222
|
|
|
231
223
|
private borderedRow(content: string, width: number): string {
|
|
232
|
-
return `${this.theme.fg("border", "│")} ${
|
|
224
|
+
return `${this.theme.fg("border", "│")} ${fitWorkflowViewerRow(content, width)} ${this.theme.fg("border", "│")}`;
|
|
233
225
|
}
|
|
234
226
|
|
|
235
227
|
private requestRender(): void {
|
|
@@ -300,17 +292,6 @@ export class ReviewResultsViewer implements Component {
|
|
|
300
292
|
}
|
|
301
293
|
}
|
|
302
294
|
|
|
303
|
-
function fitRows(lines: readonly string[], height: number): string[] {
|
|
304
|
-
const visible = lines.slice(0, Math.max(0, height));
|
|
305
|
-
return [...visible, ...Array.from({ length: Math.max(0, height - visible.length) }, () => "")];
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function padAnsi(text: string, width: number): string {
|
|
309
|
-
const truncated = truncateDisplay(text, width);
|
|
310
|
-
const padding = Math.max(0, width - visibleWidth(truncated));
|
|
311
|
-
return `${truncated}${" ".repeat(padding)}`;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
295
|
function scrollArrows(hasAbove: boolean, hasBelow: boolean): string {
|
|
315
296
|
return `${hasAbove ? "↑" : ""}${hasBelow ? "↓" : ""}`;
|
|
316
297
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { sessionKey } from "../session-identity.ts";
|
|
3
3
|
import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "../options.ts";
|
|
4
4
|
import type { LoadedWorkflow } from "../types.ts";
|
|
5
5
|
import type { WorkflowExecution } from "../workflow-execution.ts";
|
|
@@ -42,7 +42,7 @@ export class ReviewSessionCoordinator {
|
|
|
42
42
|
): void {
|
|
43
43
|
const name = execution.envelope.name;
|
|
44
44
|
if (name !== "code-review") return;
|
|
45
|
-
const key =
|
|
45
|
+
const key = sessionKey(ctx);
|
|
46
46
|
const report = codeReviewReport(name, execution.envelope.result);
|
|
47
47
|
if (!report) {
|
|
48
48
|
this.sessions.delete(key);
|
|
@@ -72,7 +72,7 @@ export class ReviewSessionCoordinator {
|
|
|
72
72
|
});
|
|
73
73
|
if (decision.kind !== "open") return;
|
|
74
74
|
|
|
75
|
-
const retained = this.sessions.get(
|
|
75
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
76
76
|
if (!retained) return;
|
|
77
77
|
await this.openAndHandle(ctx, retained, decision.issues);
|
|
78
78
|
}
|
|
@@ -82,7 +82,7 @@ export class ReviewSessionCoordinator {
|
|
|
82
82
|
ctx.ui.notify("Code-review results viewer requires the TUI", "warning");
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
const retained = this.sessions.get(
|
|
85
|
+
const retained = this.sessions.get(sessionKey(ctx));
|
|
86
86
|
if (!retained) {
|
|
87
87
|
ctx.ui.notify("No code-review result is available yet. Run /workflow code-review first.", "warning");
|
|
88
88
|
return;
|
|
@@ -96,7 +96,7 @@ export class ReviewSessionCoordinator {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
dispose(ctx: ExtensionContext): void {
|
|
99
|
-
this.sessions.delete(
|
|
99
|
+
this.sessions.delete(sessionKey(ctx));
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
private async openAndHandle(
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { throwIfAborted } from "../cancellation.ts";
|
|
3
|
+
import { captureDiffTarget, type DiffCaptureFailure } from "../diff-capture.ts";
|
|
3
4
|
import {
|
|
4
|
-
|
|
5
|
-
type DiffCaptureFailure,
|
|
5
|
+
reviewGitDiffBaseline,
|
|
6
6
|
type GitReviewDiffTarget,
|
|
7
7
|
type PullRequestReviewDiffTarget,
|
|
8
8
|
type ReviewDiffTarget,
|
|
9
|
-
|
|
10
|
-
} from "../diff-capture.ts";
|
|
9
|
+
} from "../review-diff-target.ts";
|
|
11
10
|
import { runBoundedProcess } from "../process-runner.ts";
|
|
12
11
|
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
13
12
|
import type { WorktreeBaseline } from "../worktree.ts";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
export type GitDiffBaselineTarget =
|
|
5
|
+
| { readonly kind: "working-tree" }
|
|
6
|
+
| { readonly kind: "index" }
|
|
7
|
+
| { readonly kind: "range"; readonly ref: string };
|
|
8
|
+
|
|
9
|
+
export const GitReviewDiffTargetSchema = Type.Object({
|
|
10
|
+
kind: Type.Literal("git"),
|
|
11
|
+
args: Type.Array(Type.String()),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const PullRequestReviewDiffTargetSchema = Type.Object({
|
|
15
|
+
kind: Type.Literal("pull-request"),
|
|
16
|
+
number: Type.Integer({ minimum: 1 }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
|
|
20
|
+
|
|
21
|
+
export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
|
|
22
|
+
export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
|
|
23
|
+
|
|
24
|
+
/** Serializable semantic identity of the diff being reviewed. */
|
|
25
|
+
export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
|
|
26
|
+
|
|
27
|
+
const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
|
|
28
|
+
const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
|
|
29
|
+
const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
|
|
30
|
+
|
|
31
|
+
export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
|
|
32
|
+
const tokens = command.trim().split(/\s+/).filter(Boolean);
|
|
33
|
+
if (tokens.length < 2) return { error: "empty or incomplete diff command" };
|
|
34
|
+
if (tokens[0] === "git" && tokens[1] === "diff") {
|
|
35
|
+
const parsed = parseGitDiff(tokens);
|
|
36
|
+
return "error" in parsed ? parsed : parsed.target;
|
|
37
|
+
}
|
|
38
|
+
if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
|
|
39
|
+
const number = Number(tokens[3]);
|
|
40
|
+
if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
|
|
41
|
+
const flags = tokens.slice(4);
|
|
42
|
+
if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
|
|
43
|
+
const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
|
|
44
|
+
if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
|
|
45
|
+
return { kind: "pull-request", number };
|
|
46
|
+
}
|
|
47
|
+
return { error: "diff command is not in the git/gh allowlist" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseGitDiff(
|
|
51
|
+
tokens: readonly string[],
|
|
52
|
+
): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
|
|
53
|
+
const args = tokens.slice(2);
|
|
54
|
+
const safeArgs = ["diff", "--no-ext-diff"];
|
|
55
|
+
const operands: string[] = [];
|
|
56
|
+
let staged = false;
|
|
57
|
+
let pathMode = false;
|
|
58
|
+
|
|
59
|
+
for (const token of args) {
|
|
60
|
+
if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
|
|
61
|
+
if (token === "--") {
|
|
62
|
+
pathMode = true;
|
|
63
|
+
safeArgs.push(token);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!pathMode && token.startsWith("-")) {
|
|
67
|
+
if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
|
|
68
|
+
staged ||= token === "--cached" || token === "--staged";
|
|
69
|
+
if (token !== "--no-ext-diff") safeArgs.push(token);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!pathMode) operands.push(token);
|
|
73
|
+
safeArgs.push(token);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const baseline = classifyGitDiffBaseline(staged, operands);
|
|
77
|
+
return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
|
|
81
|
+
if (operands.length > 1) {
|
|
82
|
+
return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
|
|
83
|
+
}
|
|
84
|
+
if (staged) return { kind: "index" };
|
|
85
|
+
|
|
86
|
+
const range = operands.find((operand) => operand.includes(".."));
|
|
87
|
+
if (range) {
|
|
88
|
+
const separator = range.includes("...") ? "..." : "..";
|
|
89
|
+
return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { kind: "working-tree" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isAllowedGitDiffFlag(token: string): boolean {
|
|
96
|
+
return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
|
|
100
|
+
return target.kind === "pull-request"
|
|
101
|
+
? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
|
|
102
|
+
: { file: "git", args: target.args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
|
|
106
|
+
export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
|
|
107
|
+
const command = reviewDiffCommand(target);
|
|
108
|
+
return [command.file, ...command.args].join(" ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
|
|
112
|
+
export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
|
|
113
|
+
const parsed = parseGitDiff(["git", ...target.args]);
|
|
114
|
+
if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
|
|
115
|
+
return parsed.baseline;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Validate persisted targets by round-tripping through the single command allowlist parser. */
|
|
119
|
+
export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
|
|
120
|
+
if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
|
|
121
|
+
const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
|
|
122
|
+
return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
|
|
126
|
+
if (left.kind !== right.kind) return false;
|
|
127
|
+
if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
|
|
128
|
+
if (right.kind !== "git" || left.args.length !== right.args.length) return false;
|
|
129
|
+
return left.args.every((arg, index) => arg === right.args[index]);
|
|
130
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Stable identity for state retained for the lifetime of a pi session. */
|
|
4
|
+
export function sessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
|
|
5
|
+
return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
6
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const MAX_SCHEMA_REPAIR_ATTEMPTS = 2;
|
|
2
|
+
export const STRUCTURED_OUTPUT_ERROR_CODE = "WORKFLOW_STRUCTURED_OUTPUT_NONCOMPLIANCE";
|
|
3
|
+
|
|
4
|
+
export interface StructuredOutputErrorDetails {
|
|
5
|
+
readonly agentLabel: string;
|
|
6
|
+
readonly promptAttempts: number;
|
|
7
|
+
readonly repairAttempts: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Recoverable failure raised when a schema agent never calls `final_answer`. */
|
|
11
|
+
export class WorkflowStructuredOutputError extends Error {
|
|
12
|
+
readonly name = "WorkflowStructuredOutputError";
|
|
13
|
+
readonly code = STRUCTURED_OUTPUT_ERROR_CODE;
|
|
14
|
+
readonly details: StructuredOutputErrorDetails;
|
|
15
|
+
|
|
16
|
+
constructor(agentLabel: string, repairAttempts: number) {
|
|
17
|
+
const promptAttempts = repairAttempts + 1;
|
|
18
|
+
super(
|
|
19
|
+
`Schema agent "${agentLabel}" did not call final_answer after ${promptAttempts} prompt attempts (${repairAttempts} repairs).`,
|
|
20
|
+
);
|
|
21
|
+
this.details = { agentLabel, promptAttempts, repairAttempts };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toJSON(): {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly details: StructuredOutputErrorDetails;
|
|
29
|
+
} {
|
|
30
|
+
return { name: this.name, message: this.message, code: this.code, details: this.details };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolHint } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Raised before prompting when required semantic tool capabilities are unavailable. */
|
|
5
|
+
export class WorkflowToolHintUnavailableError extends Error {
|
|
6
|
+
readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE";
|
|
7
|
+
readonly hints: readonly AgentToolHint[];
|
|
8
|
+
|
|
9
|
+
constructor(hints: readonly AgentToolHint[]) {
|
|
10
|
+
super(`Required workflow tool capability unavailable: ${hints.join(", ")}`);
|
|
11
|
+
this.name = "WorkflowToolHintUnavailableError";
|
|
12
|
+
this.hints = [...hints];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boolean {
|
|
17
|
+
return hint === "search" ? isSearchLikeTool(tool) : isExternalSearchLikeTool(tool);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExternalSearchLikeTool(tool: ToolInfo): boolean {
|
|
21
|
+
if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false;
|
|
22
|
+
const name = tool.name.toLowerCase();
|
|
23
|
+
const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase();
|
|
24
|
+
const text = `${name} ${description}`;
|
|
25
|
+
const hasResearchAction = /\b(?:browse|browser|search|fetch|extract|open|read|get)\b/.test(text)
|
|
26
|
+
|| /(?:^|[-_])(?:browse|browser|search|fetch|extract)(?:$|[-_])/.test(name)
|
|
27
|
+
|| /(?:^|[^a-z0-9])web(?:$|[^a-z0-9])/.test(name);
|
|
28
|
+
const hasExternalMedium = /(?:^|[-_])(?:web|browser|url|http|https|tavily|exa|firecrawl|brave|serper|parallel|perplexity)(?:$|[-_])/.test(name)
|
|
29
|
+
|| /\b(?:search|browse)\s+(?:the\s+)?(?:web|internet)\b/.test(description)
|
|
30
|
+
|| /\b(?:internet|web) search(?: engine)?\b/.test(description)
|
|
31
|
+
|| /\b(?:fetch|extract|open|read|get)\s+(?:(?:the|a|an)\s+)?(?:url|web\s?page|website|https? page)\b/.test(description);
|
|
32
|
+
return hasResearchAction && hasExternalMedium;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isSearchLikeTool(tool: ToolInfo): boolean {
|
|
36
|
+
if (isMutationLikeToolName(tool.name)) return false;
|
|
37
|
+
const name = tool.name.toLowerCase();
|
|
38
|
+
if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
|
|
39
|
+
return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isMutationLikeToolName(name: string): boolean {
|
|
43
|
+
return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase());
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
3
4
|
import { hashIdentity } from "./identity-fingerprint.ts";
|
|
4
5
|
import { logicalWorkspacePath, portableRelativePath } from "./replay-path-identity.ts";
|
|
5
6
|
import { captureTreeFingerprint, isPathWithin, validateTreeFile, type FingerprintCapture } from "./tree-fingerprint.ts";
|
|
@@ -147,7 +148,3 @@ function nonEmptyString(value: string, label: string): string {
|
|
|
147
148
|
if (value.length === 0) throw new Error(`${label} is empty`);
|
|
148
149
|
return value;
|
|
149
150
|
}
|
|
150
|
-
|
|
151
|
-
function isMissingPathError(error: unknown): boolean {
|
|
152
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
153
|
-
}
|
|
@@ -3,6 +3,7 @@ import { constants, type BigIntStats } from "node:fs";
|
|
|
3
3
|
import { lstat, open, readdir, type FileHandle } from "node:fs/promises";
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { throwIfAborted } from "./cancellation.ts";
|
|
6
|
+
import { isMissingPathError } from "./filesystem-error.ts";
|
|
6
7
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
8
|
|
|
8
9
|
export type FingerprintCapture =
|
|
@@ -398,7 +399,3 @@ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefi
|
|
|
398
399
|
}
|
|
399
400
|
return false;
|
|
400
401
|
}
|
|
401
|
-
|
|
402
|
-
function isMissingPathError(error: unknown): boolean {
|
|
403
|
-
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
404
|
-
}
|