pi-workflow-engine 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,24 +1,25 @@
1
- import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type AdvisoryFinding, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
- import { compactResults } from "./concurrency.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, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
3
3
  import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
4
4
 
5
+
5
6
  export interface AdvisoryLens {
6
7
  label: string;
7
8
  category: string;
8
9
  text: string;
9
10
  }
10
11
 
11
- export type AdvisoryVerified<Candidate extends AdvisoryCandidate = AdvisoryCandidate> = Candidate & {
12
+ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
12
13
  verdict: AdvisoryVerdict["verdict"];
13
14
  evidence: string[];
14
- confidence?: AdvisoryVerdict["confidence"];
15
+ challenge?: import("./advisory-challenge.ts").ChallengeRecord;
15
16
  };
16
17
 
17
- export interface LensVerificationPipelineResult<Verified> {
18
- verified: Verified[];
18
+ export interface LensVerificationPipelineResult {
19
+ verified: AdvisoryVerified[];
19
20
  rawCandidates: number;
20
21
  dropped: number;
21
- refuted: number;
22
+ coverage: AdvisoryStageCoverage[];
22
23
  }
23
24
 
24
25
  /** Default concrete read/inspect tools for advisory workflows. */
@@ -47,20 +48,14 @@ export function publishVerifiedKeptProgress(
47
48
  api.log(`${verified} verified → ${kept} kept`);
48
49
  }
49
50
 
50
- export type AdvisorySchedulingMode = "pipeline" | "finder-barrier";
51
-
52
- export interface LensVerificationPipelineOptions<Lens extends AdvisoryLens, Verified extends AdvisoryVerified> {
53
- api: Pick<WorkflowApi, "agent" | "parallel" | "pipeline" | "progress" | "log">;
54
- lenses: readonly Lens[];
51
+ export interface LensVerificationPipelineOptions {
52
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">;
53
+ lenses: readonly AdvisoryLens[];
55
54
  perLens: number;
56
- tools?: AgentOptions["tools"];
57
- toolHints?: AgentOptions["toolHints"];
58
- finderPhase?: string;
59
- verifierPhase?: string;
60
- schedulingMode?: AdvisorySchedulingMode;
61
- finderPrompt(lens: Lens): string;
62
- verifierPrompt(candidate: AdvisoryCandidate): string;
63
- makeVerified(candidate: AdvisoryCandidate, lens: Lens, verdict: AdvisoryVerdict): Verified;
55
+ finderPhase?: "Find" | "Hypothesize";
56
+ finderPrompt(lens: AdvisoryLens): string;
57
+ verifierPrompt(candidate: IdentifiedAdvisoryCandidate): string;
58
+ boundCandidate?(candidate: IdentifiedAdvisoryCandidate, lens: AdvisoryLens): IdentifiedAdvisoryCandidate | undefined;
64
59
  }
65
60
 
66
61
  export interface AdvisoryBackfillDefaults {
@@ -68,117 +63,124 @@ export interface AdvisoryBackfillDefaults {
68
63
  recommendation?: string;
69
64
  }
70
65
 
71
- interface FoundForLens<Lens> {
72
- lens: Lens;
73
- candidates: AdvisoryCandidate[];
74
- }
75
-
76
- interface NovelCandidate<Lens> {
77
- lens: Lens;
78
- candidate: AdvisoryCandidate;
79
- }
80
-
81
- export async function runLensVerificationPipeline<Lens extends AdvisoryLens, Verified extends AdvisoryVerified>(
82
- options: LensVerificationPipelineOptions<Lens, Verified>,
83
- ): Promise<LensVerificationPipelineResult<Verified>> {
84
- const {
85
- api,
86
- lenses,
87
- perLens,
88
- tools = DEFAULT_ADVISORY_TOOLS,
89
- toolHints = DEFAULT_ADVISORY_TOOL_HINTS,
90
- finderPhase = "Find",
91
- verifierPhase = "Verify",
92
- schedulingMode = "pipeline",
93
- finderPrompt,
94
- verifierPrompt,
95
- makeVerified,
96
- } = options;
97
- const seen = new Set<string>();
66
+ /** Finish all discovery before verification competes for the shared agent limit. */
67
+ export async function runLensVerificationPipeline(
68
+ options: LensVerificationPipelineOptions,
69
+ ): Promise<LensVerificationPipelineResult> {
70
+ const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
71
+ const coverage: AdvisoryStageCoverage[] = [];
98
72
  let rawCandidates = 0;
99
- let dropped = 0;
100
- let refuted = 0;
101
-
102
- const findForLens = async (lens: Lens): Promise<FoundForLens<Lens>> => {
103
- const found = await api.agent(finderPrompt(lens), {
104
- phase: finderPhase,
105
- label: `find:${lens.label}`,
106
- tools,
107
- toolHints,
108
- profile: "small",
109
- schema: AdvisoryCandidatesSchema,
73
+ api.phase(finderPhase);
74
+ const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
75
+ const result = await api.agent(finderPrompt(lens), {
76
+ phase: finderPhase, label: `${finderPhase.toLowerCase()}:${lens.label}`,
77
+ tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
78
+ profile: "small", schema: AdvisoryCandidatesSchema,
110
79
  });
111
- const candidates = (found?.candidates ?? []).slice(0, perLens);
112
- rawCandidates += candidates.length;
113
- api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: candidates.length });
80
+ const raw = identifyCandidates(result.candidates.slice(0, perLens), lens.label);
81
+ rawCandidates += raw.length;
82
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
83
+ const candidates = boundCandidate ? raw.flatMap((candidate) => {
84
+ const bounded = boundCandidate(candidate, lens);
85
+ return bounded ? [bounded] : [];
86
+ }) : raw;
114
87
  for (const candidate of candidates) {
115
- api.progress({
116
- type: "lane_item",
117
- lane: "Candidates",
118
- title: candidate.summary,
119
- subtitle: formatLocation(candidate),
120
- status: "pending",
121
- details: candidate.impact,
122
- });
88
+ api.progress({ type: "lane_item", lane: finderPhase === "Hypothesize" ? "Hypotheses" : "Candidates",
89
+ title: candidate.summary, subtitle: formatLocation(candidate), status: "pending", details: candidate.impact });
123
90
  }
124
- return { lens, candidates };
125
- };
91
+ return candidates;
92
+ } })), coverage);
93
+ const candidates = dedupeCandidates(found.flat());
94
+ const dropped = rawCandidates - candidates.length;
95
+ if (dropped > 0) {
96
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
97
+ api.log(`Dropped ${dropped} duplicate or out-of-scope candidate(s)`);
98
+ }
99
+ api.phase("Verify");
100
+ const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
101
+ id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
102
+ const location = primaryLocation(candidate);
103
+ const judged = await api.agent(`${verifierPrompt(candidate)}\nCandidate record: ${JSON.stringify(candidate)}`, {
104
+ phase: "Verify", label: `verify:${location.file.split("/").pop() ?? location.file}`,
105
+ tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
106
+ profile: "small", schema: AdvisoryVerdictSchema,
107
+ });
108
+ recordVerdictProgress(api.progress, candidate, judged);
109
+ return { ...candidate, verdict: judged.verdict, evidence: judged.evidence };
110
+ },
111
+ })), coverage);
112
+ return { verified, rawCandidates, dropped, coverage };
113
+ }
126
114
 
