pi-workflow-engine 0.9.0 → 0.10.1
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 +136 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +257 -23
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +7 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +83 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +15 -69
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +24 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +200 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +96 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +59 -4
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +81 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +14 -5
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +12 -8
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +6 -2
- package/README.md +10 -1
- package/USAGE.md +50 -8
- package/package.json +8 -8
|
@@ -19,6 +19,8 @@ export interface WorkflowResultEnvelope {
|
|
|
19
19
|
completedAt: number;
|
|
20
20
|
usage?: WorkflowUsageSnapshot;
|
|
21
21
|
perf?: WorkflowPerfDetails;
|
|
22
|
+
runId?: string;
|
|
23
|
+
resumedFromRunId?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface AdvisoryWorkflowResult extends AdvisoryReport {
|
|
@@ -61,27 +63,61 @@ export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResul
|
|
|
61
63
|
return true;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
export
|
|
66
|
+
export interface WorkflowRunDisplayMetadata {
|
|
67
|
+
readonly runId?: string;
|
|
68
|
+
readonly resumedFromRunId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WorkflowDetailLineInput {
|
|
72
|
+
readonly usage?: unknown;
|
|
73
|
+
readonly perf?: WorkflowPerfDetails;
|
|
74
|
+
readonly metadata?: WorkflowRunDisplayMetadata;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderWorkflowResult(
|
|
78
|
+
name: string,
|
|
79
|
+
result: unknown,
|
|
80
|
+
expanded: boolean,
|
|
81
|
+
theme: Theme,
|
|
82
|
+
usage?: unknown,
|
|
83
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
84
|
+
perf?: WorkflowPerfDetails,
|
|
85
|
+
): Component {
|
|
65
86
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
66
|
-
box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage), 0, 0));
|
|
87
|
+
box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage, metadata, perf), 0, 0));
|
|
67
88
|
return box;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
export function renderWorkflowResultText(
|
|
91
|
+
export function renderWorkflowResultText(
|
|
92
|
+
name: string,
|
|
93
|
+
result: unknown,
|
|
94
|
+
expanded: boolean,
|
|
95
|
+
theme: Theme,
|
|
96
|
+
usage?: unknown,
|
|
97
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
98
|
+
perf?: WorkflowPerfDetails,
|
|
99
|
+
): string {
|
|
71
100
|
if (isAdvisoryReport(result)) {
|
|
72
|
-
return renderAdvisoryResult(name, result, expanded, theme, usage);
|
|
101
|
+
return renderAdvisoryResult(name, result, expanded, theme, usage, metadata, perf);
|
|
73
102
|
}
|
|
74
|
-
return renderGenericWorkflowResult(name, result, expanded, theme, usage);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function renderAdvisoryResult(
|
|
103
|
+
return renderGenericWorkflowResult(name, result, expanded, theme, usage, metadata, perf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderAdvisoryResult(
|
|
107
|
+
name: string,
|
|
108
|
+
result: AdvisoryWorkflowResult,
|
|
109
|
+
expanded: boolean,
|
|
110
|
+
theme: Theme,
|
|
111
|
+
usage?: unknown,
|
|
112
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
113
|
+
perf?: WorkflowPerfDetails,
|
|
114
|
+
): string {
|
|
78
115
|
const icon = theme.fg("success", "✓");
|
|
79
116
|
const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
|
|
80
117
|
const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
|
|
81
118
|
const stats = statsLine(result.stats, theme);
|
|
82
119
|
if (stats) lines.push(stats);
|
|
83
|
-
|
|
84
|
-
if (usageLine) lines.push(theme.fg("dim", usageLine));
|
|
120
|
+
pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
|
|
85
121
|
|
|
86
122
|
if (result.findings.length === 0) {
|
|
87
123
|
lines.push(theme.fg("success", "No findings."));
|
|
@@ -108,17 +144,49 @@ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): vo
|
|
|
108
144
|
}
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
function renderGenericWorkflowResult(
|
|
147
|
+
function renderGenericWorkflowResult(
|
|
148
|
+
name: string,
|
|
149
|
+
result: unknown,
|
|
150
|
+
expanded: boolean,
|
|
151
|
+
theme: Theme,
|
|
152
|
+
usage?: unknown,
|
|
153
|
+
metadata?: WorkflowRunDisplayMetadata,
|
|
154
|
+
perf?: WorkflowPerfDetails,
|
|
155
|
+
): string {
|
|
112
156
|
const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
|
|
113
157
|
const summary = extractSummary(result);
|
|
114
158
|
if (summary) lines.push(theme.fg("muted", summary));
|
|
115
|
-
|
|
116
|
-
if (usageLine) lines.push(theme.fg("dim", usageLine));
|
|
159
|
+
pushWorkflowDetailLines(lines, theme, { usage, metadata, perf });
|
|
117
160
|
if (expanded) lines.push(theme.fg("dim", safeJson(result)));
|
|
118
161
|
else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
|
|
119
162
|
return lines.join("\n");
|
|
120
163
|
}
|
|
121
164
|
|
|
165
|
+
function pushWorkflowDetailLines(lines: string[], theme: Theme, input: WorkflowDetailLineInput): void {
|
|
166
|
+
for (const line of formatWorkflowDetailLines(input)) {
|
|
167
|
+
lines.push(theme.fg("dim", line));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function formatWorkflowDetailLines(input: WorkflowDetailLineInput): string[] {
|
|
172
|
+
return [
|
|
173
|
+
formatWorkflowRunLine(input.metadata),
|
|
174
|
+
formatWorkflowUsageLine(input.usage),
|
|
175
|
+
formatWorkflowPerfLine(input.perf),
|
|
176
|
+
].filter((line): line is string => line !== undefined);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function formatWorkflowRunLine(metadata: WorkflowRunDisplayMetadata | undefined): string | undefined {
|
|
180
|
+
if (!metadata?.runId) return undefined;
|
|
181
|
+
return metadata.resumedFromRunId ? `Run: ${metadata.runId} (resumed from ${metadata.resumedFromRunId})` : `Run: ${metadata.runId}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatWorkflowPerfLine(perf: WorkflowPerfDetails | undefined): string | undefined {
|
|
185
|
+
if (!perf) return undefined;
|
|
186
|
+
const parts = perf.aggregates.slice(0, 4).map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
|
|
187
|
+
return parts.length > 0 ? `Perf: ${parts.join(" · ")}` : "Perf: no samples";
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
function statsLine(stats: Record<string, string | number> | undefined, theme: Theme): string | undefined {
|
|
123
191
|
if (!stats) return undefined;
|
|
124
192
|
const ordered = ["files", "candidates", "dropped", "verified", "kept"];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
+
import { compactResults } from "./concurrency.ts";
|
|
2
3
|
import type { AgentOptions, WorkflowApi, WorkflowProgressEvent } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
export interface AdvisoryLens {
|
|
@@ -20,7 +21,11 @@ export interface LensVerificationPipelineResult<Verified> {
|
|
|
20
21
|
refuted: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** Default concrete read/inspect tools for advisory workflows. */
|
|
25
|
+
export const DEFAULT_ADVISORY_TOOLS: NonNullable<AgentOptions["tools"]> = ["read", "bash", "grep", "find", "ls"];
|
|
26
|
+
|
|
27
|
+
/** Dynamically include installed grep/find/search-like extension tools. */
|
|
28
|
+
export const DEFAULT_ADVISORY_TOOL_HINTS: NonNullable<AgentOptions["toolHints"]> = ["search"];
|
|
24
29
|
|
|
25
30
|
export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
|
|
26
31
|
|
|
@@ -29,6 +34,7 @@ export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Veri
|
|
|
29
34
|
lenses: readonly Lens[];
|
|
30
35
|
perLens: number;
|
|
31
36
|
tools?: AgentOptions["tools"];
|
|
37
|
+
toolHints?: AgentOptions["toolHints"];
|
|
32
38
|
finderPhase?: string;
|
|
33
39
|
verifierPhase?: string;
|
|
34
40
|
schedulingMode?: AdvisorySchedulingMode;
|
|
@@ -60,6 +66,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
60
66
|
lenses,
|
|
61
67
|
perLens,
|
|
62
68
|
tools = DEFAULT_ADVISORY_TOOLS,
|
|
69
|
+
toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
|
|
63
70
|
finderPhase = "Find",
|
|
64
71
|
verifierPhase = "Verify",
|
|
65
72
|
schedulingMode = "pipeline",
|
|
@@ -77,6 +84,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
77
84
|
phase: finderPhase,
|
|
78
85
|
label: `find:${lens.label}`,
|
|
79
86
|
tools,
|
|
87
|
+
toolHints,
|
|
80
88
|
thinkingLevel: "low",
|
|
81
89
|
schema: AdvisoryCandidatesSchema,
|
|
82
90
|
});
|
|
@@ -113,6 +121,7 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
113
121
|
phase: verifierPhase,
|
|
114
122
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
115
123
|
tools,
|
|
124
|
+
toolHints,
|
|
116
125
|
thinkingLevel: "low",
|
|
117
126
|
schema: AdvisoryVerdictSchema,
|
|
118
127
|
});
|
|
@@ -125,9 +134,9 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
125
134
|
|
|
126
135
|
if (schedulingMode === "finder-barrier") {
|
|
127
136
|
const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
|
|
128
|
-
const novel = found.flatMap(dedupeFound);
|
|
137
|
+
const novel = compactResults(found).flatMap(dedupeFound);
|
|
129
138
|
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
130
|
-
return { verified: verdicts
|
|
139
|
+
return { verified: compactResults(verdicts), rawCandidates, dropped, refuted };
|
|
131
140
|
}
|
|
132
141
|
|
|
133
142
|
const perLensVerified = await api.pipeline(
|
|
@@ -136,11 +145,11 @@ export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Ver
|
|
|
136
145
|
async (found) => {
|
|
137
146
|
const novel = dedupeFound(found);
|
|
138
147
|
const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
|
|
139
|
-
return verdicts
|
|
148
|
+
return compactResults(verdicts);
|
|
140
149
|
},
|
|
141
150
|
);
|
|
142
151
|
|
|
143
|
-
return { verified: perLensVerified.flat(), rawCandidates, dropped, refuted };
|
|
152
|
+
return { verified: compactResults(perLensVerified).flat(), rawCandidates, dropped, refuted };
|
|
144
153
|
}
|
|
145
154
|
|
|
146
155
|
function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runBoundedProcess } from "./process-runner.ts";
|
|
5
|
+
|
|
6
|
+
export interface WorktreeGitCommandOptions {
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly args: readonly string[];
|
|
9
|
+
readonly signal?: AbortSignal;
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
readonly maxBufferBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WorktreeGitCommandResult {
|
|
15
|
+
readonly ok: boolean;
|
|
16
|
+
readonly stdout: string;
|
|
17
|
+
readonly stderr: string;
|
|
18
|
+
readonly error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WorktreeGitRunner {
|
|
22
|
+
runGit(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorktreeRef {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorktreeAddFailure {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly error: string;
|
|
32
|
+
readonly cleanup?: WorktreeGitCommandResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GitWorktreeProbe {
|
|
36
|
+
readonly ok: boolean;
|
|
37
|
+
readonly inside: boolean;
|
|
38
|
+
readonly error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WorktreeRemovalOutcome extends WorktreeGitCommandResult {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorktreePatch {
|
|
46
|
+
readonly patch: string;
|
|
47
|
+
readonly changed: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WorktreeRegistryOptions {
|
|
51
|
+
readonly repoCwd: string;
|
|
52
|
+
readonly runner?: WorktreeGitRunner;
|
|
53
|
+
readonly patchCapture?: WorktreePatchCapture;
|
|
54
|
+
readonly timeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WorktreePatchCapture = (options: {
|
|
58
|
+
readonly worktreePath: string;
|
|
59
|
+
readonly runner?: WorktreeGitRunner;
|
|
60
|
+
readonly signal?: AbortSignal;
|
|
61
|
+
readonly timeoutMs?: number;
|
|
62
|
+
}) => Promise<WorktreePatch | { readonly error: string }>;
|
|
63
|
+
|
|
64
|
+
const DEFAULT_WORKTREE_TIMEOUT_MS = 30_000;
|
|
65
|
+
const WORKTREE_DIFF_MAX_BYTES = 16 << 20;
|
|
66
|
+
let pathCounter = 0;
|
|
67
|
+
|
|
68
|
+
export class WorktreeRegistry {
|
|
69
|
+
private readonly paths = new Set<string>();
|
|
70
|
+
private readonly runner: WorktreeGitRunner;
|
|
71
|
+
private readonly patchCapture: WorktreePatchCapture;
|
|
72
|
+
private readonly timeoutMs: number;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly repoCwd: string, options: Omit<WorktreeRegistryOptions, "repoCwd"> = {}) {
|
|
75
|
+
this.runner = options.runner ?? spawnGitRunner;
|
|
76
|
+
this.patchCapture = options.patchCapture ?? captureWorktreePatch;
|
|
77
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get size(): number {
|
|
81
|
+
return this.paths.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
register(path: string): void {
|
|
85
|
+
this.paths.add(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async probe(signal?: AbortSignal): Promise<GitWorktreeProbe> {
|
|
89
|
+
return await isGitWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async add(signal?: AbortSignal): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
93
|
+
const added = await addWorktree({ repoCwd: this.repoCwd, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
94
|
+
this.register(added.path);
|
|
95
|
+
if ("error" in added) {
|
|
96
|
+
const cleanup = await this.remove(added.path);
|
|
97
|
+
return { ...added, cleanup };
|
|
98
|
+
}
|
|
99
|
+
return added;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async capturePatch(path: string, signal?: AbortSignal): Promise<WorktreePatch | { readonly error: string }> {
|
|
103
|
+
return await this.patchCapture({ worktreePath: path, runner: this.runner, signal, timeoutMs: this.timeoutMs });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async remove(path: string): Promise<WorktreeGitCommandResult> {
|
|
107
|
+
const result = await removeWorktree({ repoCwd: this.repoCwd, path, runner: this.runner, timeoutMs: this.timeoutMs }).catch((error: unknown) => ({
|
|
108
|
+
ok: false,
|
|
109
|
+
stdout: "",
|
|
110
|
+
stderr: "",
|
|
111
|
+
error: formatError(error),
|
|
112
|
+
}));
|
|
113
|
+
if (result.ok) this.paths.delete(path);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async removeAll(): Promise<readonly WorktreeRemovalOutcome[]> {
|
|
118
|
+
return await Promise.all(
|
|
119
|
+
[...this.paths].map(async (path) => ({
|
|
120
|
+
path,
|
|
121
|
+
...(await this.remove(path)),
|
|
122
|
+
})),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createWorktreePath(baseDir = tmpdir()): string {
|
|
128
|
+
pathCounter += 1;
|
|
129
|
+
return join(baseDir, `pi-workflow-${process.pid}-${pathCounter}-${randomUUID()}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function isGitWorktree(options: {
|
|
133
|
+
readonly repoCwd: string;
|
|
134
|
+
readonly runner?: WorktreeGitRunner;
|
|
135
|
+
readonly signal?: AbortSignal;
|
|
136
|
+
readonly timeoutMs?: number;
|
|
137
|
+
}): Promise<GitWorktreeProbe> {
|
|
138
|
+
const result = await (options.runner ?? spawnGitRunner)
|
|
139
|
+
.runGit({
|
|
140
|
+
cwd: options.repoCwd,
|
|
141
|
+
args: ["rev-parse", "--is-inside-work-tree"],
|
|
142
|
+
signal: options.signal,
|
|
143
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
144
|
+
})
|
|
145
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
146
|
+
if (!result.ok) return { ok: false, inside: false, error: result.error ?? (result.stderr.trim() || "git worktree probe failed") };
|
|
147
|
+
return { ok: true, inside: result.stdout.trim() === "true" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function addWorktree(options: {
|
|
151
|
+
readonly repoCwd: string;
|
|
152
|
+
readonly runner?: WorktreeGitRunner;
|
|
153
|
+
readonly signal?: AbortSignal;
|
|
154
|
+
readonly timeoutMs?: number;
|
|
155
|
+
readonly baseDir?: string;
|
|
156
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
157
|
+
const path = createWorktreePath(options.baseDir);
|
|
158
|
+
const result = await (options.runner ?? spawnGitRunner)
|
|
159
|
+
.runGit({
|
|
160
|
+
cwd: options.repoCwd,
|
|
161
|
+
args: ["worktree", "add", "--detach", path, "HEAD"],
|
|
162
|
+
signal: options.signal,
|
|
163
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
164
|
+
})
|
|
165
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
166
|
+
if (!result.ok) return { path, error: result.error ?? (result.stderr.trim() || "git worktree add failed") };
|
|
167
|
+
return { path };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function removeWorktree(options: {
|
|
171
|
+
readonly repoCwd: string;
|
|
172
|
+
readonly path: string;
|
|
173
|
+
readonly runner?: WorktreeGitRunner;
|
|
174
|
+
readonly timeoutMs?: number;
|
|
175
|
+
}): Promise<WorktreeGitCommandResult> {
|
|
176
|
+
return await (options.runner ?? spawnGitRunner).runGit({
|
|
177
|
+
cwd: options.repoCwd,
|
|
178
|
+
args: ["worktree", "remove", "--force", options.path],
|
|
179
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function captureWorktreePatch(options: {
|
|
184
|
+
readonly worktreePath: string;
|
|
185
|
+
readonly runner?: WorktreeGitRunner;
|
|
186
|
+
readonly signal?: AbortSignal;
|
|
187
|
+
readonly timeoutMs?: number;
|
|
188
|
+
}): Promise<WorktreePatch | { readonly error: string }> {
|
|
189
|
+
const runner = options.runner ?? spawnGitRunner;
|
|
190
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WORKTREE_TIMEOUT_MS;
|
|
191
|
+
const intentToAdd = await runner
|
|
192
|
+
.runGit({
|
|
193
|
+
cwd: options.worktreePath,
|
|
194
|
+
args: ["add", "-N", "."],
|
|
195
|
+
signal: options.signal,
|
|
196
|
+
timeoutMs,
|
|
197
|
+
})
|
|
198
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
199
|
+
if (!intentToAdd.ok) return { error: intentToAdd.error ?? (intentToAdd.stderr.trim() || "git add -N failed before patch capture") };
|
|
200
|
+
|
|
201
|
+
const diff = await runner
|
|
202
|
+
.runGit({
|
|
203
|
+
cwd: options.worktreePath,
|
|
204
|
+
args: ["diff", "--no-color", "HEAD"],
|
|
205
|
+
signal: options.signal,
|
|
206
|
+
timeoutMs,
|
|
207
|
+
maxBufferBytes: WORKTREE_DIFF_MAX_BYTES,
|
|
208
|
+
})
|
|
209
|
+
.catch((error: unknown) => ({ ok: false, stdout: "", stderr: "", error: formatError(error) }));
|
|
210
|
+
if (!diff.ok) return { error: diff.error ?? (diff.stderr.trim() || "worktree diff capture failed") };
|
|
211
|
+
return { patch: diff.stdout, changed: diff.stdout.trim().length > 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const spawnGitRunner: WorktreeGitRunner = {
|
|
215
|
+
async runGit(options) {
|
|
216
|
+
return await runGitCommand(options);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
async function runGitCommand(options: WorktreeGitCommandOptions): Promise<WorktreeGitCommandResult> {
|
|
221
|
+
return await runBoundedProcess({
|
|
222
|
+
file: "git",
|
|
223
|
+
args: options.args,
|
|
224
|
+
cwd: options.cwd,
|
|
225
|
+
env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
|
|
226
|
+
signal: options.signal,
|
|
227
|
+
timeoutMs: options.timeoutMs,
|
|
228
|
+
maxBufferBytes: options.maxBufferBytes,
|
|
229
|
+
abortError: "git worktree command aborted",
|
|
230
|
+
timeoutError: `git worktree command timed out after ${options.timeoutMs}ms`,
|
|
231
|
+
maxBufferError: options.maxBufferBytes === undefined ? undefined : `git worktree command exceeded ${options.maxBufferBytes} bytes`,
|
|
232
|
+
exitError: (stderr, code, signal) => stderr.trim() || `git exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatError(error: unknown): string {
|
|
237
|
+
return error instanceof Error ? error.message : String(error);
|
|
238
|
+
}
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
primaryLocation,
|
|
15
15
|
recordVerdictProgress,
|
|
16
16
|
verdictConfidence,
|
|
17
|
+
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
18
|
+
DEFAULT_ADVISORY_TOOLS,
|
|
17
19
|
} from "../src/workflow-advisory-utils.ts";
|
|
20
|
+
import { compactResults } from "../src/concurrency.ts";
|
|
18
21
|
import { captureDiff } from "../src/diff-capture.ts";
|
|
19
22
|
import type { ReviewContext } from "../src/review/review-issues.ts";
|
|
20
23
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
@@ -55,7 +58,8 @@ const ANGLES: Angle[] = [
|
|
|
55
58
|
{ label: "conventions", kind: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
|
|
56
59
|
];
|
|
57
60
|
|
|
58
|
-
const TOOLS =
|
|
61
|
+
const TOOLS = DEFAULT_ADVISORY_TOOLS;
|
|
62
|
+
const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
|
|
59
63
|
const PER_ANGLE = 6;
|
|
60
64
|
const DIFF_EMBED_CAP = 60_000;
|
|
61
65
|
|
|
@@ -123,7 +127,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
123
127
|
"Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
|
|
124
128
|
"and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
|
|
125
129
|
"Return diffCommand exactly as a reviewer should run it. Structured output only.",
|
|
126
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
130
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
127
131
|
);
|
|
128
132
|
|
|
129
133
|
if (!scope) {
|
|
@@ -195,7 +199,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
195
199
|
`Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
|
|
196
200
|
"locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
|
|
197
201
|
"Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
|
|
198
|
-
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
|
|
202
|
+
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
|
|
199
203
|
);
|
|
200
204
|
const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
|
|
201
205
|
rawCandidateCount += raw.length;
|
|
@@ -224,7 +228,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
224
228
|
}),
|
|
225
229
|
);
|
|
226
230
|
|
|
227
|
-
const novel = perAngle.flatMap(({ angle, candidates }) =>
|
|
231
|
+
const novel = compactResults(perAngle).flatMap(({ angle, candidates }) =>
|
|
228
232
|
candidates
|
|
229
233
|
.filter((candidate) => {
|
|
230
234
|
const key = dedupKey(candidate);
|
|
@@ -249,6 +253,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
249
253
|
phase: "Verify",
|
|
250
254
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
251
255
|
tools: TOOLS,
|
|
256
|
+
toolHints: TOOL_HINTS,
|
|
252
257
|
thinkingLevel: "low",
|
|
253
258
|
schema: AdvisoryVerdictSchema,
|
|
254
259
|
},
|
|
@@ -259,7 +264,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
259
264
|
}),
|
|
260
265
|
);
|
|
261
266
|
|
|
262
|
-
const verified = verdicts
|
|
267
|
+
const verified = compactResults(verdicts);
|
|
263
268
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
264
269
|
const stats = makeStats(verified.length, surviving.length);
|
|
265
270
|
progress({ type: "counter", key: "verified", label: "verified", value: verified.length });
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
formatLocation,
|
|
15
15
|
primaryLocation,
|
|
16
16
|
recordVerdictProgress,
|
|
17
|
+
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
18
|
+
DEFAULT_ADVISORY_TOOLS,
|
|
17
19
|
} from "../src/workflow-advisory-utils.ts";
|
|
20
|
+
import { compactResults } from "../src/concurrency.ts";
|
|
18
21
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
19
22
|
|
|
20
23
|
export const meta: WorkflowMeta = {
|
|
@@ -57,7 +60,8 @@ const HYPOTHESIS_LENSES: HypothesisLens[] = [
|
|
|
57
60
|
{ label: "test-fixture", category: "test-fixture", text: "The failure is caused by test setup, fixtures, mocks, generated files, or stale local state rather than product code." },
|
|
58
61
|
];
|
|
59
62
|
|
|
60
|
-
const TOOLS =
|
|
63
|
+
const TOOLS = DEFAULT_ADVISORY_TOOLS;
|
|
64
|
+
const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
|
|
61
65
|
const PER_LENS = 4;
|
|
62
66
|
|
|
63
67
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
@@ -85,7 +89,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
85
89
|
"Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
|
|
86
90
|
"Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
|
|
87
91
|
"Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
|
|
88
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
92
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
89
93
|
);
|
|
90
94
|
|
|
91
95
|
if (!scope) {
|
|
@@ -117,7 +121,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
117
121
|
`Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
|
|
118
122
|
`Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
|
|
119
123
|
"Each hypothesis must include a one-line summary, locations, impact explaining how it produces the symptom, and an optional recommendation for the next validation step. Structured output only.",
|
|
120
|
-
{ phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
|
|
124
|
+
{ phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "low", schema: AdvisoryCandidatesSchema },
|
|
121
125
|
);
|
|
122
126
|
const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
|
|
123
127
|
rawCandidateCount += candidates.length;
|
|
@@ -136,13 +140,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
136
140
|
}),
|
|
137
141
|
);
|
|
138
142
|
|
|
139
|
-
const hypotheses = dedupe(perLens.flat(), (dropped) => {
|
|
143
|
+
const hypotheses = dedupe(compactResults(perLens).flat(), (dropped) => {
|
|
140
144
|
droppedCandidateCount += dropped;
|
|
141
145
|
progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
|
|
142
146
|
});
|
|
143
147
|
|
|
144
148
|
phase("Verify");
|
|
145
|
-
const verified = (
|
|
149
|
+
const verified = compactResults(
|
|
146
150
|
await parallel(
|
|
147
151
|
hypotheses.map((hypothesis) => async (): Promise<Verified | null> => {
|
|
148
152
|
const location = primaryLocation(hypothesis);
|
|
@@ -157,6 +161,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
157
161
|
phase: "Verify",
|
|
158
162
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
159
163
|
tools: TOOLS,
|
|
164
|
+
toolHints: TOOL_HINTS,
|
|
160
165
|
thinkingLevel: "low",
|
|
161
166
|
schema: AdvisoryVerdictSchema,
|
|
162
167
|
},
|
|
@@ -167,8 +172,8 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
167
172
|
});
|
|
168
173
|
return { ...hypothesis, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
|
|
169
174
|
}),
|
|
170
|
-
)
|
|
171
|
-
)
|
|
175
|
+
),
|
|
176
|
+
);
|
|
172
177
|
|
|
173
178
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
174
179
|
const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
|
|
@@ -241,4 +246,3 @@ function rank(finding: Verified): number {
|
|
|
241
246
|
if (finding.verdict === "CONFIRMED") return 0;
|
|
242
247
|
return 1;
|
|
243
248
|
}
|
|
244
|
-
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
formatEvidence,
|
|
11
11
|
formatLocation,
|
|
12
12
|
runLensVerificationPipeline,
|
|
13
|
+
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
14
|
+
DEFAULT_ADVISORY_TOOLS,
|
|
13
15
|
} from "../src/workflow-advisory-utils.ts";
|
|
14
16
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
15
17
|
|
|
@@ -51,7 +53,8 @@ const PERF_LENSES: PerfLens[] = [
|
|
|
51
53
|
{ label: "measurement", category: "measurement", text: "Missing, misleading, noisy, or insufficient benchmark/measurement design." },
|
|
52
54
|
];
|
|
53
55
|
|
|
54
|
-
const TOOLS =
|
|
56
|
+
const TOOLS = DEFAULT_ADVISORY_TOOLS;
|
|
57
|
+
const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
|
|
55
58
|
const PER_LENS = 4;
|
|
56
59
|
|
|
57
60
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
@@ -77,7 +80,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
77
80
|
"Inspect repository structure, scripts, likely hot-path files, and any existing benchmark or measurement commands. " +
|
|
78
81
|
"Prefer identifying what to measure before claiming bottlenecks. Return files, commands, summary, and known measurements or the lack of them. " +
|
|
79
82
|
`This workflow will fan out across ${PERF_LENSES.length} lenses with up to ${PER_LENS} candidates per lens. Structured output only.`,
|
|
80
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
83
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
81
84
|
);
|
|
82
85
|
|
|
83
86
|
if (!scope || scope.files.length === 0) {
|
|
@@ -107,6 +110,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
107
110
|
schedulingMode: "finder-barrier",
|
|
108
111
|
perLens: PER_LENS,
|
|
109
112
|
tools: TOOLS,
|
|
113
|
+
toolHints: TOOL_HINTS,
|
|
110
114
|
finderPrompt: (lens) =>
|
|
111
115
|
`## Perf-review finder — ${lens.label}\n\n${scopeBlock}\n` +
|
|
112
116
|
"This workflow is advisory-only: identify bottleneck hypotheses, measurement gaps, and safe optimization directions, but do not edit files.\n" +
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
formatEvidence,
|
|
11
11
|
formatLocation,
|
|
12
12
|
runLensVerificationPipeline,
|
|
13
|
+
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
14
|
+
DEFAULT_ADVISORY_TOOLS,
|
|
13
15
|
} from "../src/workflow-advisory-utils.ts";
|
|
14
16
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
15
17
|
|
|
@@ -50,7 +52,8 @@ const REFACTOR_LENSES: RefactorLens[] = [
|
|
|
50
52
|
{ label: "conventions", category: "conventions", text: "Departures from project conventions, naming, dependency rules, or local idioms." },
|
|
51
53
|
];
|
|
52
54
|
|
|
53
|
-
const TOOLS =
|
|
55
|
+
const TOOLS = DEFAULT_ADVISORY_TOOLS;
|
|
56
|
+
const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
|
|
54
57
|
const PER_LENS = 5;
|
|
55
58
|
|
|
56
59
|
export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
@@ -76,7 +79,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
76
79
|
"Inspect repository structure, the target path or module, and relevant AGENTS.md / project docs conventions. " +
|
|
77
80
|
"Return the concrete files that should be considered, a short summary, and any conventions that affect refactor advice. " +
|
|
78
81
|
`This workflow will fan out across ${REFACTOR_LENSES.length} lenses with up to ${PER_LENS} candidates per lens. Structured output only.`,
|
|
79
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
82
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, thinkingLevel: "medium", schema: ScopeSchema },
|
|
80
83
|
);
|
|
81
84
|
|
|
82
85
|
if (!scope || scope.files.length === 0) {
|
|
@@ -104,6 +107,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
104
107
|
schedulingMode: "finder-barrier",
|
|
105
108
|
perLens: PER_LENS,
|
|
106
109
|
tools: TOOLS,
|
|
110
|
+
toolHints: TOOL_HINTS,
|
|
107
111
|
finderPrompt: (lens) =>
|
|
108
112
|
`## Refactor-scout finder — ${lens.label}\n\n${scopeBlock}\n` +
|
|
109
113
|
"This workflow is advisory-only: do not edit files and do not propose broad rewrites.\n" +
|