pi-workflow-engine 0.13.0 → 1.1.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 +152 -416
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +17 -14
- 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 +12 -13
- 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 +131 -117
- 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-workflow-tool.ts +3 -0
- 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/classify.ts +107 -0
- 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 +58 -117
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +41 -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/host-tools.ts +76 -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/model-ref.ts +19 -0
- 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/pi-compat.ts +2 -2
- 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 +29 -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 +17 -4
- 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 +30 -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 +41 -34
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +162 -22
- 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/README.md +1 -1
- package/USAGE.md +79 -20
- 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,7 +1,11 @@
|
|
|
1
|
-
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
-
import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
|
|
1
|
+
import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
|
|
2
|
+
import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, repoPathCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
|
|
3
|
+
import { isFatalWorkflowError } from "./cancellation.ts";
|
|
4
|
+
import { WorkflowClassifierUnavailableError } from "./classify.ts";
|
|
5
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
3
6
|
import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
|
|
4
7
|
|
|
8
|
+
|
|
5
9
|
export interface AdvisoryLens {
|
|
6
10
|
label: string;
|
|
7
11
|
category: string;
|
|
@@ -11,7 +15,6 @@ export interface AdvisoryLens {
|
|
|
11
15
|
export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
|
|
12
16
|
verdict: AdvisoryVerdict["verdict"];
|
|
13
17
|
evidence: string[];
|
|
14
|
-
confidence?: AdvisoryVerdict["confidence"];
|
|
15
18
|
challenge?: import("./advisory-challenge.ts").ChallengeRecord;
|
|
16
19
|
};
|
|
17
20
|
|
|
@@ -19,7 +22,6 @@ export interface LensVerificationPipelineResult {
|
|
|
19
22
|
verified: AdvisoryVerified[];
|
|
20
23
|
rawCandidates: number;
|
|
21
24
|
dropped: number;
|
|
22
|
-
refuted: number;
|
|
23
25
|
coverage: AdvisoryStageCoverage[];
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -50,7 +52,7 @@ export function publishVerifiedKeptProgress(
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export interface LensVerificationPipelineOptions {
|
|
53
|
-
api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
|
|
55
|
+
api: Pick<WorkflowApi, "agent" | "classify" | "parallel" | "phase" | "progress" | "log">;
|
|
54
56
|
lenses: readonly AdvisoryLens[];
|
|
55
57
|
perLens: number;
|
|
56
58
|
finderPhase?: "Find" | "Hypothesize";
|
|
@@ -71,7 +73,6 @@ export async function runLensVerificationPipeline(
|
|
|
71
73
|
const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
|
|
72
74
|
const coverage: AdvisoryStageCoverage[] = [];
|
|
73
75
|
let rawCandidates = 0;
|
|
74
|
-
let refuted = 0;
|
|
75
76
|
api.phase(finderPhase);
|
|
76
77
|
const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
|
|
77
78
|
const result = await api.agent(finderPrompt(lens), {
|
|
@@ -92,7 +93,7 @@ export async function runLensVerificationPipeline(
|
|
|
92
93
|
}
|
|
93
94
|
return candidates;
|
|
94
95
|
} })), coverage);
|
|
95
|
-
const candidates = dedupeCandidates(found.flat());
|
|
96
|
+
const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
|
|
96
97
|
const dropped = rawCandidates - candidates.length;
|
|
97
98
|
if (dropped > 0) {
|
|
98
99
|
api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
|
|
@@ -107,11 +108,159 @@ export async function runLensVerificationPipeline(
|
|
|
107
108
|
tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
|
|
108
109
|
profile: "small", schema: AdvisoryVerdictSchema,
|
|
109
110
|
});
|
|
110
|
-
recordVerdictProgress(api.progress, candidate, judged
|
|
111
|
-
return { ...candidate, verdict: judged.verdict, evidence: judged.evidence
|
|
111
|
+
recordVerdictProgress(api.progress, candidate, judged);
|
|
112
|
+
return { ...candidate, verdict: judged.verdict, evidence: judged.evidence };
|
|
112
113
|
},
|
|
113
114
|
})), coverage);
|
|
114
|
-
return { verified, rawCandidates, dropped,
|
|
115
|
+
return { verified, rawCandidates, dropped, coverage };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */
|
|
119
|
+
export const DUPLICATE_CANDIDATE_CLASSIFIERS = [
|
|
120
|
+
"typesafe/jev-latest",
|
|
121
|
+
"openrouter/~typesafe/jev-latest",
|
|
122
|
+
"openrouter/typesafe/jev-1.13",
|
|
123
|
+
"cloudflare-workers-ai/typesafe/jev",
|
|
124
|
+
"vercel-ai-gateway/typesafe-ai/jev",
|
|
125
|
+
"opencode/jev-1.13",
|
|
126
|
+
"opencode/jev-1.13-free",
|
|
127
|
+
] as const;
|
|
128
|
+
|
|
129
|
+
/** Probability above which two candidates count as one defect. */
|
|
130
|
+
const SAME_DEFECT_PROBABILITY = 0.8;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier.
|
|
134
|
+
* Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier
|
|
135
|
+
* agents and cost by about 40% without losing a finding. Runs without a reachable Jev classifier skip it.
|
|
136
|
+
*/
|
|
137
|
+
export async function mergeEquivalentCandidates<T extends IdentifiedAdvisoryCandidate>(
|
|
138
|
+
api: Pick<WorkflowApi, "classify" | "parallel" | "log">,
|
|
139
|
+
candidates: readonly T[],
|
|
140
|
+
): Promise<T[]> {
|
|
141
|
+
const files = candidates.map(candidateFile);
|
|
142
|
+
const pairs: Array<readonly [number, number]> = [];
|
|
143
|
+
for (let first = 0; first < candidates.length; first++) {
|
|
144
|
+
for (let second = first + 1; second < candidates.length; second++) {
|
|
145
|
+
if (files[first] === files[second]) pairs.push([first, second]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (pairs.length === 0) return [...candidates];
|
|
149
|
+
const sameDefect = async ([first, second]: readonly [number, number]): Promise<number> => {
|
|
150
|
+
const answers = await api.classify({
|
|
151
|
+
state: { first: candidateForClassifier(candidates[first]!), second: candidateForClassifier(candidates[second]!) },
|
|
152
|
+
questions: {
|
|
153
|
+
same: {
|
|
154
|
+
type: "bool",
|
|
155
|
+
instructions: "Two code-review candidates were raised against the same file. Do they describe the same underlying defect (same root cause and same fix), even if worded differently?",
|
|
156
|
+
criteria: { true: "Same underlying defect", false: "Different defects" },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}, { model: DUPLICATE_CANDIDATE_CLASSIFIERS, label: "dedup" });
|
|
160
|
+
return answers.same?.type === "bool" ? answers.same.probability : 0;
|
|
161
|
+
};
|
|
162
|
+
// The first call probes the classifier: if it fails for any reason (no Jev route, bad credentials, a rate
|
|
163
|
+
// limit), skip the merge rather than send every other pair into the same failure.
|
|
164
|
+
let firstProbability: number;
|
|
165
|
+
try {
|
|
166
|
+
firstProbability = await sameDefect(pairs[0]!);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (isFatalWorkflowError(error, undefined)) throw error;
|
|
169
|
+
if (!(error instanceof WorkflowClassifierUnavailableError)) api.log(`Skipped merging duplicate candidates: ${unknownErrorMessage(error)}`);
|
|
170
|
+
return [...candidates];
|
|
171
|
+
}
|
|
172
|
+
if (pairs.length > 1) api.log(`Comparing ${pairs.length} same-file candidate pairs for duplicates`);
|
|
173
|
+
const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair)));
|
|
174
|
+
const probability = new Map<string, number>();
|
|
175
|
+
pairs.forEach(([first, second], index) => probability.set(`${first}:${second}`, index === 0 ? firstProbability : rest[index - 1] ?? 0));
|
|
176
|
+
|
|
177
|
+
// Complete linkage: a candidate joins a group only when it matches every member, so one vague candidate
|
|
178
|
+
// that resembles two different defects cannot chain them together.
|
|
179
|
+
const groups: number[][] = [];
|
|
180
|
+
candidates.forEach((_, index) => {
|
|
181
|
+
const group = groups.find((members) =>
|
|
182
|
+
members.every((member) => (probability.get(`${member}:${index}`) ?? 0) >= SAME_DEFECT_PROBABILITY));
|
|
183
|
+
if (group) group.push(index);
|
|
184
|
+
else groups.push([index]);
|
|
185
|
+
});
|
|
186
|
+
const merged = groups.map(([keep, ...folded]) => {
|
|
187
|
+
const kept = { ...candidates[keep!]! };
|
|
188
|
+
for (const index of folded) {
|
|
189
|
+
const duplicate = candidates[index]!;
|
|
190
|
+
mergeCandidateInto(kept, duplicate);
|
|
191
|
+
// The kept summary leads; keep folded wording visible to the verifier in case the classifier was wrong.
|
|
192
|
+
if (duplicate.summary !== kept.summary) {
|
|
193
|
+
kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), `Also reported as: ${duplicate.summary}`])];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return kept;
|
|
197
|
+
});
|
|
198
|
+
const folded = candidates.length - merged.length;
|
|
199
|
+
if (folded > 0) api.log(`Merged ${folded} candidate(s) describing the same defect before verification`);
|
|
200
|
+
return merged;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Pair by the changed line when there is one, with the repository-relative path exact dedup uses. */
|
|
204
|
+
function candidateFile(candidate: IdentifiedAdvisoryCandidate): string {
|
|
205
|
+
return repoPathCandidates((candidate.reviewAnchor ?? primaryLocation(candidate)).file)[1];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) {
|
|
209
|
+
return {
|
|
210
|
+
category: candidate.category,
|
|
211
|
+
summary: candidate.summary,
|
|
212
|
+
impact: candidate.impact,
|
|
213
|
+
location: formatLocation(candidate),
|
|
214
|
+
evidence: (candidate.discoveryEvidence ?? []).slice(0, 4),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Stats for a lens review that stopped before discovery. */
|
|
219
|
+
export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
|
|
220
|
+
|
|
221
|
+
export interface LensReviewConclusion {
|
|
222
|
+
/** Scoped file count reported in stats. */
|
|
223
|
+
files: number;
|
|
224
|
+
rank(finding: AdvisoryVerified): number;
|
|
225
|
+
/** One ranked finding's synthesis-prompt block, headed by the IDs synthesis selects; defaults to {@link formatRankedFinding}. */
|
|
226
|
+
formatFinding?(finding: AdvisoryVerified, index: number): string;
|
|
227
|
+
/** Report used when no finding survives verification and challenge. */
|
|
228
|
+
empty: { summary: string; nextSteps: string[] };
|
|
229
|
+
synthesisPrompt(block: string, ranked: readonly AdvisoryVerified[], refuted: readonly AdvisoryVerified[]): string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function formatRankedFinding(finding: AdvisoryVerified, index: number, recommendationLabel = "Recommendation"): string {
|
|
233
|
+
return `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
|
|
234
|
+
`${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\n${recommendationLabel}: ${finding.recommendation ?? "(none supplied)"}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Rank and synthesize the findings that survived verification and challenge into the final report. */
|
|
238
|
+
export async function concludeLensReview(
|
|
239
|
+
api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">,
|
|
240
|
+
pipeline: LensVerificationPipelineResult,
|
|
241
|
+
verified: AdvisoryVerified[],
|
|
242
|
+
conclusion: LensReviewConclusion,
|
|
243
|
+
) {
|
|
244
|
+
const { coverage } = pipeline;
|
|
245
|
+
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
246
|
+
const stats: WorkflowRunStats = {
|
|
247
|
+
files: conclusion.files,
|
|
248
|
+
candidates: pipeline.rawCandidates,
|
|
249
|
+
verified: verified.length,
|
|
250
|
+
kept: surviving.length,
|
|
251
|
+
dropped: pipeline.dropped,
|
|
252
|
+
refuted: verified.length - surviving.length,
|
|
253
|
+
};
|
|
254
|
+
publishVerifiedKeptProgress(api, verified.length, surviving.length);
|
|
255
|
+
if (surviving.length === 0) {
|
|
256
|
+
return finishAdvisoryReport(emptyAdvisoryReport(conclusion.empty.summary, conclusion.empty.nextSteps, stats), coverage, verified);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ranked = [...surviving].sort((a, b) => conclusion.rank(a) - conclusion.rank(b));
|
|
260
|
+
const block = ranked.map((finding, index) => (conclusion.formatFinding ?? formatRankedFinding)(finding, index)).join("\n\n");
|
|
261
|
+
const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
|
|
262
|
+
const resolved = await synthesizeAdvisoryReport(api, conclusion.synthesisPrompt(block, ranked, refuted), ranked, coverage);
|
|
263
|
+
return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length } }, coverage, verified);
|
|
115
264
|
}
|
|
116
265
|
|
|
117
266
|
export async function synthesizeAdvisoryReport(
|
|
@@ -123,7 +272,7 @@ export async function synthesizeAdvisoryReport(
|
|
|
123
272
|
api.phase("Synthesize");
|
|
124
273
|
const [report] = await collectAdvisoryStage(api, "Synthesize", [{ id: "synthesize", run: () => api.agent(
|
|
125
274
|
SYNTHESIS_ID_INSTRUCTIONS + prompt,
|
|
126
|
-
{ phase: "Synthesize", label: "synthesize", tools: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
|
|
275
|
+
{ phase: "Synthesize", label: "synthesize", tools: [], skills: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
|
|
127
276
|
) }], coverage);
|
|
128
277
|
return resolveAdvisorySynthesis(report, ranked, {
|
|
129
278
|
impact: "Impact not restated by verification.",
|
|
@@ -142,27 +291,20 @@ export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">)
|
|
|
142
291
|
}
|
|
143
292
|
|
|
144
293
|
export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
|
|
145
|
-
|
|
146
|
-
const line = location.line != null ? `:${location.line}` : "";
|
|
147
|
-
const symbol = location.symbol ? ` (${location.symbol})` : "";
|
|
148
|
-
return `${location.file}${line}${symbol}`;
|
|
294
|
+
return formatAdvisoryLocation(primaryLocation(candidate));
|
|
149
295
|
}
|
|
150
296
|
|
|
151
297
|
export function formatEvidence(evidence: readonly string[]): string {
|
|
152
298
|
return evidence.join("; ");
|
|
153
299
|
}
|
|
154
300
|
|
|
155
|
-
export function normalizePath(path: string): string {
|
|
156
|
-
return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
301
|
const VERDICT_PRESENTATION = {
|
|
160
302
|
CONFIRMED: { lane: "Confirmed", status: "success", confidence: "high" },
|
|
161
303
|
PLAUSIBLE: { lane: "Plausible", status: "warning", confidence: "medium" },
|
|
162
304
|
NOT_SUBSTANTIATED: { lane: "Unresolved", status: "warning", confidence: "medium" },
|
|
163
305
|
REFUTED: { lane: "Refuted", status: "error", confidence: "low" },
|
|
164
306
|
} satisfies Record<AdvisoryVerdict["verdict"], {
|
|
165
|
-
lane: string; status: "success" | "warning" | "error"; confidence:
|
|
307
|
+
lane: string; status: "success" | "warning" | "error"; confidence: AdvisoryFinding["confidence"];
|
|
166
308
|
}>;
|
|
167
309
|
|
|
168
310
|
export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
|
|
@@ -173,11 +315,9 @@ export function recordVerdictProgress(
|
|
|
173
315
|
progress: (event: WorkflowProgressEvent) => void,
|
|
174
316
|
candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
|
|
175
317
|
verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
|
|
176
|
-
onRefuted?: () => void,
|
|
177
318
|
): void {
|
|
178
319
|
progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
|
|
179
320
|
if (verdict.verdict === "REFUTED") {
|
|
180
|
-
onRefuted?.();
|
|
181
321
|
progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
|
|
182
322
|
}
|
|
183
323
|
progress({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isRecord } from "./guards.ts";
|
|
2
3
|
import type { ResolvedWorkflowRunOptions } from "./options.ts";
|
|
3
4
|
import type { PerfAggregate, PerfSink, PerfSnapshot } from "./perf.ts";
|
|
4
5
|
import type { WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
@@ -11,14 +12,14 @@ import type {
|
|
|
11
12
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
12
13
|
|
|
13
14
|
export interface WorkflowPerfDetails {
|
|
14
|
-
readonly enabled: boolean;
|
|
15
|
-
readonly startedAt: number;
|
|
16
15
|
readonly aggregates: readonly PerfAggregate[];
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export interface WorkflowResultEnvelope {
|
|
20
19
|
readonly name: string;
|
|
21
20
|
readonly result: unknown;
|
|
21
|
+
/** Optional so envelopes persisted before it existed still render. */
|
|
22
|
+
readonly startedAt?: number;
|
|
22
23
|
readonly completedAt: number;
|
|
23
24
|
readonly usage?: WorkflowUsageSnapshot;
|
|
24
25
|
readonly perf?: WorkflowPerfDetails;
|
|
@@ -26,6 +27,12 @@ export interface WorkflowResultEnvelope {
|
|
|
26
27
|
readonly resumedFromRunId?: string;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
|
|
31
|
+
export function workflowResultSummary(result: unknown): string | undefined {
|
|
32
|
+
if (typeof result === "string") return result;
|
|
33
|
+
return isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export type ResolvedWorkflowRunner = (
|
|
30
37
|
ctx: ExtensionContext,
|
|
31
38
|
mod: LoadedWorkflow,
|
|
@@ -46,13 +53,8 @@ export interface WorkflowExecutionInput {
|
|
|
46
53
|
readonly onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
|
-
export interface WorkflowExecution {
|
|
50
|
-
readonly metadata?: WorkflowRunMetadata;
|
|
51
|
-
readonly envelope: WorkflowResultEnvelope;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
56
|
/** Shared command/tool execution path. The engine remains lazy-loaded by the caller. */
|
|
55
|
-
export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<
|
|
57
|
+
export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<WorkflowResultEnvelope> {
|
|
56
58
|
let perfSnapshot: PerfSnapshot | undefined;
|
|
57
59
|
let usageSnapshot: WorkflowUsageSnapshot | undefined;
|
|
58
60
|
let runMetadata: WorkflowRunMetadata | undefined;
|
|
@@ -86,27 +88,20 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
|
|
|
86
88
|
);
|
|
87
89
|
},
|
|
88
90
|
};
|
|
91
|
+
const startedAt = Date.now();
|
|
89
92
|
const result = await input.runResolvedWorkflow(input.ctx, input.mod, input.args, runOptions);
|
|
90
|
-
const perf = compactPerfSnapshot(perfSnapshot);
|
|
91
93
|
return {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
resumedFromRunId: runMetadata?.resumedFromRunId,
|
|
101
|
-
},
|
|
94
|
+
name: input.name,
|
|
95
|
+
result,
|
|
96
|
+
startedAt,
|
|
97
|
+
completedAt: Date.now(),
|
|
98
|
+
usage: usageSnapshot,
|
|
99
|
+
perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
|
|
100
|
+
runId: runMetadata?.runId,
|
|
101
|
+
resumedFromRunId: runMetadata?.resumedFromRunId,
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
|
|
106
|
-
if (!snapshot?.enabled) return undefined;
|
|
107
|
-
return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
105
|
async function notifyLifecycleObservers(...observers: ReadonlyArray<() => void | Promise<void>>): Promise<void> {
|
|
111
106
|
const failures: unknown[] = [];
|
|
112
107
|
for (const observer of observers) {
|
|
@@ -5,35 +5,33 @@ import type {
|
|
|
5
5
|
WorkflowSourceIdentity,
|
|
6
6
|
} from "./types.ts";
|
|
7
7
|
import type { WorktreeBaseline } from "./worktree.ts";
|
|
8
|
+
import { isRecord } from "./guards.ts";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
10
|
+
/** Validate and normalize workflow metadata without executing workflow code. */
|
|
11
|
+
export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
|
|
12
|
+
if (!isRecord(meta)) return { reason: "meta export must be an object" };
|
|
13
|
+
if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readonly default?: unknown;
|
|
18
|
-
};
|
|
15
|
+
const description = meta.description;
|
|
16
|
+
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
const phases = meta.phases;
|
|
19
|
+
if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
|
|
20
|
+
|
|
21
|
+
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
22
|
+
if (phases !== undefined) workflowMeta.phases = phases;
|
|
23
|
+
return { meta: workflowMeta };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
|
|
27
27
|
if (!isRecord(value)) return { reason: "module export is not an object" };
|
|
28
|
-
|
|
29
|
-
const meta = candidate.meta;
|
|
30
|
-
if (!isRecord(meta)) return { reason: "missing meta export" };
|
|
28
|
+
if (value.meta === undefined) return { reason: "missing meta export" };
|
|
31
29
|
|
|
32
|
-
const parsedMeta =
|
|
30
|
+
const parsedMeta = parseWorkflowMeta(value.meta);
|
|
33
31
|
if ("reason" in parsedMeta) return parsedMeta;
|
|
34
|
-
if (!isWorkflowRun(
|
|
32
|
+
if (!isWorkflowRun(value.default)) return { reason: "default export must be a function" };
|
|
35
33
|
|
|
36
|
-
return { module: { meta: parsedMeta.meta, default:
|
|
34
|
+
return { module: { meta: parsedMeta.meta, default: value.default } };
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
/** Attach engine-owned source provenance after an authored module has been validated. */
|
|
@@ -48,20 +46,6 @@ export function loadWorkflow(
|
|
|
48
46
|
: { ...loaded, isolatedWorktreeBaseline };
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
|
|
52
|
-
if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
|
|
53
|
-
|
|
54
|
-
const description = meta.description;
|
|
55
|
-
if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
|
|
56
|
-
|
|
57
|
-
const phases = meta.phases;
|
|
58
|
-
if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
|
|
59
|
-
|
|
60
|
-
const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
|
|
61
|
-
if (phases !== undefined) workflowMeta.phases = phases;
|
|
62
|
-
return { meta: workflowMeta };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
49
|
function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
66
50
|
return typeof value === "function";
|
|
67
51
|
}
|
|
@@ -69,7 +53,3 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
|
|
|
69
53
|
function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
|
|
70
54
|
return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
|
|
71
55
|
}
|
|
72
|
-
|
|
73
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
74
|
-
return typeof value === "object" && value !== null;
|
|
75
|
-
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
|
|
2
|
+
import { isRecord } from "./guards.ts";
|
|
3
|
+
import { toReviewIssues } from "./review/review-issues.ts";
|
|
4
|
+
import { formatPerfSummary } from "./perf.ts";
|
|
5
|
+
import { prettyJson } from "./text.ts";
|
|
6
|
+
import { formatWorkflowUsageLine } from "./usage.ts";
|
|
7
|
+
import { workflowResultSummary, type WorkflowResultEnvelope } from "./workflow-execution.ts";
|
|
8
|
+
|
|
9
|
+
/** Cap on the result JSON copied into the host agent's context; the run record keeps the full value. */
|
|
10
|
+
const MAX_CONTEXT_RESULT_JSON_CHARS = 20_000;
|
|
11
|
+
|
|
12
|
+
/** The host model only sees this text, never the envelope `details`, so it carries every result field. */
|
|
13
|
+
export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope): string {
|
|
14
|
+
const details = formatWorkflowDetailLines(envelope);
|
|
15
|
+
return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Run id, usage and perf lines appended for the host model. */
|
|
19
|
+
function formatWorkflowDetailLines(envelope: WorkflowResultEnvelope): string[] {
|
|
20
|
+
const run = envelope.runId
|
|
21
|
+
? envelope.resumedFromRunId ? `Run: ${envelope.runId} (resumed from ${envelope.resumedFromRunId})` : `Run: ${envelope.runId}`
|
|
22
|
+
: undefined;
|
|
23
|
+
return [
|
|
24
|
+
run,
|
|
25
|
+
formatWorkflowUsageLine(envelope.usage),
|
|
26
|
+
envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
|
|
27
|
+
].filter((line): line is string => line !== undefined);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatResultForContext(result: unknown): string {
|
|
31
|
+
if (typeof result === "string") return result;
|
|
32
|
+
if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
|
|
33
|
+
const summary = workflowResultSummary(result);
|
|
34
|
+
if (summary !== undefined && isRecord(result) && Object.keys(result).length === 1) return summary;
|
|
35
|
+
const json = formatResultJson(result);
|
|
36
|
+
if (json === undefined) return summary ?? "Workflow finished.";
|
|
37
|
+
return summary === undefined ? json : `${summary}\n\n${json}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatAdvisoryReportForContext(report: AdvisoryReport): string {
|
|
41
|
+
const lines = [report.summary];
|
|
42
|
+
const issues = toReviewIssues(report);
|
|
43
|
+
if (issues.length > 0) {
|
|
44
|
+
lines.push("", "Findings:");
|
|
45
|
+
for (const { id, finding } of issues) {
|
|
46
|
+
lines.push(
|
|
47
|
+
`\n### ${id}: ${finding.summary}`,
|
|
48
|
+
`- Severity: ${finding.severity}`,
|
|
49
|
+
`- Confidence: ${finding.confidence}`,
|
|
50
|
+
`- Category: ${finding.category}`,
|
|
51
|
+
`- Location: ${formatFindingLocations(finding)}`,
|
|
52
|
+
`- Impact: ${finding.impact}`,
|
|
53
|
+
`- Evidence: ${finding.evidence.length > 0 ? finding.evidence.join("; ") : "(none cited)"}`,
|
|
54
|
+
`- Recommendation: ${finding.recommendation}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (report.gaps && report.gaps.length > 0) {
|
|
59
|
+
lines.push("", "Coverage gaps:", ...report.gaps.map((gap) => `- ${gap}`));
|
|
60
|
+
}
|
|
61
|
+
if (report.nextSteps.length > 0) {
|
|
62
|
+
lines.push("", "Next steps:", ...report.nextSteps.map((step) => `- ${step}`));
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatResultJson(result: unknown): string | undefined {
|
|
68
|
+
if (result === null || result === undefined) return undefined;
|
|
69
|
+
const json = prettyJson(result);
|
|
70
|
+
if (json === undefined) return undefined;
|
|
71
|
+
if (json.length <= MAX_CONTEXT_RESULT_JSON_CHARS) return `Result:\n\`\`\`json\n${json}\n\`\`\``;
|
|
72
|
+
return `Result (first ${MAX_CONTEXT_RESULT_JSON_CHARS} of ${json.length} characters; truncated, the run record holds the full value):\n\`\`\`json\n${json.slice(0, MAX_CONTEXT_RESULT_JSON_CHARS)}\n\`\`\``;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The review anchor that the findings viewer and PR comments cite comes first, then every other cited location. */
|
|
76
|
+
function formatFindingLocations(finding: AdvisoryFinding): string {
|
|
77
|
+
const locations = finding.reviewAnchor ? [finding.reviewAnchor, ...finding.locations] : finding.locations;
|
|
78
|
+
return [...new Set(locations.map(formatAdvisoryLocation))].join(", ");
|
|
79
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isFiniteNumber, isRecord } from "./guards.ts";
|
|
2
|
+
import { truncateText } from "./text.ts";
|
|
1
3
|
import type { WorkflowBackgroundOrigin } from "./types.ts";
|
|
2
4
|
import type { WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
3
5
|
|
|
@@ -19,7 +21,7 @@ export function createPersistedWorkflowBackground(
|
|
|
19
21
|
if (!origin) return undefined;
|
|
20
22
|
return {
|
|
21
23
|
origin: {
|
|
22
|
-
sessionId:
|
|
24
|
+
sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
|
|
23
25
|
requestedAt: origin.requestedAt,
|
|
24
26
|
},
|
|
25
27
|
delivery: { state: "pending" },
|
|
@@ -38,7 +40,7 @@ export function updateWorkflowRunDelivery(
|
|
|
38
40
|
background: {
|
|
39
41
|
origin: record.background.origin,
|
|
40
42
|
delivery: delivery.state === "unavailable"
|
|
41
|
-
? { ...delivery, message:
|
|
43
|
+
? { ...delivery, message: truncateText(delivery.message, MAX_BACKGROUND_TEXT) }
|
|
42
44
|
: delivery,
|
|
43
45
|
},
|
|
44
46
|
};
|
|
@@ -58,15 +60,3 @@ export function isPersistedWorkflowBackground(value: unknown): value is Persiste
|
|
|
58
60
|
return false;
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
|
-
|
|
62
|
-
function boundedText(value: string): string {
|
|
63
|
-
return value.length <= MAX_BACKGROUND_TEXT ? value : `${value.slice(0, MAX_BACKGROUND_TEXT - 1)}…`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function isFiniteNumber(value: unknown): value is number {
|
|
67
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
71
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
-
}
|