127
- const dedupeFound = (found: FoundForLens<Lens>): NovelCandidate<Lens>[] => {
128
- const novel = candidatesNovelToRun(found.candidates, seen).map((candidate) => ({ lens: found.lens, candidate }));
129
- const droppedForLens = found.candidates.length - novel.length;
130
- if (droppedForLens > 0) {
131
- dropped += droppedForLens;
132
- api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: droppedForLens });
133
- api.log(`find:${found.lens.label}: dropped ${droppedForLens} duplicate candidate(s)`);
134
- }
135
- return novel;
136
- };
115
+ /** Stats for a lens review that stopped before discovery. */
116
+ export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
117
+
118
+ export interface LensReviewConclusion {
119
+ /** Scoped file count reported in stats. */
120
+ files: number;
121
+ rank(finding: AdvisoryVerified): number;
122
+ /** One ranked finding's synthesis-prompt block, headed by the IDs synthesis selects; defaults to {@link formatRankedFinding}. */
123
+ formatFinding?(finding: AdvisoryVerified, index: number): string;
124
+ /** Report used when no finding survives verification and challenge. */
125
+ empty: { summary: string; nextSteps: string[] };
126
+ synthesisPrompt(block: string, ranked: readonly AdvisoryVerified[], refuted: readonly AdvisoryVerified[]): string;
127
+ }
137
128
 
138
- const verifyCandidate = async ({ lens, candidate }: NovelCandidate<Lens>): Promise<Verified | null> => {
139
- const location = primaryLocation(candidate);
140
- const judged = await api.agent(verifierPrompt(candidate), {
141
- phase: verifierPhase,
142
- label: `verify:${location.file.split("/").pop() ?? location.file}`,
143
- tools,
144
- toolHints,
145
- profile: "small",
146
- schema: AdvisoryVerdictSchema,
147
- });
148
- if (!judged) return null;
149
- recordVerdictProgress(api.progress, candidate, judged, () => {
150
- refuted += 1;
151
- });
152
- return makeVerified(candidate, lens, judged);
153
- };
129
+ export function formatRankedFinding(finding: AdvisoryVerified, index: number, recommendationLabel = "Recommendation"): string {
130
+ return `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
131
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\n${recommendationLabel}: ${finding.recommendation ?? "(none supplied)"}`;
132
+ }
154
133
 
155
- if (schedulingMode === "finder-barrier") {
156
- const found = await api.parallel(lenses.map((lens) => async () => findForLens(lens)));
157
- const novel = compactResults(found).flatMap(dedupeFound);
158
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
159
- return { verified: compactResults(verdicts), rawCandidates, dropped, refuted };
134
+ /** Rank and synthesize the findings that survived verification and challenge into the final report. */
135
+ export async function concludeLensReview(
136
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">,
137
+ pipeline: LensVerificationPipelineResult,
138
+ verified: AdvisoryVerified[],
139
+ conclusion: LensReviewConclusion,
140
+ ) {
141
+ const { coverage } = pipeline;
142
+ const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
143
+ const stats: WorkflowRunStats = {
144
+ files: conclusion.files,
145
+ candidates: pipeline.rawCandidates,
146
+ verified: verified.length,
147
+ kept: surviving.length,
148
+ dropped: pipeline.dropped,
149
+ refuted: verified.length - surviving.length,
150
+ };
151
+ publishVerifiedKeptProgress(api, verified.length, surviving.length);
152
+ if (surviving.length === 0) {
153
+ return finishAdvisoryReport(emptyAdvisoryReport(conclusion.empty.summary, conclusion.empty.nextSteps, stats), coverage, verified);
160
154
  }
161
155
 
162
- const perLensVerified = await api.pipeline(
163
- lenses,
164
- async (_prev, lens) => findForLens(lens),
165
- async (found) => {
166
- const novel = dedupeFound(found);
167
- const verdicts = await api.parallel(novel.map((entry) => async () => verifyCandidate(entry)));
168
- return compactResults(verdicts);
169
- },
170
- );
156
+ const ranked = [...surviving].sort((a, b) => conclusion.rank(a) - conclusion.rank(b));
157
+ const block = ranked.map((finding, index) => (conclusion.formatFinding ?? formatRankedFinding)(finding, index)).join("\n\n");
158
+ const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
159
+ const resolved = await synthesizeAdvisoryReport(api, conclusion.synthesisPrompt(block, ranked, refuted), ranked, coverage);
160
+ return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length } }, coverage, verified);
161
+ }
171
162
 
172
- return { verified: compactResults(perLensVerified).flat(), rawCandidates, dropped, refuted };
163
+ export async function synthesizeAdvisoryReport(
164
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase">,
165
+ prompt: string,
166
+ ranked: readonly AdvisoryVerified[],
167
+ coverage: AdvisoryStageCoverage[],
168
+ ): Promise<AdvisoryReport> {
169
+ api.phase("Synthesize");
170
+ const [report] = await collectAdvisoryStage(api, "Synthesize", [{ id: "synthesize", run: () => api.agent(
171
+ SYNTHESIS_ID_INSTRUCTIONS + prompt,
172
+ { phase: "Synthesize", label: "synthesize", tools: [], skills: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
173
+ ) }], coverage);
174
+ return resolveAdvisorySynthesis(report, ranked, {
175
+ impact: "Impact not restated by verification.",
176
+ recommendation: "Inspect the cited evidence and validate the smallest repair.",
177
+ }, coverage);
173
178
  }
174
179
 
175
- function candidatesNovelToRun(candidates: readonly AdvisoryCandidate[], seen: Set<string>): AdvisoryCandidate[] {
176
- return candidates.filter((candidate) => {
177
- const key = advisoryDedupKey(candidate);
178
- if (seen.has(key)) return false;
179
- seen.add(key);
180
- return true;
181
- });
180
+ export function finishAdvisoryReport<T extends AdvisoryReport>(
181
+ report: T, coverage: AdvisoryStageCoverage[], verification: AdvisoryVerified[] = [],
182
+ ) {
183
+ return { ...withAdvisoryCoverage(report, coverage), verification };
182
184
  }
183
185
 
184
186
  export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">): AdvisoryLocation {
@@ -186,107 +188,95 @@ export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">)
186
188
  }
187
189
 
188
190
  export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
189
- const location = primaryLocation(candidate);
190
- const line = location.line != null ? `:${location.line}` : "";
191
- const symbol = location.symbol ? ` (${location.symbol})` : "";
192
- return `${location.file}${line}${symbol}`;
191
+ return formatAdvisoryLocation(primaryLocation(candidate));
193
192
  }
194
193
 
195
194
  export function formatEvidence(evidence: readonly string[]): string {
196
195
  return evidence.join("; ");
197
196
  }
198
197
 
199
- export function normalizePath(path: string): string {
200
- return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
201
- }
202
-
203
- export function advisoryDedupKey(candidate: AdvisoryCandidate): string {
204
- const location = primaryLocation(candidate);
205
- const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : "file";
206
- return `${candidate.category}:${normalizePath(location.file)}:${lineKey}:${candidate.summary.slice(0, 60).toLowerCase()}`;
207
- }
208
-
209
- export function verdictLane(verdict: AdvisoryVerdict["verdict"]): string {
210
- switch (verdict) {
211
- case "CONFIRMED":
212
- return "Confirmed";
213
- case "PLAUSIBLE":
214
- return "Plausible";
215
- case "REFUTED":
216
- return "Refuted";
217
- }
218
- }
219
-
220
- export function verdictStatus(verdict: AdvisoryVerdict["verdict"]): "success" | "warning" | "error" {
221
- switch (verdict) {
222
- case "CONFIRMED":
223
- return "success";
224
- case "PLAUSIBLE":
225
- return "warning";
226
- case "REFUTED":
227
- return "error";
228
- }
229
- }
198
+ const VERDICT_PRESENTATION = {
199
+ CONFIRMED: { lane: "Confirmed", status: "success", confidence: "high" },
200
+ PLAUSIBLE: { lane: "Plausible", status: "warning", confidence: "medium" },
201
+ NOT_SUBSTANTIATED: { lane: "Unresolved", status: "warning", confidence: "medium" },
202
+ REFUTED: { lane: "Refuted", status: "error", confidence: "low" },
203
+ } satisfies Record<AdvisoryVerdict["verdict"], {
204
+ lane: string; status: "success" | "warning" | "error"; confidence: AdvisoryFinding["confidence"];
205
+ }>;
230
206
 
231
207
  export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
232
- switch (verdict) {
233
- case "CONFIRMED":
234
- return "high";
235
- case "PLAUSIBLE":
236
- return "medium";
237
- case "REFUTED":
238
- return "low";
239
- }
240
- }
241
-
242
- export function sameFinding(candidate: Pick<AdvisoryCandidate, "locations">, finding: Pick<AdvisoryFinding, "locations" | "summary">): boolean {
243
- return findingLocationKey(candidate) === findingLocationKey(finding);
244
- }
245
-
246
- export function findingLocationKey(value: Pick<AdvisoryCandidate, "locations"> | Pick<AdvisoryFinding, "locations">): string {
247
- const location = primaryLocation(value);
248
- return `${normalizePath(location.file)}:${location.line ?? "file"}`;
208
+ return VERDICT_PRESENTATION[verdict].confidence;
249
209
  }
250
210
 
251
211
  export function recordVerdictProgress(
252
212
  progress: (event: WorkflowProgressEvent) => void,
253
213
  candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
254
214
  verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
255
- onRefuted?: () => void,
256
215
  ): void {
257
216
  progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
258
217
  if (verdict.verdict === "REFUTED") {
259
- onRefuted?.();
260
218
  progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
261
219
  }
262
220
  progress({
263
221
  type: "lane_item",
264
- lane: verdictLane(verdict.verdict),
222
+ lane: VERDICT_PRESENTATION[verdict.verdict].lane,
265
223
  title: candidate.summary,
266
224
  subtitle: formatLocation(candidate),
267
- status: verdictStatus(verdict.verdict),
225
+ status: VERDICT_PRESENTATION[verdict.verdict].status,
268
226
  details: formatEvidence(verdict.evidence),
269
227
  });
270
228
  }
271
229
 
272
230
  export function backfillAdvisoryFindings<Source extends AdvisoryVerified>(
273
- findings: AdvisoryReport["findings"],
231
+ findings: AdvisorySynthesis["findings"],
274
232
  ranked: readonly Source[],
275
233
  defaults: AdvisoryBackfillDefaults,
276
234
  ): AdvisoryReport["findings"] {
277
- const rankedByLocation = new Map<string, Source>();
278
- for (const candidate of ranked) {
279
- const key = findingLocationKey(candidate);
280
- if (!rankedByLocation.has(key)) rankedByLocation.set(key, candidate);
281
- }
235
+ const sources = new Map(ranked.flatMap((source) => source.sourceCandidateIds.map((id) => [id, source] as const)));
236
+ const used = new Set<string>();
237
+ return findings.flatMap((selection) => {
238
+ const ids = [...new Set(selection.sourceCandidateIds)];
239
+ if (ids.length === 0 || ids.some((id) => !sources.has(id) || used.has(id))) return [];
240
+ const records = [...new Set(ids.map((id) => sources.get(id)!))];
241
+ if (records.some((record) => record.verdict === "REFUTED")) return [];
242
+ const sourceCandidateIds = [...new Set(records.flatMap((record) => record.sourceCandidateIds))];
243
+ if (sourceCandidateIds.some((id) => used.has(id))) return [];
244
+ sourceCandidateIds.forEach((id) => used.add(id));
245
+ const first = records[0]!;
246
+ return [{
247
+ sourceCandidateIds,
248
+ summary: [...new Set(records.map((record) => record.summary))].join("; "),
249
+ category: first.category,
250
+ severity: selection.severity,
251
+ verdict: records.some((record) => record.verdict === "NOT_SUBSTANTIATED") ? "NOT_SUBSTANTIATED" as const : records.some((record) => record.verdict === "PLAUSIBLE") ? "PLAUSIBLE" as const : "CONFIRMED" as const,
252
+ confidence: records.every((record) => record.verdict === "CONFIRMED") ? "high" as const : "medium" as const,
253
+ locations: uniqueLocations(records.flatMap((record) => record.locations)),
254
+ ...(first.reviewAnchor ? { reviewAnchor: first.reviewAnchor } : {}),
255
+ evidence: [...new Set(records.flatMap((record) => [...(record.discoveryEvidence ?? []), ...record.evidence]))],
256
+ impact: [...new Set(records.map((record) => record.impact || defaults.impact))].join("\n"),
257
+ recommendation: selection.recommendation || first.recommendation || defaults.recommendation || "",
258
+ }];
259
+ });
260
+ }
282
261
 
283
- return findings.map((finding) => {
284
- const source = rankedByLocation.get(findingLocationKey(finding));
262
+ /** Retain verified records if synthesis fails, and expose invalid ID selections as a gap. */
263
+ export function resolveAdvisorySynthesis<Source extends AdvisoryVerified>(
264
+ report: AdvisorySynthesis | undefined,
265
+ ranked: readonly Source[],
266
+ defaults: AdvisoryBackfillDefaults,
267
+ coverage: AdvisoryStageCoverage[],
268
+ ): AdvisoryReport {
269
+ if (!report) {
285
270
  return {
286
- ...finding,
287
- evidence: finding.evidence.length > 0 ? finding.evidence : (source?.evidence ?? []),
288
- impact: finding.impact || source?.impact || defaults.impact,
289
- recommendation: finding.recommendation || source?.recommendation || defaults.recommendation || "",
271
+ summary: "Synthesis unavailable; verified records retained.",
272
+ findings: backfillAdvisoryFindings(ranked.map((finding) => ({ sourceCandidateIds: finding.sourceCandidateIds, severity: "medium", recommendation: finding.recommendation ?? defaults.recommendation ?? "Inspect verifier evidence." })), ranked, defaults),
273
+ nextSteps: ["Inspect verifier evidence or rerun synthesis."],
290
274
  };
291
- });
275
+ }
276
+ const findings = backfillAdvisoryFindings(report.findings, ranked, defaults);
277
+ if (findings.length !== report.findings.length) {
278
+ coverage.push({ stage: "Synthesis provenance", expected: report.findings.length, completed: findings.length,
279
+ failed: report.findings.length - findings.length, failures: [{ branch: "synthesize", reason: "Discarded invalid, repeated, or unverified candidate IDs." }] });
280
+ }
281
+ return { ...report, findings };
292
282
  }
@@ -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<WorkflowExecution> {
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
- metadata: runMetadata,
93
- envelope: {
94
- name: input.name,
95
- result,
96
- completedAt: Date.now(),
97
- usage: usageSnapshot,
98
- perf,
99
- runId: runMetadata?.runId,
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
- type WorkflowMetaCandidate = {
10
- readonly name?: unknown;
11
- readonly description?: unknown;
12
- readonly phases?: unknown;
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
- type WorkflowModuleCandidate = {
16
- readonly meta?: unknown;
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
- /** Validate and normalize workflow metadata without executing workflow code. */
21
- export function parseWorkflowMeta(value: unknown): { meta: WorkflowMeta } | { reason: string } {
22
- if (!isRecord(value)) return { reason: "meta export must be an object" };
23
- return parseWorkflowMetaObject(value);
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
- const candidate = value as WorkflowModuleCandidate;
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 = parseWorkflowMetaObject(meta);
30
+ const parsedMeta = parseWorkflowMeta(value.meta);
33
31
  if ("reason" in parsedMeta) return parsedMeta;
34
- if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
32
+ if (!isWorkflowRun(value.default)) return { reason: "default export must be a function" };
35
33
 
36
- return { module: { meta: parsedMeta.meta, default: candidate.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
- }